diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c5e202e03..0149d953a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -107,12 +107,16 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ + 'append_direct_project_conversation_message', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', + 'read_direct_project_conversation', 'stop_local_game_preview_if_matches', + 'start_game_creator_external_mcp', + 'stop_game_creator_external_mcp', ]; const sourceExtensions = new Set([ '.json', diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 397d88dcf..64577d35f 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) { }); } +function readBackendServiceFailure( + state, + { + expectedDatabase = backendDatabase, + expectedSpacetimeDataDir = backendSpacetimeDataDir, + } = {}, +) { + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir, + }); + if (!targets.hasMatchingBackend) { + return null; + } + + for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) { + const service = state?.services?.[serviceName]; + if (service?.status !== 'failed') { + continue; + } + + return { + serviceName, + failure: service.signal + ? `signal=${service.signal}` + : `code=${service.exitCode ?? 1}`, + }; + } + + return null; +} + async function isBackendReady({ state = readJson(devStackStatePath), isReady = isHttpReady, @@ -505,11 +538,29 @@ async function terminateChildTree( return { stopped, forced: true }; } -async function waitForBackendReady(backendChild, timeoutMs = 600_000) { +async function waitForBackendReady( + backendChild, + timeoutMs = 600_000, + { + checkBackendReady = isBackendReady, + readState = () => readJson(devStackStatePath), + resolveTargets = readBackendTargets, + } = {}, +) { + const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - if (await isBackendReady()) { - return readBackendTargets(); + if (await checkBackendReady()) { + return resolveTargets(); + } + const state = readState(); + if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) { + const serviceFailure = readBackendServiceFailure(state); + if (serviceFailure) { + throw new Error( + `配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`, + ); + } } const failure = readChildFailure(backendChild); if (failure) { @@ -686,6 +737,7 @@ export { isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readChildFailure, readExistingViteServer, readLinuxProcessGroupAlive, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index bb0f70aee..cc37945ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,8 @@ mod codex_cli; mod codex_provider_proxy; mod direct_codex_attachments; mod direct_codex_audit; +mod direct_project_history; +mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -38,6 +40,8 @@ pub(crate) use codex_cli::{ pub(crate) use codex_provider_proxy::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; +pub(crate) use direct_project_history::*; +pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 848a8882e..b31ae894a 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.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, Mutex, Notify}; +use uuid::Uuid; 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"; @@ -443,13 +444,17 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { - AgentMessageDelta(String), + AgentMessageDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { completed: bool, params: serde_json::Value, }, + RawItem(serde_json::Value), Terminal(serde_json::Value), TransportClosed(String), } @@ -869,11 +874,28 @@ fn direct_codex_notification_event( .get("delta") .and_then(serde_json::Value::as_str) .filter(|value| !value.trim().is_empty()) - .map(|delta| CodexTurnEvent::AgentMessageDelta(delta.to_string())), + .map(|delta| { + let item_id = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()); + CodexTurnEvent::AgentMessageDelta { + item_id, + delta: delta.to_string(), + } + }), "item/started" | "item/completed" => Some(CodexTurnEvent::Item { completed: method == "item/completed", params: params.clone(), }), + "rawResponseItem/completed" => Some(CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + )), _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -1312,6 +1334,9 @@ fn codex_app_server_thread_start_params( params["modelProvider"] = serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + params["experimentalRawEvents"] = serde_json::Value::Bool(true); + } params } @@ -1321,6 +1346,7 @@ fn codex_app_server_turn_start_params( model: &str, workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, + client_user_message_id: Option<&str>, ) -> serde_json::Value { let approval_policy = "never"; let mut params = serde_json::json!({ @@ -1339,6 +1365,13 @@ fn codex_app_server_turn_start_params( "networkAccess": true }); } + if let Some(client_user_message_id) = client_user_message_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + params["clientUserMessageId"] = + serde_json::Value::String(client_user_message_id.to_string()); + } params } @@ -2551,32 +2584,48 @@ impl CodexAppServerConnection { mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; + let mut request = request; + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let current_prompt = direct_codex_current_user_prompt(&request).trim(); + if current_prompt.is_empty() { + return Err(platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + )); + } + 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)?; + append_direct_project_user_message_at(history_root, &user_item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + } + } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); - let mut request = request; - if thread_created && self.inner.workspace_mode.uses_direct_conversation() { - // DirectProject owns the append-only project history in AGC. The - // replay builder derives a bounded prompt without mutating that - // durable fact source, so a new ephemeral thread can recover the - // newest contiguous context within the model budget. - let current_prompt = direct_codex_current_user_prompt(&request).to_string(); - let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); - let history_prompt = build_direct_codex_history_prompt( - history_root, - direct_client_turn_id.unwrap_or("__none__"), - ¤t_prompt, - &request, - llm, - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; - if let Some(message) = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - { - message.content = history_prompt; + 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)); + } + }; + if let Err(error) = self + .request( + "thread/inject_items", + serde_json::json!({"threadId": thread_id, "items": items}), + ) + .await + { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::Transport(error)); + } } } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { @@ -2598,7 +2647,7 @@ impl CodexAppServerConnection { "AGC 直连项目缺少客户端受控工具桥".to_string(), ) })? - .begin_user_turn(direct_codex_current_user_prompt(&request)) + .begin_user_turn() .map_err(platform_llm::LlmError::InvalidRequest)?, ) } else { @@ -2615,6 +2664,7 @@ impl CodexAppServerConnection { model, &self.inner.workspace_path, self.inner.workspace_mode, + direct_client_turn_id, ); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { @@ -2659,6 +2709,7 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let mut receiver = self.register_turn(&turn_id).await; + let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { connection: self.clone(), thread_id: thread_id.clone(), @@ -2705,7 +2756,10 @@ impl CodexAppServerConnection { } }; match event { - Some(CodexTurnEvent::AgentMessageDelta(delta)) => { + Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + direct_project_history.observe_delta(&item_id, &delta); + } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::AccumulatedText( @@ -2725,6 +2779,28 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::IntermediateText(text)); } } + Some(CodexTurnEvent::RawItem(item)) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if item.is_null() { + return Err(platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + )); + } + let history_root = history_root.to_path_buf(); + let history_item = item.clone(); + tokio::task::spawn_blocking(move || { + append_direct_project_history_item_at(&history_root, &history_item) + }) + .await + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "DirectProject 历史落盘任务失败:{error}" + )) + })? + .map_err(platform_llm::LlmError::InvalidRequest)?; + direct_project_history.complete_item(&item); + } + } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::Activity(activity)); @@ -2843,7 +2919,22 @@ impl CodexAppServerConnection { } } }; - let text = collect.await?; + let text = match collect.await { + Ok(text) => text, + Err(error) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if let Err(persist_error) = persist_direct_project_partial_items_at( + history_root, + &mut direct_project_history, + ) { + return Err(platform_llm::LlmError::Transport(format!( + "DirectProject 异常收尾历史失败:{persist_error};原始回合错误:{error}" + ))); + } + } + return Err(error); + } + }; guard.armed = false; self.inner.turns.lock().await.remove(&turn_id); let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; @@ -3220,7 +3311,11 @@ async fn read_game_creator_codex_app_server_stdout( ); if !matches!( method, - "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" + "item/agentMessage/delta" + | "item/started" + | "item/completed" + | "rawResponseItem/completed" + | "turn/completed" ) && safe_activity.is_none() && intermediate_text.is_none() { @@ -3258,14 +3353,57 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = match direct_codex_notification_event( - method, - ¶ms, - intermediate_text, - safe_activity, - ) { - Some(event) => event, - None => continue, + let event = if let Some(activity) = safe_activity { + // Preparing notifications may carry private plan/reasoning text; + // expose only the safe activity category. Other categories may + // retain their bounded, redacted intermediate text below. + if activity == "preparing" { + CodexTurnEvent::Activity(activity) + } else if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else { + CodexTurnEvent::Activity(activity) + } + } else if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else { + match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let item_id = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); + CodexTurnEvent::AgentMessageDelta { + item_id, + delta: delta.to_string(), + } + } + "item/started" | "item/completed" => CodexTurnEvent::Item { + completed: method == "item/completed", + params, + }, + "rawResponseItem/completed" => CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + ), + _ => CodexTurnEvent::Terminal(params), + } }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -3657,13 +3795,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); + let generated_client_turn_id; + let effective_client_turn_id = match client_turn_id { + Some(client_turn_id) => Some(client_turn_id), + None => { + generated_client_turn_id = format!("direct-cli-{}", Uuid::new_v4()); + Some(generated_client_turn_id.as_str()) + } + }; connection .run_turn_with_direct_observer_and_history( &snapshot, &config.llm, request, Some(&codex_root), - client_turn_id, + effective_client_turn_id, None, observer, audit, @@ -3673,155 +3819,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } -/// Builds the prompt used when a new DirectProject thread needs replay. -/// -/// The JSONL history remains immutable; only the derived prompt is bounded. -/// When the full request exceeds the replay target, the oldest contiguous -/// records are omitted and a plain `system:` marker is prepended. -pub(crate) fn build_direct_codex_history_prompt( - root: &std::path::Path, - client_turn_id: &str, - current_prompt: &str, - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, -) -> Result { - let conversation = read_local_conversation_for_session_at(root, None, None)?; - let current_message_id = format!("direct-codex:{client_turn_id}:user"); - // The deliberately simple role-prefix format is part of the Direct - // replay contract. Do not introduce an envelope or implicit escaping - // here without updating the persisted-history compatibility decision. - let lines = conversation - .messages - .iter() - .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) - .map(|message| format!("{}: {}", message.role, message.content)) - .collect::>(); - let current_line = format!("user: {}", current_prompt.trim()); - let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); - let target_budget = direct_codex_replay_target_budget(llm, base_request)?; - if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { - return Ok(full_prompt); - } - - // The persisted project conversation is immutable. We only derive a - // bounded prompt for this replay, keeping the newest contiguous records. - let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; - let mut selected_reversed = Vec::new(); - for line in lines.iter().rev() { - let mut candidate_reversed = selected_reversed.clone(); - candidate_reversed.push(line.as_str()); - let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); - let candidate_prompt = format_direct_codex_replay_prompt( - &candidate_lines, - ¤t_line, - Some(omission_marker), - ); - if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { - selected_reversed.push(line.as_str()); - } else { - break; - } - } - - let selected_lines = selected_reversed.iter().rev().copied().collect::>(); - let marked_prompt = - format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); - if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { - return Ok(marked_prompt); - } - - // If the marker itself would push the request over the target, preserve - // the current user request and omit only the marker. - let current_only_prompt = - format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); - if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { - return Ok(current_only_prompt); - } - direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) -} - -const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = - "system: Earlier conversation history was omitted due to context budget."; - -fn format_direct_codex_replay_prompt( - history_lines: &[impl AsRef], - current_line: &str, - omission_marker: Option<&str>, -) -> String { - let mut lines = Vec::with_capacity(history_lines.len() + 2); - if let Some(marker) = omission_marker { - lines.push(marker.to_string()); - } - lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); - lines.push(current_line.to_string()); - lines.join("\n") -} - -fn direct_codex_replay_target_budget( - llm: &GameCreatorLlmConfig, - request: &LlmRunRequest, -) -> Result { - const SAFETY_MARGIN_TOKENS: u64 = 4_096; - let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); - let hard_budget = llm - .context_window_tokens - .checked_sub(max_output_tokens) - .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) - .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; - Ok(llm.auto_compact_token_limit.min(hard_budget)) -} - -fn direct_codex_replay_estimate( - base_request: &LlmRunRequest, - prompt: &str, -) -> Result { - let mut request = base_request.clone(); - let user = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; - user.content = prompt.to_string(); - Ok(request) -} - -fn direct_codex_replay_prompt_fits( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, - target_budget: u64, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - if estimated > target_budget { - return Ok(false); - } - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(true) -} - -fn direct_codex_replay_validate_context_budget( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(prompt.to_string()) -} - /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3903,123 +3900,27 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result }) } +#[cfg(test)] +pub(crate) fn build_direct_codex_history_prompt( + root: &Path, + _client_turn_id: &str, + current_prompt: &str, + _request: &LlmRunRequest, + _llm: &GameCreatorLlmConfig, +) -> Result { + let mut lines = read_direct_project_chat_history_at(root)? + .messages + .into_iter() + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + lines.push(format!("user: {}", current_prompt.trim())); + Ok(lines.join("\n")) +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn direct_history_prompt_replays_all_project_messages_in_order() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "history-project", "history").expect("init"); - for (role, content, message_id) in [ - ("user", "hello", "direct-codex:old:user"), - ( - "assistant", - "partial\nunexpected interrupt happened here", - "partial-id", - ), - ("tool", "file-read result", "tool-id"), - ("user", "stored raw request", "direct-codex:new-turn:user"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content: content.to_string(), - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &test_llm(), - ) - .expect("build prompt"); - assert_eq!( - prompt, - "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" - ); - } - - #[test] - fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "window-project", "window").expect("init"); - for (role, content, message_id) in [ - ("user", "old ".repeat(1_000), "old-user"), - ("assistant", "middle ".repeat(100), "middle-assistant"), - ("tool", "newest ".repeat(100), "newest-tool"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content, - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let mut llm = test_llm(); - llm.auto_compact_token_limit = 800; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let before = - std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &llm, - ) - .expect("build prompt"); - let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - - assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); - assert!(prompt.contains("tool: newest")); - assert!(!prompt.contains("user: old")); - assert!(prompt.ends_with("user: new request")); - assert_eq!(before, after); - } - - #[test] - fn direct_history_prompt_rejects_current_request_that_exceeds_context() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); - let mut llm = test_llm(); - llm.context_window_tokens = 5_000; - llm.auto_compact_token_limit = 1_000; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(100); - let error = build_direct_codex_history_prompt( - root.path(), - "new-turn", - &"request ".repeat(10_000), - &request, - &llm, - ) - .expect_err("oversized request should fail"); - assert!(error.contains("Direct replay 请求")); - } - #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ @@ -4459,6 +4360,7 @@ mod tests { "fixture-model", workspace, CodexAppServerWorkspaceMode::DirectHome, + None, ); assert_eq!(turn["threadId"], "home-thread"); assert_eq!(turn["approvalPolicy"], "never"); @@ -4519,7 +4421,9 @@ mod tests { "fixture-model", &workspace, CodexAppServerWorkspaceMode::DirectProject, + Some("direct-turn-0001"), ); + assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!( turn.pointer("/sandboxPolicy/writableRoots/0"), Some(&serde_json::json!(workspace)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs new file mode 100644 index 000000000..42777dcfa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -0,0 +1,467 @@ +use crate::config::prepare_game_creator_private_path_for_read; +use crate::project::{ + append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, +}; +use crate::{LocalConversationMessageRecord, LocalConversationResult}; +use serde_json::Value; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; +const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ + "host_skills.instructions", + "permissions.instructions", + "environments.environment_context", +]; + +const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[ + ("# AGENTS.md instructions", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), +]; + +fn is_known_contextual_user_text(text: &str) -> bool { + let text = text.trim(); + if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS + .iter() + .any(|(start, end)| text.starts_with(start) && text.ends_with(end)) + { + return true; + } + if text.starts_with("") { + return true; + } + text.starts_with("') + .and_then(|(start, _)| start.strip_prefix(""))) +} + +pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { + if matches!( + item.get("role").and_then(Value::as_str), + Some("developer" | "system") + ) { + return true; + } + if item + .pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") + .and_then(Value::as_array) + .is_some_and(|kinds| { + kinds + .iter() + .filter_map(Value::as_str) + .any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind)) + }) + { + return true; + } + item.get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + let Some(text) = part.get("text").and_then(Value::as_str) else { + return false; + }; + is_known_contextual_user_text(text) + }) + }) +} + +fn history_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/project.jsonl") +} + +fn record(item: &Value) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, + "payload": item, + })) + .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) +} + +const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; + +fn find_direct_project_history_item_by_id_at( + path: &Path, + item_id: &str, +) -> Result, String> { + let mut file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + let mut pending = Vec::new(); + let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; + + loop { + if position == 0 { + break; + } + let read_len = usize::try_from(position) + .unwrap_or(usize::MAX) + .min(chunk.len()); + position -= read_len as u64; + file.seek(SeekFrom::Start(position)) + .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; + file.read_exact(&mut chunk[..read_len]) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + + let mut combined = Vec::with_capacity(read_len + pending.len()); + combined.extend_from_slice(&chunk[..read_len]); + combined.extend_from_slice(&pending); + let mut line_end = combined.len(); + while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { + let line = &combined[newline + 1..line_end]; + if !line.is_empty() { + if let Some(item) = direct_project_history_item_from_line(path, line)? { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + line_end = newline; + } + pending = combined[..line_end].to_vec(); + } + + if !pending.is_empty() { + // The append path repairs an unterminated final JSONL record before + // writing. A duplicate scan must not reject that repairable tail. + if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + Ok(None) +} + +fn direct_project_history_item_from_line( + path: &Path, + line: &[u8], +) -> Result, String> { + if line.iter().all(|byte| byte.is_ascii_whitespace()) { + return Ok(None); + } + let parsed: Value = serde_json::from_slice(line) + .map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + return Ok(None); + } + Ok(Some(item)) +} + +pub(crate) fn append_direct_project_history_item_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, false) +} + +/// Appends a user message authored by AGC itself. Codex response items use +/// the default path above, which deliberately ignores echoed user messages; +/// this explicit entry point keeps the two sources distinct. +pub(crate) fn append_direct_project_user_message_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, true) +} + +fn append_direct_project_history_item_at_with_user_policy( + root: &Path, + item: &Value, + allow_user_item: bool, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + if is_direct_project_internal_context_item(item) { + return Ok(()); + } + if !allow_user_item && is_direct_project_codex_user_item(item) { + return Ok(()); + } + let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; + let path = history_path(root); + let history_exists = + prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("DirectProject 历史追加写")?; + if history_exists { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? { + if &existing == item { + return Ok(()); + } + return Err(format!("DirectProject 历史 item id 冲突:{item_id}")); + } + } + } + let line = record(item)?; + append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") +} + +fn is_direct_project_codex_user_item(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) == Some("userMessage") { + return true; + } + if item.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + !item + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user")) +} + +pub(crate) fn direct_project_local_message_item( + role: &str, + content: &str, + message_id: Option<&str>, +) -> Result { + let role = role.trim(); + let content = content.trim(); + if content.is_empty() || !matches!(role, "user" | "assistant") { + return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string()); + } + let mut item = serde_json::json!({ + "type": "message", + "role": role, + "content": [{ + "type": if role == "user" { "input_text" } else { "output_text" }, + "text": content, + }], + }); + if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) { + item["id"] = Value::String(message_id.to_string()); + } + Ok(item) +} + +pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok(Vec::new()); + } + let file = File::open(&path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut items = Vec::new(); + let mut reader = BufReader::new(file); + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + if bytes == 0 { + break; + } + let had_newline = line.ends_with('\n'); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(_error) if !had_newline => break, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )); + } + }; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + continue; + } + items.push(item); + } + Ok(items) +} + +pub(crate) fn read_direct_project_chat_history_at( + root: &Path, +) -> Result { + let path = history_path(root); + let items = read_direct_project_history_items_at(root)?; + let messages = items + .into_iter() + .filter_map(|item| { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + let content = item + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("") + }) + .unwrap_or_default(); + (!content.is_empty()).then(|| LocalConversationMessageRecord { + schema_version: "agc-direct-project-context.v1".to_string(), + role: role.to_string(), + content, + agent_id: None, + message_id: item.get("id").and_then(Value::as_str).map(str::to_string), + updated_at: 0, + }) + }) + .collect(); + Ok(LocalConversationResult { + path: path.to_string_lossy().into_owned(), + agent_id: None, + session_id: None, + messages, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + append_direct_project_history_item_at, append_direct_project_user_message_at, history_path, + is_direct_project_internal_context_item, read_direct_project_history_items_at, + }; + use serde_json::json; + + #[test] + fn filters_host_context_but_keeps_real_user_items() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": ""}] + }))); + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": ""}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["environments.environment_context"] + } + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "ls ui"}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["user.text"] + } + }))); + } + + #[test] + fn filters_codex_contextual_user_item_without_passthrough_metadata() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n/tmp/project\n" + }] + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "请读取 中的说明" + }] + }))); + } + + #[test] + fn append_repairs_truncated_tail_before_idempotency_scan() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复") + .expect("init project"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + std::fs::write( + &path, + br#"{"type":"response_item","payload":{"type":"message"}"#, + ) + .expect("write truncated history tail"); + let item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": "tail-repair-item", + "content": [{"type": "output_text", "text": "已修复"}] + }); + append_direct_project_history_item_at(root.path(), &item).expect("repair and append"); + let items = + read_direct_project_history_items_at(root.path()).expect("read repaired history"); + assert_eq!(items, vec![item]); + } + + #[test] + fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤") + .expect("init project"); + let user = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "userMessage", + "id": "codex-user-item-1", + "clientId": "turn-0001", + "content": [{"type": "text", "text": "请创建菜单"}] + }), + ) + .expect("ignore Codex echo"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "message", + "role": "user", + "id": "codex-raw-user-item-1", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }), + ) + .expect("ignore raw Codex user echo"); + let items = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(items, vec![user]); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs new file mode 100644 index 000000000..c2c317692 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -0,0 +1,54 @@ +use super::direct_project_history::append_direct_project_history_item_at; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Default)] +pub(crate) struct DirectProjectHistoryAccumulator { + text_by_item_id: BTreeMap, +} + +impl DirectProjectHistoryAccumulator { + pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) { + self.text_by_item_id + .entry(item_id.to_string()) + .or_default() + .push_str(delta); + } + + pub(crate) fn complete_item(&mut self, item: &Value) { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + self.text_by_item_id.remove(item_id); + } + } + + fn take_partial_items(&mut self) -> impl Iterator + '_ { + std::mem::take(&mut self.text_by_item_id) + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(item_id, text)| { + serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }) + }) + } +} + +pub(crate) fn persist_direct_project_partial_items_at( + root: &Path, + accumulator: &mut DirectProjectHistoryAccumulator, +) -> Result<(), String> { + let items = accumulator.take_partial_items().collect::>(); + let mut first_error = None; + for item in items { + if let Err(error) = append_direct_project_history_item_at(root, &item) { + if first_error.is_none() { + first_error = Some(error); + } + } + } + first_error.map_or(Ok(()), Err) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index d5370f671..e7974ba25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2147,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Ok(validated_slices) = validated_art_slices(root) else { return Vec::new(); }; - if validated_slices.len() != 4 { + if validated_slices.is_empty() { return Vec::new(); } let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len()); @@ -2718,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at( asset_kind: asset_kind.to_string(), asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), + slice_count: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; @@ -2821,9 +2822,9 @@ fn direct_taonier_art_package_result( } else { Vec::new() }; - if includes_spritesheet && slice_paths.len() != 4 { + if includes_spritesheet && slice_paths.is_empty() { slice_warnings.push( - "当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" + "当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" .to_string(), ); } @@ -4406,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "assistant".to_string(), - content: reply.to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:assistant"), - ) - .map(|_| ()) -} - -fn persist_direct_codex_user_prompt_at( - root: &Path, - client_turn_id: &str, - prompt: &str, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "user".to_string(), - content: prompt.trim().to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:user"), - ) - .map(|_| ()) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4479,15 +4438,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), - 500, - )); - } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4503,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( 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); - 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) @@ -4525,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } +#[cfg(test)] +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "user", + prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + )?; + append_direct_project_user_message_at(root, &item) +} + +#[cfg(test)] +fn persist_direct_codex_assistant_reply_at( + root: &Path, + client_turn_id: &str, + reply: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "assistant", + reply, + Some(&format!("direct-codex:{client_turn_id}:assistant")), + )?; + append_direct_project_history_item_at(root, &item) +} + #[cfg(test)] mod tests { use super::*; @@ -4609,8 +4578,8 @@ mod tests { persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply) .expect("the App's same-id write converges idempotently"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:assistant"); let persisted = conversation .messages @@ -4623,7 +4592,7 @@ mod tests { assert!( persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复") .expect_err("same message identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } @@ -4640,8 +4609,8 @@ mod tests { persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) .expect("retry reuses the same user message identity"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:user"); let persisted = conversation .messages @@ -4654,7 +4623,7 @@ mod tests { assert!( persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") .expect_err("same turn identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 5b9f4cfdd..bdbeb222c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization { struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, - allows_regeneration: bool, brief_sha256: Option, completed_result: Option, resource_request_ids: BTreeMap, @@ -181,30 +180,23 @@ impl DirectToolBridge { &self.url } - /// Arm exactly one client-owned Direct turn. The raw user message is used - /// only for this synchronous decision and is never retained by the bridge. - pub(crate) fn begin_user_turn( - &self, - user_prompt: &str, - ) -> Result { - self.state.begin_user_turn(user_prompt) + /// Arm exactly one client-owned Direct turn. Codex chooses the business + /// operation through the reviewed MCP tool and arguments; the bridge only + /// binds that call to the active client turn. + pub(crate) fn begin_user_turn(&self) -> Result { + self.state.begin_user_turn() } } impl DirectToolBridgeState { - fn begin_user_turn( - self: &Arc, - user_prompt: &str, - ) -> Result { + fn begin_user_turn(self: &Arc) -> Result { let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; - let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt); let mut authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; authorization.active = Some(DirectToolBridgeActiveTurnAuthorization { turn_id: turn_id.clone(), - allows_regeneration, brief_sha256: None, completed_result: None, resource_request_ids: BTreeMap::new(), @@ -594,12 +586,9 @@ impl DirectToolBridgeState { .active .as_mut() .ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?; - if !active.allows_regeneration { - return Err("当前用户消息未显式授权重新生成或替换美术".to_string()); - } match active.brief_sha256.as_deref() { Some(expected) if expected != brief_sha256 => { - return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string()) + return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string()) } None => active.brief_sha256 = Some(brief_sha256.clone()), Some(_) => {} @@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_kind: kind.clone(), asset_label: asset_name.clone(), replace_existing: false, + slice_count: None, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( @@ -2736,106 +2726,20 @@ mod tests { } #[test] - fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() { - for prompt in [ - "继续修复布局", - "解释一下重新生成美术是什么意思", - "不要重新生成美术,只调整代码", - "别换一套美术,继续用现在这套", - "解释一下换一套美术按钮", - "是否要改变视觉风格?", - "Do not regenerate the art; keep the current package.", - "I don't want to change the visual style.", - "What does use a new art set mean?", - "文案写着“换一套美术”", - "Yesterday I said regenerate art, but today keep it.", - "Please explain how to regenerate art.", - "重新生成美术以后再说,现在只修代码", - "重做美术先不做,先改玩法", - "Regenerate the art maybe later; for now just fix the code.", - "把按钮文案改成“请重新生成美术”,不要执行生成工具", - "把按钮文案改成‘请重新生成美术’,不要执行生成工具", - "Change the button label to 'please regenerate the art'; do not execute it.", - "用户之前说请重新生成美术,我只是在复述", - "Yesterday the user said please regenerate the art; I am just quoting it.", - "以后请重新生成美术,现在先改代码", - "你能不能帮我重新生成美术,顺便解释一下价格", - "请重新生成美术吗", - "请重新生成美术吗,还是只改代码", - "请重新生成美术或者只改代码", - "请重新生成美术以外的内容", - "请重新生成美术,但不要执行生成工具", - "不需要重新生成美术", - "界面上显示:请重新生成美术", - "界面标题是请重新生成美术", - "产品经理让我写请重新生成美术", - "下周请重新生成美术", - "他说«请重新生成美术»", - "Could you please regenerate the art", - "Please regenerate the art? Or only fix code.", - "Please regenerate the art except for the paid generation.", - "Please regenerate the art, but do not execute the tool.", - "Please regenerate the art, but don’t execute the tool.", - "Please regenerate the art, but I don't authorize this paid generation.", - "Please regenerate the art, but I don‘t authorize this paid generation.", - "Please regenerate the art, but do not execute the paid tool.", - "Please regenerate the art, but never execute the paid tool.", - "Please regenerate the art, but avoid executing the paid tool.", - "Please regenerate the art, but 'do not execute the tool", - "Please regenerate the art only if it is free.", - "Please regenerate the art only after I confirm the charge.", - "Please regenerate the art, but do “not” execute the paid tool.", - "请重新生成美术,三天后再执行。", - "请重新生成美术,得到我的许可再做。", - "请重新生成美术,地面需要无缝循环。", - "Please regenerate the art, but skip the paid generation.", - "请重新生成美术【生成操作跳过】", - "请重新生成美术【仅在零元时执行】", - "Please regenerate the art “but skip the paid generation”", - "Please regenerate the art; alternatively, just fix the code.", - "Please regenerate the art, but do n\u{200B}ot execute the paid tool.", - "Please regenerate the art with a clay style.", - "I don't need you to regenerate the art", - "The UI shows: please regenerate the art", - "Please regenerate the art next week", - "He said «please regenerate the art»", - ] { - assert!( - !direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must fail closed: {prompt}" - ); - } - for prompt in [ - "请重新生成美术。", - "那就请重新生成美术!", - "换一套美术", - "Please regenerate the art!", - ] { - assert!( - direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must explicitly authorize: {prompt}" - ); - } - + fn regenerate_uses_current_client_turn_and_one_stable_brief() { let root = tempfile::tempdir().expect("stable client turn root"); let state = direct_tool_bridge_state(root.path().to_path_buf()); - assert!(state.begin_user_turn("请重新生成美术").is_err()); + assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) .expect("client-owned stable invocation"); - let ordinary_turn = state - .begin_user_turn("继续优化交互") - .expect("ordinary turn authorization state"); - assert!(state.authorize_regeneration_call("陶泥风格").is_err()); - drop(ordinary_turn); - - let authorized_turn = state - .begin_user_turn("请重新生成美术") - .expect("authorized regeneration turn"); + let active_turn = state + .begin_user_turn() + .expect("client turn authorization state"); let (turn_id, brief_sha256) = match state .authorize_regeneration_call("陶泥风格") - .expect("first stable regeneration call") + .expect("MCP mode selects regeneration explicitly") { DirectToolBridgeRegenerationCall::Execute { turn_id, @@ -2864,7 +2768,7 @@ mod tests { panic!("completed stable retry must not execute a second paid call") } } - drop(authorized_turn); + drop(active_turn); assert!(state.authorize_regeneration_call("陶泥风格").is_err()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 30e3b0bea..3dde7a64a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1,7 +1,13 @@ use super::*; +use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str = @@ -14,6 +20,37 @@ const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const EXTERNAL_MCP_RESPONSE_MAX_CHARS: usize = 256 * 1024; +const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; +const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; +const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; +static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +tokio::task_local! { + static EXTERNAL_MCP_BRIDGE_URL: String; +} + +pub(crate) struct ExternalMcpServer { + _bridge: super::direct_tool_bridge::DirectToolBridge, + pub(crate) url: String, + pub(crate) token: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for ExternalMcpServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +struct ExternalMcpHttpState { + bridge_url: String, + root: PathBuf, + token: String, + session_user_id: String, + session_generation: u64, +} pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] @@ -43,6 +80,62 @@ fn direct_tools_mcp_specs() -> Value { fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { let tools = vec![ + json!({ + "name": "client.session.info", + "description": "返回当前已绑定的 AGC 客户端会话和项目安全摘要;不返回宿主路径、凭据或内部地址。", + "inputSchema": { "type": "object", "additionalProperties": false } + }), + json!({ + "name": "conversation.record_codex_response", + "description": "显式记录外部 Codex 的一条最终返回。客户端只保存有界、脱敏后的正文和安全摘要,不根据正文触发业务动作。", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sequence": { "type": "integer", "minimum": 0, "maximum": 1000000 }, + "content": { "type": "string", "minLength": 1, "maxLength": EXTERNAL_MCP_RESPONSE_MAX_CHARS } + }, + "required": ["requestId", "sequence", "content"], + "additionalProperties": false + } + }), + json!({ + "name": "conversation.list", + "description": "按序读取当前项目已记录的 Codex 返回摘要。", + "inputSchema": { + "type": "object", + "properties": { + "offset": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "additionalProperties": false + } + }), + json!({ + "name": "conversation.read", + "description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", + "inputSchema": { + "type": "object", + "properties": { + "recordId": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "required": ["recordId"], + "additionalProperties": false + } + }), + json!({ + "name": "agc_read_skill_resource", + "description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "required": ["skillName", "relativePath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": "把文本写入当前 AGC 项目的相对路径。Codex 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", @@ -67,7 +160,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "taonier_prepare_game_art", - "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。regenerate 还必须通过客户端对当前用户消息签发的单回合稳定调用授权;模型参数和 MCP 自动批准本身不构成替换授权。仅在用户意图确实需要新美术时调用。", + "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。Codex 根据当前对话决定是否调用 regenerate;客户端不解析用户文本,也不替 Codex 判断意图。", "inputSchema": { "type": "object", "properties": { @@ -81,7 +174,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "type": "string", "enum": ["reuse-or-create", "regenerate"], "default": "reuse-or-create", - "description": "缺省安全复用有效美术包;只有用户明确要求换一套或重新生成时使用 regenerate" + "description": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate" } }, "required": ["brief"], @@ -763,7 +856,9 @@ fn tool_search_max_results(arguments: &Value) -> Result { } fn direct_tool_bridge_url() -> Result { - let value = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV) + let value = EXTERNAL_MCP_BRIDGE_URL + .try_with(Clone::clone) + .or_else(|_| std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV)) .map_err(|_| "客户端受控工具桥未配置".to_string())?; let parsed = url::Url::parse(&value).map_err(|_| "客户端受控工具桥地址无效".to_string())?; let host = parsed @@ -974,6 +1069,31 @@ async fn call_agc_web_search(arguments: &Value) -> Value { call_agc_web_search_with_enabled(arguments, controlled_web_search_enabled()).await } +fn call_agc_read_skill_resource(arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["skillName", "relativePath"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let skill = match bounded_tool_string(arguments, "skillName", 120) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let relative = match bounded_tool_string(arguments, "relativePath", 240) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if Path::new(&relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') + { + return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); + } + match read_agc_skill_resource(&format!("{skill}/{relative}")) { + Ok(content) => mcp_tool_result(content, Vec::new(), false), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -997,7 +1117,348 @@ async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> V .await } -async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { +fn external_mcp_journal_path(root: &Path) -> PathBuf { + root.join(EXTERNAL_MCP_JOURNAL_RELATIVE_PATH) +} + +fn redact_external_mcp_response(content: &str) -> String { + content + .lines() + .map(|line| { + let lower = line.to_ascii_lowercase(); + let sensitive = [ + "authorization:", + "cookie:", + "set-cookie:", + "api_key", + "apikey", + "access_token", + "refresh_token", + "client_secret", + "password:", + "bearer ", + ] + .iter() + .any(|marker| lower.contains(marker)); + if sensitive { + "[redacted sensitive response line]".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +fn external_mcp_response_summary(content: &str) -> String { + let normalized = content.split_whitespace().collect::>().join(" "); + normalized + .chars() + .take(EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS) + .collect() +} + +fn external_mcp_session_id(root: &Path) -> String { + let mut material = root.to_string_lossy().into_owned(); + if let Some(session) = current_platform_session() { + material.push('\0'); + material.push_str(&session.user_id); + material.push('\0'); + material.push_str(&session.generation.to_string()); + } + format!("mcp-{:x}", Sha256::digest(material.as_bytes())) +} + +fn external_mcp_project_id(root: &Path) -> String { + std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .get("projectId") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| { + format!( + "project-{:x}", + Sha256::digest(root.to_string_lossy().as_bytes()) + ) + }) +} + +fn external_mcp_account_id() -> String { + current_platform_session() + .map(|session| format!("account-{:x}", Sha256::digest(session.user_id.as_bytes()))) + .unwrap_or_else(|| "account-unknown".to_string()) +} + +fn validate_external_mcp_record_arguments( + arguments: &Value, +) -> Result<(String, u64, String), String> { + validate_tool_object_fields(arguments, &["requestId", "sequence", "content"])?; + let request_id = bounded_tool_string(arguments, "requestId", 160)?; + let sequence = arguments + .get("sequence") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 sequence 必须是非负整数".to_string())?; + if sequence > 1_000_000 { + return Err("工具参数 sequence 超出安全边界".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.is_empty() || content.chars().count() > EXTERNAL_MCP_RESPONSE_MAX_CHARS { + return Err("工具参数 content 不能为空或超过大小上限".to_string()); + } + if content + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err("工具参数 content 不能包含控制字符".to_string()); + } + Ok((request_id, sequence, content.to_string())) +} + +fn read_external_mcp_journal(root: &Path) -> Result, String> { + let path = external_mcp_journal_path(root); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(Vec::new()); + }; + if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return Err("Codex 返回记录超过客户端保留上限".to_string()); + } + bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + }) + .collect() +} + +fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { + if let Err(error) = enforce_project_permission_policy(root, "conversation.write") { + return mcp_tool_result(error, Vec::new(), true); + } + let (request_id, sequence, content) = match validate_external_mcp_record_arguments(arguments) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let redacted = redact_external_mcp_response(&content); + let key = format!("{request_id}\u{0}{sequence}"); + let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes())); + let guard = EXTERNAL_MCP_JOURNAL_LOCK + .get_or_init(|| Mutex::new(())) + .lock(); + if guard.is_err() { + return mcp_tool_result("Codex 返回记录锁不可用".to_string(), Vec::new(), true); + } + let mut records = match read_external_mcp_journal(root) { + Ok(records) => records, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if let Some(existing) = records.iter().find(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + && record.get("sequence").and_then(Value::as_u64) == Some(sequence) + }) { + return mcp_tool_result(existing.to_string(), Vec::new(), false); + } + if let Some(max_sequence) = records + .iter() + .filter(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + }) + .filter_map(|record| record.get("sequence").and_then(Value::as_u64)) + .max() + { + if sequence != max_sequence.saturating_add(1) { + return mcp_tool_result( + "工具参数 sequence 必须按 requestId 连续递增".to_string(), + Vec::new(), + true, + ); + } + } else if sequence != 0 { + return mcp_tool_result( + "同一 requestId 的首条记录 sequence 必须为 0".to_string(), + Vec::new(), + true, + ); + } + let path = external_mcp_journal_path(root); + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + return mcp_tool_result( + format!("创建 Codex 返回记录目录失败:{error}"), + Vec::new(), + true, + ); + } + } + let record = json!({ + "recordId": uuid::Uuid::new_v4().to_string(), + "recordType": "codex.response", + "accountId": external_mcp_account_id(), + "projectId": external_mcp_project_id(root), + "sessionId": external_mcp_session_id(root), + "requestId": request_id, + "sequence": sequence, + "receivedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + "content": redacted, + "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), + "summary": external_mcp_response_summary(&redacted), + "truncated": false, + "status": "completed" + }); + let line = match serde_json::to_string(&record) { + Ok(line) => line, + Err(error) => { + return mcp_tool_result( + format!("序列化 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ) + } + }; + let current_size = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return mcp_tool_result( + "Codex 返回记录达到客户端保留上限".to_string(), + Vec::new(), + true, + ); + } + let _project_lock = match acquire_project_write_lock(root, "conversation.write") { + Ok(lock) => lock, + Err(error) => { + return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) + } + }; + let append_result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| { + use std::io::Write as _; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.sync_data() + }); + if let Err(error) = append_result { + return mcp_tool_result( + format!("写入 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ); + } + // Reuse the existing conversation projection so the current UI can read + // the explicit external response without treating it as business truth. + if let Err(error) = append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: record + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + agent_id: None, + }, + &message_id, + ) { + return mcp_tool_result( + format!("Codex 返回已写入但对话投影失败:{error}"), + Vec::new(), + true, + ); + } + records.push(record.clone()); + mcp_tool_result(record.to_string(), Vec::new(), false) +} + +fn external_mcp_session_info(root: &Path) -> Value { + let manifest = std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let project_id = manifest + .as_ref() + .and_then(|value| value.get("projectId")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + mcp_tool_result( + json!({ + "status": "bound", + "projectId": project_id, + "sessionId": external_mcp_session_id(root), + "transport": "loopback-or-stdio" + }) + .to_string(), + Vec::new(), + false, + ) +} + +fn external_mcp_conversation_list(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["offset", "limit"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let offset = arguments.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; + let limit = arguments.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize; + if offset > 10_000 || !(1..=100).contains(&limit) { + return mcp_tool_result( + "conversation.list 分页参数超出安全边界".to_string(), + Vec::new(), + true, + ); + } + match read_external_mcp_journal(root) { + Ok(records) => mcp_tool_result( + json!({ "entries": records.into_iter().skip(offset).take(limit).map(|record| json!({ + "recordId": record.get("recordId"), "requestId": record.get("requestId"), + "sequence": record.get("sequence"), "receivedAt": record.get("receivedAt"), + "summary": record.get("summary"), "status": record.get("status") + })).collect::>() }) + .to_string(), + Vec::new(), + false, + ), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +fn external_mcp_conversation_read(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["recordId"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let record_id = match bounded_tool_string(arguments, "recordId", 80) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + match read_external_mcp_journal(root) { + Ok(records) => records + .into_iter() + .find(|record| { + record.get("recordId").and_then(Value::as_str) == Some(record_id.as_str()) + }) + .map(|record| mcp_tool_result(record.to_string(), Vec::new(), false)) + .unwrap_or_else(|| { + mcp_tool_result("未找到 Codex 返回记录".to_string(), Vec::new(), true) + }), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -1014,7 +1475,10 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option id, json!({ "protocolVersion": requested_protocol, - "capabilities": { "tools": { "listChanged": false } }, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, "serverInfo": { "name": "genarrative-agc-tools", "version": env!("CARGO_PKG_VERSION") @@ -1023,6 +1487,75 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option )) } "ping" => Some(mcp_success(id, json!({}))), + "resources/list" => Some(mcp_success( + id, + json!({ + "resources": [{ + "uri": "agc://skills/index", + "name": "AGC Skill 索引", + "description": "审核通过的客户端 Skill 与工具使用指导", + "mimeType": "text/plain" + }, { + "uri": "agc://conversation/codex-responses", + "name": "Codex 返回记录", + "description": "当前项目中由 conversation.record_codex_response 写入的只读 journal", + "mimeType": "application/x-ndjson" + }] + }), + )), + "resources/read" => { + let uri = request + .pointer("/params/uri") + .and_then(Value::as_str) + .unwrap_or_default(); + if uri == "agc://skills/index" { + let text = render_agc_skill_pack_index() + .map_err(|_| ()) + .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); + return Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )); + } + if let Some(resource) = uri.strip_prefix("agc://skills/") { + return match read_agc_skill_resource(resource) { + Ok(text) if text.len() <= DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES => { + Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )) + } + Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), + Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + }; + } + if uri != "agc://conversation/codex-responses" { + Some(mcp_error(id, -32602, "未知资源")) + } else { + let text = read_external_mcp_journal(root) + .map(|records| { + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { + return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); + } + Some(mcp_success( + id, + json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/x-ndjson", + "text": text + }] + }), + )) + } + } "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), "tools/call" => { let tool = request @@ -1034,6 +1567,13 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option .cloned() .unwrap_or_else(|| json!({})); let result = match tool { + "client.session.info" => external_mcp_session_info(root), + "conversation.record_codex_response" => { + external_mcp_record_response(root, &arguments) + } + "conversation.list" => external_mcp_conversation_list(root, &arguments), + "conversation.read" => external_mcp_conversation_read(root, &arguments), + "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_write_file" => call_agc_write_file(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, @@ -1122,6 +1662,108 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { Ok(()) } +fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| value == token) +} + +async fn handle_external_mcp_http_request( + AxumState(state): AxumState, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + if !external_mcp_authorized(&headers, &state.token) { + return Err(StatusCode::UNAUTHORIZED); + } + let Some(session) = current_platform_session() else { + return Err(StatusCode::UNAUTHORIZED); + }; + if session.user_id != state.session_user_id || session.generation != state.session_generation { + return Err(StatusCode::UNAUTHORIZED); + } + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + state.bridge_url.clone(), + handle_direct_tools_mcp_request(&state.root, request), + ) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + Ok(Json(response)) +} + +pub(crate) async fn start_external_mcp_loopback( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + let root = validate_direct_tools_project_root(root)?; + let session = current_platform_session() + .ok_or_else(|| "启动客户端 MCP 前必须先完成账号会话绑定".to_string())?; + let token = uuid::Uuid::new_v4().to_string(); + let route = format!("/mcp-{}", uuid::Uuid::new_v4().simple()); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动客户端 MCP loopback 失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; + let bridge = + super::direct_tool_bridge::start_direct_tool_bridge(&root, controlled_web_search).await?; + let state = ExternalMcpHttpState { + bridge_url: bridge.url().to_string(), + root, + token: token.clone(), + session_user_id: session.user_id, + session_generation: session.generation, + }; + let app = Router::new() + .route(&route, post(handle_external_mcp_http_request)) + .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!("http://127.0.0.1:{}{route}", address.port()); + let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let mut guard = registry + .lock() + .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; + if let Some(previous) = guard.take() { + drop(previous); + } + *guard = Some(ExternalMcpServer { + _bridge: bridge, + url: url.clone(), + token: token.clone(), + task, + }); + Ok((url, token)) +} + +pub(crate) fn stop_external_mcp_loopback() { + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + guard.take(); + } + } +} + +#[tauri::command] +pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let (url, token) = start_external_mcp_loopback(root, false).await?; + Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) +} + +#[tauri::command] +pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { + stop_external_mcp_loopback(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1210,6 +1852,11 @@ mod tests { assert_eq!( names, vec![ + "client.session.info", + "conversation.record_codex_response", + "conversation.list", + "conversation.read", + "agc_read_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -1243,9 +1890,10 @@ mod tests { "reuse-or-create" ); assert_eq!(art_tool["inputSchema"]["required"], json!(["brief"])); - assert!(art_tool["description"].as_str().is_some_and( - |description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权") - )); + assert!(art_tool["description"].as_str().is_some_and(|description| { + description.contains("Codex 根据当前对话决定是否调用 regenerate") + && description.contains("客户端不解析用户文本") + })); assert!(art_tool["description"].as_str().is_some_and(|description| { description.contains("用户不需要提供、配置、粘贴或创建 API Key") && description.contains("不得向用户索要凭据或暴露内部 URL") @@ -1583,4 +2231,53 @@ mod tests { assert_eq!(response["isError"], true); assert!(response.to_string().contains("未审核字段")); } + + #[test] + fn skill_resource_tool_rejects_unreviewed_paths() { + let accepted = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md" + })); + assert_eq!(accepted["isError"], false); + assert!(accepted.to_string().contains("drive prefix")); + + let denied = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json" + })); + assert_eq!(denied["isError"], true); + + let denied_windows_absolute = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md" + })); + assert_eq!(denied_windows_absolute["isError"], true); + } + + #[test] + fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() { + let response = redact_external_mcp_response( + "完成了页面布局\nAuthorization: Bearer secret-value\n下一步请运行试玩", + ); + assert!(response.contains("完成了页面布局")); + assert!(response.contains("下一步请运行试玩")); + assert!(!response.contains("secret-value")); + } + + #[test] + fn external_codex_response_arguments_reject_unknown_fields_and_control_bytes() { + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "ok", + "unexpected": true + })) + .is_err()); + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "bad\u{0001}" + })) + .is_err()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index e3cee4f47..0e7a97084 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_kind: String, pub(crate) asset_label: String, pub(crate) replace_existing: bool, + pub(crate) slice_count: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions { asset_kind: "game-art".to_string(), asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, + slice_count: None, } } } @@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) } +/// Observe an accepted operation once without waiting. A single GET that +/// reports `failed` is authoritative and allows a changed retry to release +/// the old local slot; queued/running/unknown outcomes remain protected. +async fn accepted_generation_is_authoritatively_failed_once( + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + submission_payload: &serde_json::Value, +) -> Result { + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?; + access.validate_frozen_session()?; + let payload = tokio::time::timeout( + Duration::from_secs(3), + external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) + )) + .bearer_auth(access.bearer_token()), + "查询平台图片生成任务", + ), + ) + .await + .map_err(|_| "查询平台图片生成任务超时".to_string())??; + access.validate_frozen_session()?; + let generation = platform_generation_status_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("failed") => Ok(true), + Some("queued" | "running" | "completed") => Ok(false), + Some(status) => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )), + None => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" + )), + } +} + pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -1714,24 +1757,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // long creation request cannot reject the atlas before it is queued. const MAX_DESCRIPTION_CHARS: usize = 200; const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - [ - "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", - "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", - "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材", - "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", - ] - .into_iter() - .map(|category| { - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - format!("{category}{CONTEXT_PREFIX}{project_context}") - }) - .collect() + let category = + "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] } fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { @@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = - standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?; + standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( root, prompt, briefs, options, - true, + false, &runtime_context, ) .await @@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.generation_prompt != generation_prompt { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.reference_resource_ids != [current_reference] { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = + platform_art_generation_runtime_submission_payload(&state) + { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "sliceLayout": "grid-2x2", + "sliceCount": options.slice_count, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract( has_transparent_pixels: bool, has_visible_pixels: bool, ) -> Result<(), String> { - if slices.len() != 4 { - return Err(format!( - "strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个", - slices.len() - )); + if slices.is_empty() { + return Err("spritesheet 图集至少需要一个独立切片".to_string()); } let resource_id = resource_id .map(str::trim) @@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { - return Err( - "strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成" - .to_string(), - ); - } + let _requested_slice_layout = spritesheet_slice_layout; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete( manifest: &GameCreationAppManifest, registrations: &[PlatformArtSliceManifestRegistration], ) -> Result { - if registrations.len() != 4 { + if registrations.is_empty() { return Ok(false); } let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len()); @@ -7594,6 +7679,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "手工背景".to_string(), replace_existing: true, + slice_count: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -9402,6 +9488,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "整包规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10239,6 +10326,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "整包背景图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10690,6 +10778,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11264,6 +11353,7 @@ mod canvas_generation_tests { asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, + slice_count: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 7648408da..460d8dfad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -488,15 +488,13 @@ fn game_creator_design_foundation_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, ) -> String { - let role_boundary = "角色边界:项目文件写入只允许 memory/project.md 与 game/game_design.md;配置 External Editor API Key 且任务要求界面原型时,可额外产出 assets/ui-prototype.png 与 Runtime 发现清单要求的 assets/ui-pages/*.png;UI 设计图生成后只能通过受控 ui.workflow.run 写入或关联 UI JSON、保存工作流阶段并应用页面,不得绕过该工具直接写入 UI State。不得创建、修改、删除或补丁 game/index.html,也不得改动任何其他程序实现、发布、音频或美术素材文件。完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人 owner 产物;不得调用 project.verify、command.run_limited、game.static_smoke、preview.start 或 preview.validate,也不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright,或执行任何桌面端、移动端试玩验证。完整 DAG 的最终静态验收仍属于 preview-readiness,浏览器验收仍属于 preview-playtest。"; + let role_boundary = "角色边界:只负责玩法规格、界面建议和视觉工具使用指导。项目文件与图片输出必须服从当前任务明确要求;不创建固定图片槽位,不规定固定数量或布局,不修改 game/index.html,不启动预览或试玩。"; if !editor_api_key_is_configured { return format!( "{prompt}\n\n你负责玩法规格与界面原型基础交付。当前未配置 External Editor API Key,因此本轮必须完成 memory/project.md 与 game/game_design.md,不调用 canvas.asset_generate,也不伪造 assets/ui-prototype.png。把界面结构、控件、状态和双视口要求写进玩法规格;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},供 Runtime 自动发现和后续程序组实现;完成写入后直接交付,不要自行运行任何验证命令。{role_boundary}" ); } - format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},作为 Runtime 自动发现的权威设计声明。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。八项视觉检查通过后,先调用 ui.workflow.run 的 discover 自动读取受控页面声明,不得凭空猜页面;再按返回的每个 pageId 逐页调用 canvas.asset_generate,以固定 16:9、2K、assetKind=ui-prototype、replaceExisting=false 生成并登记对应 assets/ui-pages/{{pageId}}.png 设计图,assetLabel 使用该页标题,使用发现的真实 applicationPath 依次执行 prepare、recognize、status,确认所有页面均无 blockers 后再执行 finalize。该工具会创建并关联 kind=UI 的 JSON 编辑资源、持久化每一阶段 State、同步 manifest/客户端,并在完成后返回 visual-binding 最终编辑器路由;只登记 ui-prototype 图片或只写计划不算完成。由 Runtime 在收束门内同时核对固定 owner 文档、当前 revision 与视觉证据。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" - ) + format!("{prompt}\n\n根据当前玩法需求编写规格和界面建议;如需图片,明确说明用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet,再调用 canvas.asset_generate。不要使用固定图片合同。{role_boundary}") } fn game_creator_art_director_tool_plan_prompt( @@ -506,7 +504,7 @@ fn game_creator_art_director_tool_plan_prompt( if !editor_api_key_is_configured { return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,这是只读协调任务:只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。seed task 中生成规范图的图片产物与验收条款在本轮不适用。"); } - format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。canvas.asset_generate 成功只表示固定候选已生成并登记,不等于视觉门已经通过;生成成功后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") + format!("{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词;可以生成一张或多张图片,也可以不生成图片。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态;不要假设固定图片名称、数量、素材类别或布局。") } fn game_creator_art_asset_plan_tool_plan_prompt( @@ -519,7 +517,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。透明证据核对完成后直接交付,由 Runtime 在收束门内验证本人固定 manifest 产物并复核 Canvas 证据。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定,不得套用固定图片包或固定 2x2。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。" ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 3fa69f520..a617d7f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 art-director 必须是非只读规范图生成任务", )); } - let art_artifacts = + // 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。 + let _art_artifacts = autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } let code_director = code_director.ok_or_else(|| { autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") @@ -1976,7 +1969,7 @@ mod tests { plan: Vec::new(), actions: vec![ autonomous_initial_delegate("design-director", &[]), - autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("art-director", &[]), autonomous_initial_delegate("code-director", &[]), ], response: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index a3eac4093..d9d062c0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1199,32 +1199,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( agent_id: &str, required_run_id: Option<&str>, ) -> Option { - if !editor_api_key_is_configured() { - return None; - } - let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), - "art-asset-plan" => ( - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图", - ), - _ => return None, - }; - let manifest = match read_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: format!("无法核对{label},不能完成任务"), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }); + // 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。 + return None; + #[allow(unreachable_code)] + { + if !editor_api_key_is_configured() { + return None; } - }; - if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { - return Some(AgentRuntimeToolObservation { + let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), + "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "art-spritesheet", + "首版美术素材图", + ), + _ => return None, + }; + let manifest = match read_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: format!("无法核对{label},不能完成任务"), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { + return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), @@ -1234,29 +1238,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( redact_agent_runtime_project_paths(root, &error, 300), )), }); - } - if agent_id != "design-foundation" { - return None; - } - match ui_prototype_visual_inspection_blocker_detail_at_locked( - root, - agent_id, - required_run_id, - expected_path, - ) { - Ok(None) => None, - Ok(Some(detail)) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), - detail: Some(detail), - }), - Err(error) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }), + } + if agent_id != "design-foundation" { + return None; + } + match ui_prototype_visual_inspection_blocker_detail_at_locked( + root, + agent_id, + required_run_id, + expected_path, + ) { + Ok(None) => None, + Ok(Some(detail)) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), + detail: Some(detail), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 45571d8c9..326eab51a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke } pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { - editor_api_key_is_configured() - && matches!( - task_id, - "art-director" | "design-foundation" | "art-asset-plan" - ) + false } fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { @@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; - let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() { - "art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。" - } else { - "任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。" - }; + let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。"; let verification_requirement = match task.id.as_str() { "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", @@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( if task.id == "art-director" { if autonomous_manifest_ready_task_requires_visual_asset(&task.id) { return format!( - "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论。" ); } return format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 948eda19a..211da5829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = if editor_api_key_is_configured() { - match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, - } - } else { - None - }; + let required_visual_artifact: Option<&str> = None; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { !expected_artifacts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 220dae8ed..4d4616f77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); } } - if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" { - let manifest = match read_existing_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "无法确认首版美术素材登记状态,未执行删除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; - let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { - asset.local_path == "assets/art-spritesheet.png" - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if registered_fixed_asset_exists { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(), - detail: Some( - "path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费" - .to_string(), - ), - }; - } - } if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index fa17cbfcc..48ba9b4e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -540,6 +540,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("replace_existing")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let slice_count = input + .get("sliceCount") + .or_else(|| input.get("slice_count")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as usize); let requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, @@ -547,83 +552,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio asset_kind, asset_label, replace_existing, + slice_count, }; - let canonical_options = match agent_id { - "art-director" => Some(PlatformArtAssetGenerationOptions { - output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), - asset_label: "游戏统一视觉规范图".to_string(), - replace_existing: false, - }), - "design-foundation" - if requested_options - .output_path - .as_deref() - .is_some_and(design_foundation_ui_page_output_path_is_valid) => - { - Some(PlatformArtAssetGenerationOptions { - output_path: requested_options.output_path.clone(), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: if requested_options.asset_label.trim().is_empty() { - "游戏功能页面设计图".to_string() - } else { - requested_options.asset_label.clone() - }, - replace_existing: false, - }) - } - "design-foundation" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/ui-prototype.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: "游戏横屏界面原型图".to_string(), - replace_existing: false, - }), - "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/art-spritesheet.png".to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), - asset_label: "游戏首版核心美术素材".to_string(), - replace_existing: false, - }), - _ => None, - }; - let mut options = if let Some(canonical) = canonical_options { - let mismatch = requested_options - .output_path - .as_deref() - .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) - || (!requested_options.aspect_ratio.is_empty() - && requested_options.aspect_ratio != canonical.aspect_ratio) - || (!requested_options.image_size.is_empty() - && requested_options.image_size != canonical.image_size) - || (!requested_options.asset_kind.is_empty() - && requested_options.asset_kind != canonical.asset_kind) - || (!requested_options.asset_label.is_empty() - && requested_options.asset_label != canonical.asset_label); - if mismatch { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: format!( - "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", - canonical.output_path.as_deref().unwrap_or("null"), - canonical.aspect_ratio, - canonical.image_size, - canonical.asset_kind, - canonical.asset_label, - ), - detail: None, - }; - } - canonical - } else { + let mut options = { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { output_path: requested_options.output_path, @@ -648,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio requested_options.asset_label }, replace_existing, + slice_count, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index aec1efbc2..9cb4fda28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result { Ok(lines.join("\n")) } +pub(crate) fn read_agc_skill_resource(resource: &str) -> Result { + let manifest = validated_skill_pack_manifest()?; + let normalized = resource.trim().trim_start_matches('/').replace('\\', "/"); + let (skill_name, relative) = normalized + .split_once('/') + .ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?; + let entry = manifest + .skills + .iter() + .find(|entry| entry.name == skill_name) + .ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?; + if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) { + return Err("未登记或不安全的 AGC Skill 资源".to_string()); + } + let bundled_path = format!("{skill_name}/{relative}"); + let bytes = + bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?; + let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?; + String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string()) +} + pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result { let manifest = validated_skill_pack_manifest()?; let skills_root = isolated_os_home.join(".agents").join("skills"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 3f34dc890..9bab62192 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 10ac70a7f..45918c9e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::read_direct_project_chat_history_at; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -4752,6 +4753,15 @@ pub(crate) fn read_local_conversation( read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref()) } +#[tauri::command] +pub(crate) fn read_direct_project_conversation( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -4780,6 +4790,20 @@ pub(crate) fn append_local_conversation_message( } } +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn build_local_project_index( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 9de89f01a..923491cde 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2489,6 +2489,8 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + start_game_creator_external_mcp, + stop_game_creator_external_mcp, create_automatic_local_game_project, init_local_game_project, import_local_godot_project, @@ -2594,7 +2596,9 @@ fn main() { set_active_game_creator_agent_session, archive_game_creator_agent_session, read_local_conversation, + read_direct_project_conversation, append_local_conversation_message, + append_direct_project_conversation_message, build_local_project_index, create_local_project_checkpoint, export_local_project_package, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index bd2f3e18b..4fd654a80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) } -pub(super) struct ProjectAppendLock { +pub(crate) struct ProjectAppendLock { process_lock: Arc>, os_lock_path: PathBuf, } -pub(super) struct ProjectAppendGuard<'a> { +pub(crate) struct ProjectAppendGuard<'a> { _process_guard: std::sync::MutexGuard<'a, ()>, _os_lock: File, } @@ -4335,7 +4335,7 @@ impl ProjectAppendLock { .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏")) } - pub(super) fn lock(&self, error_label: &str) -> Result, String> { + pub(crate) fn lock(&self, error_label: &str) -> Result, String> { let process_guard = self .process_lock .lock() @@ -4348,7 +4348,7 @@ impl ProjectAppendLock { } } -pub(super) fn project_append_lock_for(path: &Path) -> Result { +pub(crate) fn project_append_lock_for(path: &Path) -> Result { let mut locks = project_append_locks() .lock() .map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?; @@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -617,10 +613,6 @@ export function App({ receivedDirectUpdate: boolean; } | null>(null); const lastDirectCodexActivityRef = useRef(null); - const recoveredDirectCodexTurnClaimsRef = useRef(new Set()); - const directCodexClaimReleaseOnConversationWriteFailureRef = useRef( - new Map(), - ); const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null @@ -1801,6 +1793,16 @@ export function App({ projectConversationWriteConfirmedRef.current = null; projectConversationWriteCancelledRef.current = null; } + // DirectProject history is written by Rust from raw app-server items. + // The browser only renders that projection and must not append chat rows. + if (projectSupervisorOnly && directCodexProductRuntime) { + // `messages` is only an optimistic UI projection in Direct mode; it is + // intentionally not proof of durability. Rust owns the raw response + // history, so this effect must not route these rows through the generic + // browser conversation writer. + savedConversationCountRef.current = messages.length; + return; + } const start = savedConversationCountRef.current; const pendingMessages = messages.slice(start); if (pendingMessages.length === 0) { @@ -1871,7 +1873,6 @@ export function App({ return; } conversationWriteInFlightRef.current = true; - let failedDirectTerminalMessageCount: number | null = null; void (async () => { let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { @@ -1886,56 +1887,22 @@ export function App({ savedConversationCountRef.current = start + index + 1; continue; } - try { - await invoke( - 'append_local_conversation_message', - { - projectPath: nextProjectPath, + await invoke( + 'append_local_conversation_message', + { + projectPath: nextProjectPath, + agentId: null, + ...(message.messageId ? { messageId: message.messageId } : {}), + message: { + role: message.role, + content: message.text, agentId: null, - ...(message.messageId ? { messageId: message.messageId } : {}), - message: { - role: message.role, - content: message.text, - agentId: null, - ...(typeof message.updatedAt === 'number' - ? { updatedAt: message.updatedAt } - : {}), - }, + ...(typeof message.updatedAt === 'number' + ? { updatedAt: message.updatedAt } + : {}), }, - ); - } catch (error) { - const claimKey = message.messageId - ? directCodexClaimReleaseOnConversationWriteFailureRef.current.get( - message.messageId, - ) - : undefined; - if (claimKey) { - directCodexClaimReleaseOnConversationWriteFailureRef.current.delete( - message.messageId!, - ); - recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); - failedDirectTerminalMessageCount = start + index + 1; - } - throw error; - } - if (message.messageId) { - const claimKey = - directCodexClaimReleaseOnConversationWriteFailureRef.current.get( - message.messageId, - ); - if (claimKey) { - directCodexClaimReleaseOnConversationWriteFailureRef.current.delete( - message.messageId, - ); - if (localProjectPathRef.current === nextProjectPath) { - // Any history read started before this terminal append may hold - // A stale history snapshot may still be missing this terminal - // append. Invalidate it before releasing the in-memory claim. - projectSupervisorHistoryLoadVersionRef.current += 1; - } - recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); - } - } + }, + ); wroteMessage = true; savedConversationCountRef.current = start + index + 1; } @@ -1950,12 +1917,10 @@ export function App({ } })() .catch((error) => { - savedConversationCountRef.current = failedDirectTerminalMessageCount - ? Math.max( - savedConversationCountRef.current, - failedDirectTerminalMessageCount, - ) - : Math.min(savedConversationCountRef.current, start); + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + start, + ); setWorkspaceStatus( `项目对话保存失败:${ error instanceof Error ? error.message : String(error) @@ -1985,6 +1950,7 @@ export function App({ conversationWriteVersion, pendingUiConfirmation, projectSupervisorOnly, + directCodexProductRuntime, ]); function appendLocalPermissionLog( @@ -2741,11 +2707,12 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; const projectConversation = await invoke( - 'read_local_conversation', - { - projectPath: nextProjectPath, - agentId: null, - }, + directCodexProductRuntime + ? 'read_direct_project_conversation' + : 'read_local_conversation', + directCodexProductRuntime + ? { projectPath: nextProjectPath } + : { projectPath: nextProjectPath, agentId: null }, ); let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; @@ -5450,6 +5417,7 @@ export function App({ clientTurnId: directConversationTurnId, creationType, attachments, + directPolicyChecked = false, }: ExecuteChatAgentReplyInput) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. @@ -5459,6 +5427,44 @@ export function App({ if (directProjectPath && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); + if ( + !directPolicyChecked && + projectConversationWriteConfirmedRef.current !== directProjectPath + ) { + try { + const policyPaused = await queueProjectPolicyConfirmationIfNeeded( + directInvoke, + 'conversation.write', + directProjectPath, + '写入 DirectProject 对话历史', + 'DirectProject 对话写入需要确认。', + () => { + projectConversationWriteConfirmedRef.current = + directProjectPath; + void executeChatAgentReply({ + prompt, + clientTurnId, + creationType, + attachments, + directPolicyChecked: true, + }); + }, + ); + if (policyPaused) { + return; + } + projectConversationWriteConfirmedRef.current = directProjectPath; + } catch (error) { + if (localProjectPathRef.current === directProjectPath) { + setProjectSupervisorRuntimeError( + `DirectProject 对话权限检查失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return; + } + } const directUserMessageId = directCodexConversationMessageId( clientTurnId, 'user', @@ -5470,20 +5476,45 @@ export function App({ const appendDirectUserMessageIfMissing = ( current: ChatMessage[], ): ChatMessage[] => { - return current.some( - (message) => message.messageId === directUserMessageId, - ) - ? current - : [ - ...current, - { - role: 'user' as const, - text: prompt, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - }, - ]; + if ( + current.some((message) => message.messageId === directUserMessageId) + ) { + return current; + } + let optimisticIndex = -1; + for (let index = current.length - 1; index >= 0; index -= 1) { + const message = current[index]; + if ( + message?.role === 'user' && + message.text === prompt && + !message.messageId + ) { + optimisticIndex = index; + break; + } + } + if (optimisticIndex >= 0) { + return current.map((message, index) => + index === optimisticIndex + ? { + ...message, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + } + : message, + ); + } + return [ + ...current, + { + role: 'user' as const, + text: prompt, + runtimeOwned: true, + messageId: directUserMessageId, + updatedAt: Date.now(), + }, + ]; }; const appendDirectAssistantMessage = ( current: ChatMessage[], @@ -5507,41 +5538,6 @@ export function App({ index === existingIndex ? nextMessage : message, ); }; - const persistDirectAssistantMessage = (text: string) => - directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directAssistantMessageId, - message: { - role: 'assistant', - content: text, - agentId: null, - }, - }, - ); - const persistDirectPartialMessage = (messageId: string, text: string) => - directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId, - message: { - role: 'assistant', - // An interrupted partial is intentionally a normal assistant - // record so replay sees exactly what Codex emitted before the - // disconnect; the marker is product data, not UI metadata. - content: `${text.trim()}\nunexpected interrupt happened here`, - agentId: null, - }, - }, - ); - const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; - recoveredDirectCodexTurnClaimsRef.current.add( - recoveredDirectCodexTurnClaimKey, - ); activeDirectCodexTurnRef.current = { projectPath: directProjectPath, turnId: clientTurnId, @@ -5558,36 +5554,6 @@ export function App({ setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { - const interruptedPartial = directCodexInterruptedPartialRef.current; - if ( - interruptedPartial?.projectPath === directProjectPath && - interruptedPartial.text.trim() - ) { - await persistDirectPartialMessage( - interruptedPartial.messageId, - interruptedPartial.text, - ); - directCodexInterruptedPartialRef.current = null; - } - // Rust owns the normalized user record for attachment turns so the - // durable message includes the same bounded project mapping that is - // sent to Codex. Plain turns keep the optimistic browser write; the - // Rust writer then converges on it through messageId idempotency. - if (!attachments?.length) { - await directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directUserMessageId, - message: { - role: 'user', - content: prompt, - agentId: null, - }, - }, - ); - } const directTurnInput: { projectPath: string; prompt: string; @@ -5609,28 +5575,11 @@ export function App({ 'chat_with_game_creator_direct_codex', directTurnInput, ); - try { - await persistDirectAssistantMessage(reply); - } catch (error) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - `陶泥儿回复保存失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the in-memory turn claim can be - // released without reopening the Provider side effect. Invalidate - // any history snapshot captured before Rust committed the terminal - // reply first. + // Rust already persisted the complete raw response items. Invalidate + // any history snapshot captured before the turn completed. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setMessages((current) => @@ -5643,9 +5592,6 @@ export function App({ } } catch (error) { if (isDirectCodexTurnAlreadyRunningError(error)) { - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( @@ -5665,46 +5611,7 @@ export function App({ '陶泥儿智能创作', true, ); - const partial = directCodexTransientReplyRef.current.trim(); - if (partial) { - const partialMessageId = - globalThis.crypto?.randomUUID?.() || - `direct-partial-${Date.now().toString(36)}`; - directCodexInterruptedPartialRef.current = { - projectPath: directProjectPath, - text: partial, - messageId: partialMessageId, - }; - try { - await persistDirectPartialMessage(partialMessageId, partial); - } catch { - // The next user send retries this idempotent append before - // constructing the replay prompt. - } - } - try { - await persistDirectAssistantMessage(visibleMessage); - if (localProjectPathRef.current === directProjectPath) { - projectSupervisorHistoryLoadVersionRef.current += 1; - } - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); - } catch { - if (localProjectPathRef.current === directProjectPath) { - // Do not release the claim while the React conversation writer - // can still persist this terminal record. That writer releases - // the claim only after its exact append resolves or rejects. - directCodexClaimReleaseOnConversationWriteFailureRef.current.set( - directAssistantMessageId, - recoveredDirectCodexTurnClaimKey, - ); - } else { - recoveredDirectCodexTurnClaimsRef.current.delete( - recoveredDirectCodexTurnClaimKey, - ); - } - } + projectSupervisorHistoryLoadVersionRef.current += 1; if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setDirectCodexStatus('failed'); diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx new file mode 100644 index 000000000..3d79df338 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -0,0 +1,281 @@ +import type { ErrorInfo, ReactNode } from 'react'; +import { + Children, + Component, + createContext, + isValidElement, + useContext, +} from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +export type ChatMarkdownMessageProps = { + text: string; + role: 'assistant' | 'user'; + streaming?: boolean; +}; + +type MarkdownErrorBoundaryProps = { + fallbackText: string; + children: ReactNode; +}; + +type MarkdownErrorBoundaryState = { + hasError: boolean; +}; + +export class MarkdownErrorBoundary extends Component< + MarkdownErrorBoundaryProps, + MarkdownErrorBoundaryState +> { + state: MarkdownErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): MarkdownErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + // Keep the original message visible without logging its potentially sensitive content. + const errorName = + error instanceof Error && error.name ? error.name : 'UnknownError'; + console.error('[chat-markdown] render failed', { + errorName, + hasComponentStack: Boolean(errorInfo.componentStack?.trim()), + }); + } + + componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) { + if ( + this.state.hasError && + prevProps.fallbackText !== this.props.fallbackText + ) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) { + return ( + + {this.props.fallbackText} + + ); + } + return this.props.children; + } +} + +const ListDepthContext = createContext(0); +const ListKindContext = createContext<'unordered' | 'ordered' | null>(null); +type ListItemParagraphPosition = 'first' | 'continuation'; + +const ListItemContext = createContext(null); + +function MarkdownUnorderedList({ children }: { children?: ReactNode }) { + const depth = useContext(ListDepthContext); + return ( + + +
    0 ? 'pl-4' : 'pl-0' + }`} + > + {children} +
+
+
+ ); +} + +function MarkdownOrderedList({ + children, + start, +}: { + children?: ReactNode; + start?: number; +}) { + const depth = useContext(ListDepthContext); + return ( + + +
    + {children} +
+
+
+ ); +} + +function MarkdownParagraph({ children }: { children?: ReactNode }) { + const paragraphPosition = useContext(ListItemContext); + return ( +

+ {children} +

+ ); +} + +function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) { + const paragraphPosition = useContext(ListItemContext); + return ( +

+ {children} +

+ ); +} + +function MarkdownListItem({ children }: { children?: ReactNode }) { + const listKind = useContext(ListKindContext); + let paragraphIndex = 0; + const childrenWithParagraphContext = Children.map( + children, + (child, index) => { + if ( + isValidElement(child) && + (child.type === MarkdownParagraph || + child.type === StreamingMarkdownParagraph) + ) { + const position: ListItemParagraphPosition = + paragraphIndex++ === 0 ? 'first' : 'continuation'; + return ( + + {child} + + ); + } + return child; + }, + ); + return ( +
  • + {listKind === 'unordered' ? '- ' : null} + {childrenWithParagraphContext} +
  • + ); +} + +const markdownComponents: Components = { + // TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。 + a: ({ children }) => children, + img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'), + h1: ({ children }) => ( +

    {children}

    + ), + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + h4: ({ children }) => ( +

    {children}

    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    + {children} +
    + ), + p: MarkdownParagraph, + ul: MarkdownUnorderedList, + ol: MarkdownOrderedList, + li: MarkdownListItem, + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + code: ({ className, children, node: _node, ...props }) => { + const isBlock = + Boolean(className?.includes('language-')) || + String(children).includes('\n'); + return isBlock ? ( + + {children} + + ) : ( + + {children} + + ); + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () => ( +
    + ), +}; + +const streamingMarkdownComponents: Components = { + ...markdownComponents, + p: StreamingMarkdownParagraph, +}; + +export function ChatMarkdownMessage({ + text, + role, + streaming = false, +}: ChatMarkdownMessageProps) { + if (role === 'user') { + return {text}; + } + + return ( + + + {text} + + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index d8df6bc98..451316d2e 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2140,6 +2140,14 @@ export function projectSupervisorVisibleConversationText( ); } +export function projectSupervisorChatMessageText( + message: Pick, +) { + return message.role === 'assistant' + ? projectSupervisorVisibleConversationText(message.text, message.role) + : message.text; +} + export function projectRuntimeVisibleToolSummary(summary: string) { return summary .split('·') diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts index f256883f0..2181c5aac 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts @@ -272,7 +272,13 @@ export function summarizeProjectFileContent(result: LocalProjectFileResult) { } 字符` : result.content; - return `文件:${result.path}\n${content || '空文件'}`; + const visibleContent = content || '空文件'; + let longestBacktickRun = 0; + for (const match of visibleContent.matchAll(/`+/gu)) { + longestBacktickRun = Math.max(longestBacktickRun, match[0].length); + } + const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); + return `文件:${result.path}\n\n${fence}text\n${visibleContent}\n${fence}`; } export function inferProjectFileAssetDraft( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index d69ff1376..a627e22d2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -10,9 +10,9 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView } from '../../app/types'; -import { - type ClientLlmModel, - type ClientLlmModelCatalog, +import type { + ClientLlmModel, + ClientLlmModelCatalog, } from '../../services/clientApi'; import { cachedLlmModelCatalog, @@ -25,13 +25,15 @@ export type ConversationModelSelectHandle = { }; export function ConversationModelSelect({ + className, disabled, onReady, projectPath, ref, }: { + className?: string; disabled: boolean; - onReady: (ready: boolean) => void; + onReady?: (ready: boolean) => void; projectPath?: string; ref?: Ref; }) { @@ -40,10 +42,14 @@ export function ConversationModelSelect({ initialCatalog?.models ?? [], ); const [selected, setSelected] = useState(''); + const [defaultModelId, setDefaultModelId] = useState( + initialCatalog?.defaultModelId ?? '', + ); const [busy, setBusy] = useState(!initialCatalog); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [open, setOpen] = useState(false); + const containerRef = useRef(null); const appliedRevisionRef = useRef( initialCatalog?.revision ?? null, ); @@ -65,7 +71,7 @@ export function ConversationModelSelect({ }, []); const markReady = useCallback((ready: boolean) => { - onReadyRef.current(ready); + onReadyRef.current?.(ready); }, []); const applyCatalog = useCallback( @@ -73,6 +79,7 @@ export function ConversationModelSelect({ catalog: ClientLlmModelCatalog, showBusy: boolean, epochAtRequest: number, + configPromise: Promise, ) => { if ( mountedRef.current && @@ -80,12 +87,11 @@ export function ConversationModelSelect({ ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); + setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); - if (!invoke) throw new Error('Native host unavailable'); - const config = await invoke( - 'read_game_creator_app_config', - ); + const config = await configPromise; + if (!invoke || !config) throw new Error('Native host unavailable'); if ( saveInFlightRef.current || selectionEpochRef.current !== epochAtRequest @@ -101,6 +107,7 @@ export function ConversationModelSelect({ let next = ''; let nextIsDefault = false; let nextNotice = ''; + // 跟随默认项的选择会随后台默认模型变化;用户手动选择后不再被覆盖。 if (followsDefault && defaultEnabled) { next = catalog.defaultModelId; nextIsDefault = true; @@ -109,6 +116,7 @@ export function ConversationModelSelect({ } else if (!followsDefault && saved && enabled(saved)) { next = saved; } + // 已选模型被停用/移除时回退默认模型,避免下拉出现「请选择模型」的空态。 if (!next && defaultEnabled) { next = catalog.defaultModelId; nextIsDefault = true; @@ -123,7 +131,7 @@ export function ConversationModelSelect({ persisted.config.selectedModelId !== next || persisted.config.selectedModelIsDefault !== nextIsDefault ) - throw new Error('Default selection was not saved'); + throw new Error('Model selection was not saved'); } const ready = Boolean(next); if (!mountedRef.current) return ready; @@ -148,9 +156,20 @@ export function ConversationModelSelect({ markReady(false); } const epochAtRequest = selectionEpochRef.current; + const invoke = resolveTauriInvoke(); + const configPromise: Promise = invoke + ? invoke( + 'read_game_creator_app_config', + ).catch(() => null) + : Promise.resolve(null); try { const catalog = await refreshLlmModelCatalog(); - return await applyCatalog(catalog, showBusy, epochAtRequest); + return await applyCatalog( + catalog, + showBusy, + epochAtRequest, + configPromise, + ); } catch { const cached = cachedLlmModelCatalog(); if (cached) { @@ -158,6 +177,7 @@ export function ConversationModelSelect({ cached, showBusy, epochAtRequest, + configPromise, ).catch(() => false); if (mountedRef.current) setError('模型列表加载失败'); return ready; @@ -185,6 +205,27 @@ export function ConversationModelSelect({ return () => window.removeEventListener('focus', handleWindowFocus); }, [syncCatalog]); + useEffect(() => { + if (!open) return; + function handleOutsidePointerDown(event: MouseEvent) { + const target = event.target as Node | null; + if (containerRef.current && !containerRef.current.contains(target)) { + setOpen(false); + } + } + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + } + } + document.addEventListener('mousedown', handleOutsidePointerDown); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleOutsidePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + const ensureUsable = useCallback(() => syncCatalog(true), [syncCatalog]); useImperativeHandle(ref, () => ({ ensureUsable }), [ensureUsable]); @@ -220,7 +261,14 @@ export function ConversationModelSelect({ } return ( -
    +
    {error ? {error} : null} {notice ? {notice} : null} ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    + +
    ))} {directCodex && (runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? ( @@ -210,6 +211,15 @@ export function ProjectSupervisorView({ : '陶泥儿正在处理'} + {transientReply ? ( +
    + +
    + ) : null} {directProcessDetail ? (

    ) : null} - {transientReply ? ( -

    - {transientReply} -

    + +
    ) : null}
    {directCodex ? null : isPlanningLaneRuntime( @@ -351,7 +365,9 @@ export function ProjectSupervisorView({ {directCodex ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index f70d648c7..4d7b5c8ea 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -26,12 +26,13 @@ import type { PendingCommand, PendingUiConfirmation, } from '../../app/types'; +import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import type { ProjectAgentResultSummary } from '../../view/project-development'; import { formatAgentRecentRuntimeTask, formatAgentRuntimeTaskQueue, + projectSupervisorChatMessageText, ProjectSupervisorRuntimePanel, - projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; import { @@ -804,12 +805,12 @@ export function ProjectWorkspaceChatPane({ ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    +
    + +
    {message.draftCommand ? ( ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    + + ))} {transientReply ? ( -

    - {transientReply} -

    + + ) : null} {running && !transientReply ? (
    - - +
    + +
    +
    + + +
    diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx new file mode 100644 index 000000000..6df7c4333 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ChatMarkdownMessage, + MarkdownErrorBoundary, +} from '../src/components/ChatMarkdownMessage'; + +function FailingChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error('transient render failure'); + } + return markdown recovered; +} + +describe('ChatMarkdownMessage', () => { + it('渲染 assistant 的 GFM 内容与代码块', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h2')?.textContent).toBe('标题'); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.querySelector('pre code')?.textContent).toContain( + 'const answer = 42;', + ); + }); + + it('为嵌套无序列表保留逐层缩进', () => { + const { container } = render( + , + ); + + const lists = container.querySelectorAll('ul'); + expect(lists).toHaveLength(3); + expect(lists[0]?.className).toContain('pl-0'); + expect(lists[1]?.className).toContain('pl-4'); + expect(lists[2]?.className).toContain('pl-4'); + }); + + it('为四到六级标题提供递进的字号与字重', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h4')?.className).toContain('text-sm'); + expect(container.querySelector('h4')?.className).toContain('font-semibold'); + expect(container.querySelector('h5')?.className).toContain('font-medium'); + expect(container.querySelector('h6')?.className).toContain('text-xs'); + expect(container.querySelector('h6')?.className).toContain('tracking-wide'); + }); + + it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => { + const { container } = render( + , + ); + + const items = container.querySelectorAll('ol > li'); + expect(items[0]?.textContent?.trim()).toBe('第一件事'); + expect(items[1]?.textContent?.trim()).toContain('第二件事'); + expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline'); + expect(items[0]?.className).toContain('whitespace-normal'); + expect(items[0]?.textContent).not.toContain('-'); + }); + + it('保留 Markdown 有序列表的起始编号', () => { + const { container } = render( + , + ); + + expect(container.querySelector('ol')?.getAttribute('start')).toBe('3'); + expect(container.querySelectorAll('ol > li')).toHaveLength(2); + }); + + it('为列表项后续段落保留段落间距', () => { + const { container } = render( + , + ); + + const paragraphs = container.querySelectorAll('ul > li p'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]?.className).toContain('inline'); + expect(paragraphs[1]?.className).toContain('mt-2'); + expect(paragraphs[1]?.className).not.toContain('inline'); + }); + + it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('pre code')?.getAttribute('node'), + ).toBeNull(); + }); + + it('无语言标记的多行围栏代码仍使用代码块样式', () => { + const { container } = render( + , + ); + + const code = container.querySelector('pre code'); + expect(code?.className).toContain('whitespace-pre'); + expect(code?.className).not.toContain('rounded'); + }); + + it('保留行内代码中的 HTML 字面量', () => { + const { container } = render( + `'} />, + ); + + expect(container.querySelector('code')?.textContent).toBe(''); + }); + + it('保留围栏代码中的 HTML 字面量而不双重转义', () => { + const { container } = render( +
    \n```'} + />, + ); + + expect(container.querySelector('pre code')?.textContent).toContain( + '
    ', + ); + expect(container.querySelector('pre code')?.textContent).not.toContain( + '<div', + ); + }); + + it('累计文本变化后可从 Markdown 错误回退中恢复', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + const { container, rerender } = render( + + + , + ); + + expect(container.textContent).toBe('partial'); + + rerender( + + + , + ); + + expect(container.textContent).toBe('markdown recovered'); + } finally { + consoleError.mockRestore(); + } + }); + + it('保留流式 assistant 文本并标记 streaming 状态', () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain('正在生成'); + expect(container.textContent).toContain('结果'); + }); + + it('用户消息保持纯文本,不解析 Markdown', () => { + const { container } = render( + , + ); + + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('code')).toBeNull(); + expect(container.textContent).toContain('**不要解析**'); + expect(container.textContent).toContain('`/read game`'); + }); + + it('不输出原始 HTML、可点击链接或图片节点', () => { + const { container } = render( + alert(1)\n\n[外链](https://example.com) ![示意图](x.png)' + } + />, + ); + + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain('外链'); + expect(container.textContent).toContain('图片:示意图'); + }); + + it('支持表格并允许窄视口横向滚动', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('table th')).toHaveLength(2); + expect(container.querySelector('div.overflow-x-auto')).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e83a74396..8c5c1c118 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -62,6 +62,47 @@ function ApprovedGddStartHarness() { } export function registerClientHomeTests() { + it('adds a model selector to the home composer and shows the default model', async () => { + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + const modelTrigger = await screen.findByRole('button', { + name: '对话模型', + }); + await waitFor(() => expect(modelTrigger.textContent).toContain('高质量')); + const createButton = screen.getByRole('button', { name: '开启创作' }); + expect(createButton).toHaveProperty('disabled', false); + + fireEvent.click(modelTrigger); + await waitFor(() => + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(), + ); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'fast', + isDefault: false, + }), + ); + expect(modelTrigger.textContent).toContain('快速'); + expect(createButton).toHaveProperty('disabled', false); + }); + it('anchors the empty home input placeholder to the editor while the page scrolls', () => { renderLauncherAt('/?launcher'); @@ -1220,7 +1261,17 @@ export function registerHomeProjectCreationTests() { }); it('keeps only open and create project actions without exposing a Linux fallback', () => { - const invoke = vi.fn(); + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); window.__TAURI__ = { core: { invoke } }; renderLauncherProjectsAt('/?launcher'); @@ -1242,7 +1293,12 @@ export function registerHomeProjectCreationTests() { screen.queryByRole('button', { name: '在文件管理器中显示' }), ).toBeNull(); expect(screen.queryByRole('button', { name: /Godot 项目/ })).toBeNull(); - expect(invoke).not.toHaveBeenCalled(); + // 首页模型选择器会在挂载时读取模型目录(read_game_creator_app_config), + // 这里只校验没有打开工作区窗口或其它项目操作被触发。 + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); }); it('opens the directory selected by the native picker', async () => { @@ -2122,6 +2178,7 @@ export function registerHomeProjectCreationTests() { 'existing-project', '已有项目', ); + const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { @@ -2131,14 +2188,45 @@ export function registerHomeProjectCreationTests() { if (command === 'append_local_permission_log') { return {}; } - if (command === 'append_local_conversation_message') { + if (command === 'hydrate_game_creator_plan_gdd_state') { + return null; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, - messages: [], + sessionId: null, + messages: [...persistedMessages], }; } + if (command === 'append_local_conversation_message') { + throw new Error( + 'DirectProject must not use browser conversation writer', + ); + } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + }, + { + role: 'assistant', + content: 'DIRECT_EXISTING_PROJECT_OK', + messageId: `direct-codex:${clientTurnId}:assistant`, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2165,32 +2253,18 @@ export function registerHomeProjectCreationTests() { }, ); }); - const persistedTurnCall = invoke.mock.calls.findIndex( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId !== undefined, - ); const directTurnCall = invoke.mock.calls.findIndex( ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(persistedTurnCall).toBeGreaterThanOrEqual(0); - expect(persistedTurnCall).toBeLessThan(directTurnCall); - const persistedTurnArgs = invoke.mock.calls[persistedTurnCall]?.[1] as - | Record - | undefined; const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as | Record | undefined; - expect(persistedTurnArgs).toEqual({ - projectPath, - agentId: null, - messageId: `direct-codex:${String(directTurnArgs?.clientTurnId ?? '')}:user`, - message: { - role: 'user', - content: '继续修改已有项目', - agentId: null, - }, - }); + expect(directTurnCall).toBeGreaterThanOrEqual(0); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String)); expect(invoke).not.toHaveBeenCalledWith( 'create_automatic_local_game_project', ); @@ -2214,7 +2288,10 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath }); return manifest; } - if (command === 'read_local_conversation') { + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, @@ -2257,6 +2334,23 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + messageId: `direct-codex:${clientTurnId}:assistant`, + updatedAt: 2, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts index 1df0e257a..ef3335a59 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts @@ -923,7 +923,9 @@ export function registerProjectAssetTests() { submitChat('/read game/index.html'); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', @@ -992,7 +994,9 @@ export function registerProjectAssetTests() { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index c4f51ff77..528710a1e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -3805,7 +3805,13 @@ export function registerProjectCommandTests() { submitChat('/files'); expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); - expect(screen.getByText(/- game\//)).not.toBeNull(); + expect( + screen.getByText( + (_, element) => + element?.tagName === 'LI' && + element.textContent?.trim() === '- game/', + ), + ).not.toBeNull(); expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); expect(screen.getByText(/- assets\/uploads\/hero\.png/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('list_local_project_files', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 79815398b..64373c01d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4695,6 +4695,50 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('allows selecting the model on the first direct-project entry', async () => { + const projectPath = '/tmp/first-entry-model-select'; + const manifest = createGameCreationAppManifest( + 'first-entry-model-select', + '首次进入模型选择', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + + const surface = await screen.findByLabelText('陶泥儿项目对话'); + const trigger = within(surface).getByRole('button', { name: '对话模型' }); + await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false)); + fireEvent.click(trigger); + await waitFor(() => + expect( + within(surface).getByRole('option', { name: '快速' }), + ).not.toBeNull(), + ); + fireEvent.click(within(surface).getByRole('option', { name: '快速' })); + await waitFor(() => expect(trigger.textContent).toContain('快速')); + }); + it('runs a top workbench play request without a second confirmation', async () => { const projectPath = '/tmp/top-play-request'; const supervisorHarness = createProjectSupervisorRuntimeHarness({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index cfb73e050..30d180a03 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlanningAndStatusShortcutFlow( invoke: PreviewShortcutInvoke, @@ -70,7 +71,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/blockers'); const blockerMessages = await screen.findAllByText(/当前阻塞项:/); - const blockerMessage = blockerMessages[blockerMessages.length - 1]; + const blockerMessage = messageBubble( + blockerMessages[blockerMessages.length - 1], + ); expect(blockerMessage.textContent).toContain('项目:未命名游戏原型'); expect(blockerMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -170,7 +173,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/ready'); const readyMessages = await screen.findAllByText(/试玩就绪度:/); - const readyMessage = readyMessages[readyMessages.length - 1]; + const readyMessage = messageBubble(readyMessages[readyMessages.length - 1]); expect(readyMessage.textContent).toContain('项目:未命名游戏原型'); expect(readyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -270,7 +273,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/evidence'); const evidenceMessages = await screen.findAllByText(/验证证据台账:/); - const evidenceMessage = evidenceMessages[evidenceMessages.length - 1]; + const evidenceMessage = messageBubble( + evidenceMessages[evidenceMessages.length - 1], + ); expect(evidenceMessage.textContent).toContain('项目:未命名游戏原型'); expect(evidenceMessage.textContent).toContain( 'Run trace:已有 run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -377,7 +382,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/deps'); const dependencyMessages = await screen.findAllByText(/任务依赖链:/); - const dependencyMessage = dependencyMessages[dependencyMessages.length - 1]; + const dependencyMessage = messageBubble( + dependencyMessages[dependencyMessages.length - 1], + ); expect(dependencyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -479,7 +486,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/revise'); const revisionMessages = await screen.findAllByText(/改版草稿:/); - const revisionMessage = revisionMessages[revisionMessages.length - 1]; + const revisionMessage = messageBubble( + revisionMessages[revisionMessages.length - 1], + ); expect(revisionMessage.textContent).toContain('项目:未命名游戏原型'); expect(revisionMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -604,7 +613,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/privacy'); const privacyMessages = await screen.findAllByText(/隐私与导出边界:/); - const privacyMessage = privacyMessages[privacyMessages.length - 1]; + const privacyMessage = messageBubble( + privacyMessages[privacyMessages.length - 1], + ); expect(privacyMessage.textContent).toContain('项目:未命名游戏原型'); expect(privacyMessage.textContent).toContain( '本地目录:/tmp/authorized-game', @@ -616,10 +627,10 @@ export async function assertPlanningAndStatusShortcutFlow( '本地预览:未启动;仅限 127.0.0.1 本机访问', ); expect(privacyMessage.textContent).toContain( - '试玩包:尚未导出;只应包含 game/**、assets/** 和 exports/README.md', + '试玩包:尚未导出;只应包含 game/、assets/ 和 exports/README.md', ); expect(privacyMessage.textContent).toContain( - '内部文件:.agent/**、memory/**、日志、trace、配置和密钥不得进入试玩包', + '内部文件:.agent/、memory/、日志、trace、配置和密钥不得进入试玩包', ); expect(privacyMessage.textContent).toContain( '素材来源:2 个;上传 1 / 画板 1', @@ -697,7 +708,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/criteria'); expect(await screen.findByText(/当前验收标准:/)).not.toBeNull(); const criteriaMessages = screen.getAllByText(/当前验收标准:/); - const criteriaMessage = criteriaMessages[criteriaMessages.length - 1]; + const criteriaMessage = messageBubble( + criteriaMessages[criteriaMessages.length - 1], + ); expect(criteriaMessage.textContent).toContain( 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan)', ); @@ -740,7 +753,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/groups'); expect(await screen.findByText(/专业组进度:/)).not.toBeNull(); const groupMessages = screen.getAllByText(/专业组进度:/); - const groupMessage = groupMessages[groupMessages.length - 1]; + const groupMessage = messageBubble(groupMessages[groupMessages.length - 1]); expect(groupMessage.textContent).toContain( '美术组:完成 0/3 · active 0 · carry 0 · ready 1', ); @@ -793,7 +806,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/balance'); expect(await screen.findByText(/数值状态:/)).not.toBeNull(); const balanceMessages = screen.getAllByText(/数值状态:/); - const balanceMessage = balanceMessages[balanceMessages.length - 1]; + const balanceMessage = messageBubble( + balanceMessages[balanceMessages.length - 1], + ); expect(balanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(balanceMessage.textContent).toContain( 'balance-director:Director 确定数值口径 · 待处理', @@ -874,7 +889,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/budget'); expect(await screen.findByText(/运行预算:/)).not.toBeNull(); const budgetMessages = screen.getAllByText(/运行预算:/); - const budgetMessage = budgetMessages[budgetMessages.length - 1]; + const budgetMessage = messageBubble( + budgetMessages[budgetMessages.length - 1], + ); expect(budgetMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -923,7 +940,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/qa'); expect(await screen.findByText(/质量检查:/)).not.toBeNull(); const qaMessages = screen.getAllByText(/质量检查:/); - const qaMessage = qaMessages[qaMessages.length - 1]; + const qaMessage = messageBubble(qaMessages[qaMessages.length - 1]); expect(qaMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -988,7 +1005,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/changes'); expect(await screen.findByText(/最近变更:/)).not.toBeNull(); const changeMessages = screen.getAllByText(/最近变更:/); - const changeMessage = changeMessages[changeMessages.length - 1]; + const changeMessage = messageBubble( + changeMessages[changeMessages.length - 1], + ); expect(changeMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -1064,7 +1083,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/review'); expect(await screen.findByText(/评审状态:/)).not.toBeNull(); const reviewMessages = screen.getAllByText(/评审状态:/); - const reviewMessage = reviewMessages[reviewMessages.length - 1]; + const reviewMessage = messageBubble( + reviewMessages[reviewMessages.length - 1], + ); expect(reviewMessage.textContent).toContain('Evaluator:通过'); expect(reviewMessage.textContent).toContain('返工焦点:暂无'); expect(reviewMessage.textContent).toContain( @@ -1150,7 +1171,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/timeline'); expect(await screen.findByText(/项目时间线:/)).not.toBeNull(); const timelineMessages = screen.getAllByText(/项目时间线:/); - const timelineMessage = timelineMessages[timelineMessages.length - 1]; + const timelineMessage = messageBubble( + timelineMessages[timelineMessages.length - 1], + ); expect(timelineMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done', ); @@ -1209,7 +1232,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/handoff'); expect(await screen.findByText(/项目交接:/)).not.toBeNull(); const handoffMessages = screen.getAllByText(/项目交接:/); - const handoffMessage = handoffMessages[handoffMessages.length - 1]; + const handoffMessage = messageBubble( + handoffMessages[handoffMessages.length - 1], + ); expect(handoffMessage.textContent).toContain( '- Run:run-main-shortcut-trace', ); @@ -1335,14 +1360,14 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/todo'); const todoMessages = await screen.findAllByText(/下一轮小步:/); - const todoMessage = todoMessages[todoMessages.length - 1]; + const todoMessage = messageBubble(todoMessages[todoMessages.length - 1]); expect(todoMessage.textContent).toContain('项目:未命名游戏原型'); expect(todoMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(todoMessage.textContent).toContain('编排下一步:preview'); expect(todoMessage.textContent).toContain( - '1. ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', + 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', ); expect(todoMessage.textContent).toContain( '边界:只整理下一步;不读取任务文件;不启动 run;不修改项目', @@ -1428,7 +1453,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/plan'); const planMessages = await screen.findAllByText(/下一轮分工计划:/); - const planMessage = planMessages[planMessages.length - 1]; + const planMessage = messageBubble(planMessages[planMessages.length - 1]); expect(planMessage.textContent).toContain('项目:未命名游戏原型'); expect(planMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -1533,7 +1558,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/guide'); const guideMessages = await screen.findAllByText(/使用导引:/); - const guideMessage = guideMessages[guideMessages.length - 1]; + const guideMessage = messageBubble(guideMessages[guideMessages.length - 1]); expect(guideMessage.textContent).toContain('项目:未命名游戏原型'); expect(guideMessage.textContent).toContain('当前阶段:可导出'); expect(guideMessage.textContent).toContain( @@ -1627,7 +1652,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/progress'); const progressMessages = await screen.findAllByText(/项目进度:/); - const progressMessage = progressMessages[progressMessages.length - 1]; + const progressMessage = messageBubble( + progressMessages[progressMessages.length - 1], + ); expect(progressMessage.textContent).toContain('项目:未命名游戏原型'); expect(progressMessage.textContent).toContain('当前阶段:已生成'); expect(progressMessage.textContent).toMatch( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts index acb85a840..2e7cf976c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlaytestAndReleaseShortcutFlow( invoke: PreviewShortcutInvoke, @@ -170,7 +171,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/listing'); const listingMessages = await screen.findAllByText(/作品页草稿:/); - const listingMessage = listingMessages[listingMessages.length - 1]; + const listingMessage = messageBubble( + listingMessages[listingMessages.length - 1], + ); expect(listingMessage.textContent).toContain('标题:未命名游戏原型'); expect(listingMessage.textContent).toContain( '一句话卖点:做一个厨房弹幕游戏', @@ -262,7 +265,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( ).length; submitChat('/playtest'); const playtestMessages = await screen.findAllByText(/试玩状态:/); - const playtestMessage = playtestMessages[playtestMessages.length - 1]; + const playtestMessage = messageBubble( + playtestMessages[playtestMessages.length - 1], + ); expect(playtestMessage.textContent).toContain( '原型:最近 run 已通过 run-main-shortcut-trace', ); @@ -329,26 +334,26 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/test-plan'); const testPlanMessages = await screen.findAllByText(/手动测试计划:/); - const testPlanMessage = testPlanMessages[testPlanMessages.length - 1]; + const testPlanMessage = messageBubble( + testPlanMessages[testPlanMessages.length - 1], + ); expect(testPlanMessage.textContent).toContain('项目:未命名游戏原型'); expect(testPlanMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(testPlanMessage.textContent).toContain( '当前证据:最近 run 已通过 run-main-shortcut-trace;预览 未启动;入口 未见 trace 产物', ); expect(testPlanMessage.textContent).toContain( - '1. 启动预览:/run 后确认首屏不空白', + '启动预览:/run 后确认首屏不空白', ); expect(testPlanMessage.textContent).toContain( - '2. 30 秒理解:目标、操作、得分/失败和重开可见', + '30 秒理解:目标、操作、得分/失败和重开可见', ); expect(testPlanMessage.textContent).toContain( - '3. 输入验证:键盘/点击/触屏至少一种可完成核心动作', + '输入验证:键盘/点击/触屏至少一种可完成核心动作', ); + expect(testPlanMessage.textContent).toContain('结局验证:胜利或失败后可重开'); expect(testPlanMessage.textContent).toContain( - '4. 结局验证:胜利或失败后可重开', - ); - expect(testPlanMessage.textContent).toContain( - '5. 回归检查:/mobile;/accessibility;/performance;/audio', + '回归检查:/mobile;/accessibility;/performance;/audio', ); expect(testPlanMessage.textContent).toContain( 'preview-readiness:程序组 / Preview 执行静态自检 · 待处理', @@ -432,7 +437,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/audience'); const audienceMessages = await screen.findAllByText(/首批试玩对象:/); - const audienceMessage = audienceMessages[audienceMessages.length - 1]; + const audienceMessage = messageBubble( + audienceMessages[audienceMessages.length - 1], + ); expect(audienceMessage.textContent).toContain('项目:未命名游戏原型'); expect(audienceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(audienceMessage.textContent).toContain( @@ -545,7 +552,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/invite'); const inviteMessages = await screen.findAllByText(/试玩邀请:/); - const inviteMessage = inviteMessages[inviteMessages.length - 1]; + const inviteMessage = messageBubble( + inviteMessages[inviteMessages.length - 1], + ); expect(inviteMessage.textContent).toContain('项目:未命名游戏原型'); expect(inviteMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(inviteMessage.textContent).toContain( @@ -723,7 +732,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/survey'); const surveyMessages = await screen.findAllByText(/试玩问卷:/); - const surveyMessage = surveyMessages[surveyMessages.length - 1]; + const surveyMessage = messageBubble( + surveyMessages[surveyMessages.length - 1], + ); expect(surveyMessage.textContent).toContain('项目:未命名游戏原型'); expect(surveyMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(surveyMessage.textContent).toContain( @@ -830,7 +841,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/cover'); const coverMessages = await screen.findAllByText(/封面与缩略图:/); - const coverMessage = coverMessages[coverMessages.length - 1]; + const coverMessage = messageBubble(coverMessages[coverMessages.length - 1]); expect(coverMessage.textContent).toContain('项目:未命名游戏原型'); expect(coverMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -939,7 +950,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/screenshots'); const screenshotMessages = await screen.findAllByText(/宣传截图:/); - const screenshotMessage = screenshotMessages[screenshotMessages.length - 1]; + const screenshotMessage = messageBubble( + screenshotMessages[screenshotMessages.length - 1], + ); expect(screenshotMessage.textContent).toContain('项目:未命名游戏原型'); expect(screenshotMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -1048,7 +1061,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/trailer'); const trailerMessages = await screen.findAllByText(/试玩短视频:/); - const trailerMessage = trailerMessages[trailerMessages.length - 1]; + const trailerMessage = messageBubble( + trailerMessages[trailerMessages.length - 1], + ); expect(trailerMessage.textContent).toContain('项目:未命名游戏原型'); expect(trailerMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(trailerMessage.textContent).toContain( @@ -1158,7 +1173,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/faq'); const faqMessages = await screen.findAllByText(/试玩 FAQ:/); - const faqMessage = faqMessages[faqMessages.length - 1]; + const faqMessage = messageBubble(faqMessages[faqMessages.length - 1]); expect(faqMessage.textContent).toContain('项目:未命名游戏原型'); expect(faqMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(faqMessage.textContent).toContain( @@ -1263,7 +1278,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/post'); const postMessages = await screen.findAllByText(/社区发布文案:/); - const postMessage = postMessages[postMessages.length - 1]; + const postMessage = messageBubble(postMessages[postMessages.length - 1]); expect(postMessage.textContent).toContain('项目:未命名游戏原型'); expect(postMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(postMessage.textContent).toContain( @@ -1374,7 +1389,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/store'); const storeMessages = await screen.findAllByText(/上架资料:/); - const storeMessage = storeMessages[storeMessages.length - 1]; + const storeMessage = messageBubble(storeMessages[storeMessages.length - 1]); expect(storeMessage.textContent).toContain('项目:未命名游戏原型'); expect(storeMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(storeMessage.textContent).toContain( @@ -1484,7 +1499,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/media-kit'); const mediaKitMessages = await screen.findAllByText(/媒体资料包:/); - const mediaKitMessage = mediaKitMessages[mediaKitMessages.length - 1]; + const mediaKitMessage = messageBubble( + mediaKitMessages[mediaKitMessages.length - 1], + ); expect(mediaKitMessage.textContent).toContain('项目:未命名游戏原型'); expect(mediaKitMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(mediaKitMessage.textContent).toContain( @@ -1591,8 +1608,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/release-notes'); const releaseNotesMessages = await screen.findAllByText(/试玩更新说明:/); - const releaseNotesMessage = - releaseNotesMessages[releaseNotesMessages.length - 1]; + const releaseNotesMessage = messageBubble( + releaseNotesMessages[releaseNotesMessages.length - 1], + ); expect(releaseNotesMessage.textContent).toContain('项目:未命名游戏原型'); expect(releaseNotesMessage.textContent).toContain( '一句话:做一个厨房弹幕游戏', @@ -1709,7 +1727,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/known-issues'); const knownIssueMessages = await screen.findAllByText(/已知问题清单:/); - const knownIssueMessage = knownIssueMessages[knownIssueMessages.length - 1]; + const knownIssueMessage = messageBubble( + knownIssueMessages[knownIssueMessages.length - 1], + ); expect(knownIssueMessage.textContent).toContain('项目:未命名游戏原型'); expect(knownIssueMessage.textContent).toContain( '当前状态:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed;预览 未启动', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index 6da7eacc0..4d0019dff 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectAndDesignShortcutFlow( invoke: PreviewShortcutInvoke, @@ -43,11 +44,12 @@ export async function assertProjectAndDesignShortcutFlow( ), ).toBe(true); submitChat('/agent-conversations'); - expect( - await screen.findByText( - /Agent 对话读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read \.agent\/conversations\/agents\/art-asset-plan\.jsonl/, - ), - ).not.toBeNull(); + const agentConversationMessage = + await screen.findByText('Agent 对话读取命令:'); + const agentConversationBubble = messageBubble(agentConversationMessage); + expect(agentConversationBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read .agent/conversations/agents/art-asset-plan.jsonl', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向对话' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -60,11 +62,12 @@ export async function assertProjectAndDesignShortcutFlow( }), ); submitChat('/agent-memories'); - expect( - await screen.findByText( - /Agent 私有记忆读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read memory\/agents\/art\/asset\.md/, - ), - ).not.toBeNull(); + const agentMemoryMessage = + await screen.findByText('Agent 私有记忆读取命令:'); + const agentMemoryBubble = messageBubble(agentMemoryMessage); + expect(agentMemoryBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read memory/agents/art/asset.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向记忆' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -126,7 +129,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/goal'); expect(await screen.findByText(/创作目标:/)).not.toBeNull(); const goalMessages = screen.getAllByText(/创作目标:/); - const goalMessage = goalMessages[goalMessages.length - 1]; + const goalMessage = messageBubble(goalMessages[goalMessages.length - 1]); expect(goalMessage.textContent).toContain('Manifest:做一个厨房弹幕游戏'); expect(goalMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -184,7 +187,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/spec'); const specMessages = await screen.findAllByText(/创作规格包:/); - const specMessage = specMessages[specMessages.length - 1]; + const specMessage = messageBubble(specMessages[specMessages.length - 1]); expect(specMessage.textContent).toContain('项目:未命名游戏原型'); expect(specMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(specMessage.textContent).toContain( @@ -284,7 +287,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/mvp'); expect(await screen.findByText(/MVP 范围:/)).not.toBeNull(); const mvpMessages = screen.getAllByText(/MVP 范围:/); - const mvpMessage = mvpMessages[mvpMessages.length - 1]; + const mvpMessage = messageBubble(mvpMessages[mvpMessages.length - 1]); expect(mvpMessage.textContent).toContain('项目:未命名游戏原型'); expect(mvpMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mvpMessage.textContent).toContain( @@ -380,7 +383,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/pitch'); const pitchMessages = await screen.findAllByText(/试玩定位:/); - const pitchMessage = pitchMessages[pitchMessages.length - 1]; + const pitchMessage = messageBubble(pitchMessages[pitchMessages.length - 1]); expect(pitchMessage.textContent).toContain('项目:未命名游戏原型'); expect(pitchMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(pitchMessage.textContent).toContain( @@ -475,7 +478,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/demo'); const demoMessages = await screen.findAllByText(/试玩讲解稿:/); - const demoMessage = demoMessages[demoMessages.length - 1]; + const demoMessage = messageBubble(demoMessages[demoMessages.length - 1]); expect(demoMessage.textContent).toContain('项目:未命名游戏原型'); expect(demoMessage.textContent).toContain( '30 秒开场:这是《未命名游戏原型》,目标是做一个厨房弹幕游戏', @@ -575,7 +578,9 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/rules'); expect(await screen.findByText(/玩法操作:/)).not.toBeNull(); const controlMessages = screen.getAllByText(/玩法操作:/); - const controlMessage = controlMessages[controlMessages.length - 1]; + const controlMessage = messageBubble( + controlMessages[controlMessages.length - 1], + ); expect(controlMessage.textContent).toContain('项目:未命名游戏原型'); expect(controlMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(controlMessage.textContent).toContain( @@ -679,7 +684,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/tutorial'); const tutorialMessages = await screen.findAllByText(/新手引导:/); - const tutorialMessage = tutorialMessages[tutorialMessages.length - 1]; + const tutorialMessage = messageBubble( + tutorialMessages[tutorialMessages.length - 1], + ); expect(tutorialMessage.textContent).toContain('项目:未命名游戏原型'); expect(tutorialMessage.textContent).toContain('首屏目标:做一个厨房弹幕游戏'); expect(tutorialMessage.textContent).toContain( @@ -782,7 +789,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/mobile'); const mobileMessages = await screen.findAllByText(/移动试玩:/); - const mobileMessage = mobileMessages[mobileMessages.length - 1]; + const mobileMessage = messageBubble( + mobileMessages[mobileMessages.length - 1], + ); expect(mobileMessage.textContent).toContain('项目:未命名游戏原型'); expect(mobileMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mobileMessage.textContent).toContain( @@ -891,8 +900,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/compatibility'); const compatibilityMessages = await screen.findAllByText(/兼容性说明:/); - const compatibilityMessage = - compatibilityMessages[compatibilityMessages.length - 1]; + const compatibilityMessage = messageBubble( + compatibilityMessages[compatibilityMessages.length - 1], + ); expect(compatibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(compatibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1006,8 +1016,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/accessibility'); const accessibilityMessages = await screen.findAllByText(/可读性与无障碍:/); - const accessibilityMessage = - accessibilityMessages[accessibilityMessages.length - 1]; + const accessibilityMessage = messageBubble( + accessibilityMessages[accessibilityMessages.length - 1], + ); expect(accessibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(accessibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1129,8 +1140,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/localization'); const localizationMessages = await screen.findAllByText(/本地化与文案:/); - const localizationMessage = - localizationMessages[localizationMessages.length - 1]; + const localizationMessage = messageBubble( + localizationMessages[localizationMessages.length - 1], + ); expect(localizationMessage.textContent).toContain('项目:未命名游戏原型'); expect(localizationMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(localizationMessage.textContent).toContain( @@ -1259,8 +1271,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/performance'); const performanceMessages = await screen.findAllByText(/性能与加载:/); - const performanceMessage = - performanceMessages[performanceMessages.length - 1]; + const performanceMessage = messageBubble( + performanceMessages[performanceMessages.length - 1], + ); expect(performanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(performanceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(performanceMessage.textContent).toContain( @@ -1376,7 +1389,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/polish'); const polishMessages = await screen.findAllByText(/试玩前打磨:/); - const polishMessage = polishMessages[polishMessages.length - 1]; + const polishMessage = messageBubble( + polishMessages[polishMessages.length - 1], + ); expect(polishMessage.textContent).toContain('项目:未命名游戏原型'); expect(polishMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(polishMessage.textContent).toContain( @@ -1488,7 +1503,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/credits'); const creditMessages = await screen.findAllByText(/素材署名:/); - const creditMessage = creditMessages[creditMessages.length - 1]; + const creditMessage = messageBubble( + creditMessages[creditMessages.length - 1], + ); expect(creditMessage.textContent).toContain('当前资产:2 个'); expect(creditMessage.textContent).toContain('来源分布:上传 1 / 画板 1'); expect(creditMessage.textContent).toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index ce71e30ed..408b6041e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -7,6 +7,7 @@ import { within, } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectToolsAndPreviewFlow( invoke: PreviewShortcutInvoke, @@ -36,7 +37,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/feedback'); const feedbackMessages = await screen.findAllByText(/试玩反馈:/); - const feedbackMessage = feedbackMessages[feedbackMessages.length - 1]; + const feedbackMessage = messageBubble( + feedbackMessages[feedbackMessages.length - 1], + ); expect(feedbackMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -125,7 +128,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/retention'); const retentionMessages = await screen.findAllByText(/复玩观察:/); - const retentionMessage = retentionMessages[retentionMessages.length - 1]; + const retentionMessage = messageBubble( + retentionMessages[retentionMessages.length - 1], + ); expect(retentionMessage.textContent).toContain('项目:未命名游戏原型'); expect(retentionMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(retentionMessage.textContent).toContain( @@ -233,7 +238,7 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/share'); const shareMessages = await screen.findAllByText(/试玩交付:/); - const shareMessage = shareMessages[shareMessages.length - 1]; + const shareMessage = messageBubble(shareMessages[shareMessages.length - 1]); expect(shareMessage.textContent).toContain('项目:未命名游戏原型'); expect(shareMessage.textContent).toContain('目录:/tmp/authorized-game'); expect(shareMessage.textContent).toContain( @@ -438,11 +443,11 @@ export async function assertProjectToolsAndPreviewFlow( expect(composerInput).toHaveProperty('value', '/read game/index.html'); await waitFor(() => expect(document.activeElement).toBe(composerInput)); submitChat('/run-artifacts'); - expect( - await screen.findByText( - /最近 Run 产物读取命令:[\s\S]*exports\/README\.md · 128B · fnv1a64:exports:\/read exports\/README\.md/, - ), - ).not.toBeNull(); + const runArtifactsHeading = + await screen.findByText('最近 Run 产物读取命令:'); + expect(messageBubble(runArtifactsHeading).textContent).toContain( + 'exports/README.md · 128B · fnv1a64:exports:/read exports/README.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取首个 Run 产物' })); expect(composerInput).toHaveProperty('value', '/read exports/README.md'); expect(invoke).not.toHaveBeenCalledWith( @@ -457,8 +462,9 @@ export async function assertProjectToolsAndPreviewFlow( submitChat('/passes'); const passArtifactMessages = await screen.findAllByText(/Agent 轮次产物读取命令:/); - const passArtifactMessage = - passArtifactMessages[passArtifactMessages.length - 1]; + const passArtifactMessage = messageBubble( + passArtifactMessages[passArtifactMessages.length - 1], + ); expect(passArtifactMessage.textContent).toContain( '.agent/passes/pass-1/agenda.md · 96B · fnv1a64:agenda', ); @@ -564,13 +570,12 @@ export async function assertProjectToolsAndPreviewFlow( expect(screen.getByText(/下一步:设计实现组 \/ Director/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Trace' })); - expect( - await screen.findByText( - (content) => - content.trimStart().startsWith('Run:run-main-shortcut-trace') && - content.includes('产物快照:'), - ), - ).not.toBeNull(); + const traceHeading = await screen.findByText( + (content, element) => + element?.tagName === 'P' && + content.trimStart().startsWith('Run:run-main-shortcut-trace'), + ); + expect(messageBubble(traceHeading).textContent).toContain('产物快照:'); expect(screen.getByText(/产物快照:/)).not.toBeNull(); expect( screen.getByText(/exports\/README\.md · fnv1a64:exports/), diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts new file mode 100644 index 000000000..60bd9e43b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts @@ -0,0 +1,7 @@ +export function messageBubble(element: Element): HTMLElement { + const bubble = element.closest('.message'); + if (!bubble) { + throw new Error('Expected chat message bubble'); + } + return bubble; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 90bdc35b3..3ed93a19a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -692,9 +692,16 @@ export function registerRuntimeSettingsTests() { }) => void) | undefined; let readCount = 0; + let modelReadResolved = false; const invoke = vi.fn((command: string) => { if (command === 'read_game_creator_app_config') { readCount += 1; + // 首页模型选择器会在挂载时读取一次配置;让首次读取立即完成, + // 以免一直处于 pending 干扰运行时配置对话框的读取计数。 + if (!modelReadResolved) { + modelReadResolved = true; + return Promise.resolve({ config: { selectedModelId: 'quality' } }); + } return new Promise((resolve) => { resolveRead = resolve as typeof resolveRead; }); @@ -723,10 +730,13 @@ export function registerRuntimeSettingsTests() { true, ); + // 首页模型选择器挂载时会读取一次配置(上面已让首次读取立即完成), + // 这里只校验:对话框处于「正在读取」时点击读取/保存不会新增读取请求。 + const readsWhileReading = readCount; fireEvent.click(screen.getByRole('button', { name: '读取' })); fireEvent.click(screen.getByRole('button', { name: '保存' })); - expect(readCount).toBe(1); + expect(readCount).toBe(readsWhileReading); await act(async () => { resolveRead?.({ path: '/home/test/AppData/game-creator.config.json', diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 2ce57324c..5e1ea8b3d 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -6,6 +6,7 @@ import { render, screen, waitFor, + within, } from '@testing-library/react'; import { createRef } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; @@ -77,25 +78,132 @@ test('falls back to the default model when the saved selection was removed', asy config: { selectedModelId: command === 'select_game_creator_model' - ? input.modelId + ? (input as { modelId: string }).modelId : 'private-old-model', selectedModelIsDefault: command === 'select_game_creator_model' - ? Boolean(input.isDefault) + ? Boolean((input as { isDefault: boolean }).isDefault) : false, }, })); const onReady = vi.fn(); render(); await screen.findByText('所选模型已停用,已切换为默认模型'); - await waitFor(() => - expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { - modelId: 'quality', - isDefault: true, - }), - ); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('private-old-model')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'quality', + isDefault: true, + }); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('高质量'); +}); + +test('failed catalog can be refreshed without enabling submission', async () => { + vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline')); + const onReady = vi.fn(); + render(); + await screen.findByText('模型列表加载失败'); + expect(onReady).toHaveBeenLastCalledWith(false); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); +}); + +test('a failed save keeps submission unavailable', async () => { + invoke.mockImplementation(async (command) => { + if (command === 'select_game_creator_model') throw new Error('disk full'); + return { + config: { selectedModelId: 'quality', selectedModelIsDefault: true }, + }; + }); + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await screen.findByText('模型选择保存失败'); + expect(onReady).toHaveBeenLastCalledWith(false); +}); + +test('closes the menu when clicking outside', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.mouseDown(document.body); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('closes the menu on Escape', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('marks the default model in the menu', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const qualityOption = screen.getByRole('option', { name: /高质量/ }); + expect(within(qualityOption).getByText('默认')).not.toBeNull(); + const fastOption = screen.getByRole('option', { name: '快速' }); + expect(within(fastOption).queryByText('默认')).toBeNull(); +}); + +test('keeps model options disabled while a selection save is in flight', async () => { + let resolveSave: ((value: unknown) => void) | undefined; + invoke.mockImplementation(async (command) => { + if (command === 'select_game_creator_model') { + return new Promise((resolve) => { + resolveSave = resolve; + }); + } + return { + config: { selectedModelId: 'quality', selectedModelIsDefault: true }, + }; + }); + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await screen.findByRole('option', { name: '快速' }); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + + // 保存期间重新打开菜单:可以查看,但选项应禁用,避免并发选择。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('option', { name: /高质量/ })).toHaveProperty( + 'disabled', + true, + ); + expect(onReady).toHaveBeenLastCalledWith(false); + + resolveSave?.({ config: { selectedModelId: 'fast' } }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + false, + ); }); test('keeps the last good catalog when a background refresh fails', async () => { @@ -106,7 +214,7 @@ test('keeps the last good catalog when a background refresh fails', async () => fireEvent(window, new Event('focus')); await screen.findByText('模型列表加载失败'); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); - expect(screen.getByRole('option', { name: '高质量' })).not.toBeNull(); + expect(screen.getByRole('option', { name: /高质量/ })).not.toBeNull(); expect(onReady).toHaveBeenLastCalledWith(true); }); @@ -165,31 +273,6 @@ test('pre-send validation falls back when the selected model is disabled', async expect(savedModelId).toBe('quality'); }); -test('failed catalog can be refreshed without enabling submission', async () => { - vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline')); - const onReady = vi.fn(); - render(); - await screen.findByText('模型列表加载失败'); - expect(onReady).toHaveBeenLastCalledWith(false); - fireEvent.click(screen.getByRole('button', { name: '对话模型' })); - fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); - await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); -}); - -test('a failed save keeps submission unavailable', async () => { - invoke.mockImplementation(async (command) => { - if (command === 'select_game_creator_model') throw new Error('disk full'); - return { config: { selectedModelId: 'quality' } }; - }); - const onReady = vi.fn(); - render(); - await screen.findByRole('button', { name: '对话模型' }); - fireEvent.click(screen.getByRole('button', { name: '对话模型' })); - fireEvent.click(screen.getByRole('option', { name: '快速' })); - await screen.findByText('模型选择保存失败'); - expect(onReady).toHaveBeenLastCalledWith(false); -}); - test('follows the new server default when the saved selection was the default', async () => { const onReady = vi.fn(); render(); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index d9130a640..4418da177 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -10,12 +10,14 @@ import { isBackendReady, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + waitForBackendReady, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; @@ -26,6 +28,7 @@ function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { return { schemaVersion: spacetimeDataDir ? 2 : 1, database: expectedDatabase, + updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { @@ -127,6 +130,47 @@ describe('AI 游戏创作配套后端复用门禁', () => { }), ).resolves.toBe(true); }); + + test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { + const state = backendState(expectedDataDir); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker'].signal = null; + + expect(readBackendServiceFailure(state)).toEqual({ + serviceName: 'bgfilter-worker', + failure: 'code=1', + }); + }); + + test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { + const state = backendState(resolve('server-rs/.spacetimedb/other/data')); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + + expect(readBackendServiceFailure(state)).toBeNull(); + }); + + test('等待后端时立即传播状态文件中的服务失败', async () => { + const initialState = backendState(expectedDataDir); + initialState.updatedAt = '2026-09-04T08:00:00.000Z'; + const state = backendState(expectedDataDir); + state.updatedAt = '2026-09-04T08:00:01.000Z'; + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 98; + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + }); + let readCount = 0; + + await expect( + waitForBackendReady(child, 100, { + checkBackendReady: async () => false, + readState: () => (readCount++ === 0 ? initialState : state), + }), + ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); + }); }); describe('AI 游戏创作启动子进程生命周期', () => { diff --git a/docs/README.md b/docs/README.md index 7453573fe..95647a6f4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 78e074daf..507747e57 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3372,8 +3372,14 @@ }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "可选固定图集切片合同。省略时沿用全图 alpha 连通域自动拆分;传 grid-2x2 时服务端要求生成四个固定象限,并按左上、右上、左下、右下各持久化一个独立切片。该模式适用于需要恰好四类核心运行时素材的游戏,不会猜测等分裁切。" + "deprecated": true, + "description": "历史兼容字段,新的调用请使用 sliceCount。" + }, + "sliceCount": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "可选的目标切片数量;省略时按图像内容自动识别。" }, "screenColor": { "type": ["string", "null"], @@ -3597,15 +3603,21 @@ }, "iconImageSrcs": { "type": "array", - "description": "默认模式识别图集中全部有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。sliceLayout=grid-2x2 时固定返回左上、右上、左下、右下四个格子的切片,各格内的零散视觉细节不会被拆成额外素材。", + "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "仅当请求使用固定切片合同且主图完成透明化、切片持久化后返回。调用方可将该字段与 iconImageSrcs=4 共同作为固定四类素材的来源证明。" + "deprecated": true, + "description": "历史兼容字段。" + }, + "sliceCount": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "实际生成的切片数量。" }, "sliceWarning": { "anyOf": [ diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 13bb6403e..14980e6d7 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -185,7 +185,7 @@ completed -> starting(nextSlice) idle -> focused(document|art|audio|version) -> idle ``` -- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。 +- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `
    `,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown` 的 `skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `` 双重转义为字面量 `<tag>`。
     - 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
     - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
     - 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
    diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
    index 8dcc07aa3..00092043a 100644
    --- a/docs/project-memory/shared-memory/decision-log.md
    +++ b/docs/project-memory/shared-memory/decision-log.md
    @@ -7961,6 +7961,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
     - 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。
     - 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。
     
    +## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显
    +
    +- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。
    +- Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。
    +- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。
    +
     ## 2026-08-31 AGC 错误报告与诊断上传
     
     - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。
    diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md
    index 9d754cb5d..ffd3726a7 100644
    --- a/docs/project-memory/shared-memory/document-map.md
    +++ b/docs/project-memory/shared-memory/document-map.md
    @@ -22,14 +22,15 @@
     AI 游戏创作 / DirectProject / UI workflow:
     
     1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
    -2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
    -3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
    -4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
    -5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
    -6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
    -7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
    -8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
    -9. UI 编辑器、宿主壳和当前测试专题文档
    +2. `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`
    +3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
    +4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
    +5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
    +6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
    +7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
    +8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
    +9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
    +10. UI 编辑器、宿主壳和当前测试专题文档
     
     图片画布 / 媒体生成:
     
    diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
    index 1651f34d8..a32351625 100644
    --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
    +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
    @@ -10,6 +10,8 @@
     - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
     - 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId` 与 `selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
     - `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
    +- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
    +- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
     - 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
     
     ## 验收
    diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    index ea07fb2a2..2f63f00e6 100644
    --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    @@ -238,7 +238,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
     
     - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。
     - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。
    -- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
    +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;AGC 是 DirectProject 用户消息的唯一持久化来源:每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。Codex 回显的 `userMessage` / `role=user` item 只用于事件观察和关联校验,不得再次追加到项目 JSONL,避免把服务端 echo 当作第二条用户消息;本地 user item 写入与 Codex echo 过滤必须区分来源,不能用同一个“忽略 user item”入口阻断 AGC 自己的预写。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
     - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。
     - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。
     - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。
    @@ -246,6 +246,13 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
     - 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。
     - 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。
     
    +### 2026-09-03 AGC 客户端能力以 MCP 暴露
    +
    +- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。客户端对话入口继续驱动 Codex app-server;app-server 通过客户端随附的 stdio MCP 子进程调用审核后的客户端能力。客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。
    +- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。
    +- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。
    +- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。
    +
     ### 2026-08-10 Supervisor 边做边聊与条件中断
     
     - 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。
    @@ -882,7 +889,7 @@ game-project/
     - 聊天输入 `/status` 会读取 `.agent/manifest.json` 并在聊天里汇总项目目录、任务状态、资产数量、预览状态和最近命令,不向普通用户暴露任务或文件面板。
     - 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;主窗口最近项目文件可一键读取,也可一键填入 `/read` 或 `/asset-register` 草稿,但资产登记仍必须走聊天确认;`/checkpoints` 复用 `file.list` / `file.read` 只读列出最近 checkpoint id、文件数、大小和可复制的 `/diff` / `/restore` 命令,主窗口最近 checkpoint 列表也可填入对应草稿;普通用户仍不暴露文件读写面板。
     - 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源,资产列表消息和主窗口最近项目资产入口都可一键填入对应资产的 `/read` 草稿;聊天输入 `/art` 只使用当前已加载 manifest 盘点美术素材,并提供首版美术生成或读取美术清单草稿,不直接读取文件、不触发平台生成或画板同步;聊天输入 `/audio` 只使用当前已加载 manifest 盘点音频素材,并提供登记音效或读取音频清单草稿,不直接读取文件、不触发资产写入;`/asset-register 路径 [kind] [mediaType]` 可确认后登记项目内已有资产;主窗口音效快捷入口只填入 `/asset-register assets/audio/sfx.wav audio audio/wav` 草稿,不直接写 manifest;普通用户仍不暴露资产面板。
    -- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;普通用户仍不暴露文件写入或删除能力。
    +- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;聊天回执中的正文必须包装为安全的 `
    ` 代码块,保留源码字面量但不得执行 HTML;普通用户仍不暴露文件写入或删除能力。
     - 主窗口常用生成产物入口只把 `game/index.html`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json` 和 `exports/README.md` 的 `/read` 草稿填入聊天输入框;聊天输入 `/artifacts` 只列出这组固定读取命令并提供首个 `/read` 草稿,聊天输入 `/run-artifacts` 只列出最近 run trace 里的产物读取命令并提供首个 `/read` 草稿,聊天输入 `/run-files` 只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 的读取命令并提供首个 `/read` 草稿;读取仍由聊天侧 `file.read` 权限流执行。
     - 聊天输入 `/logs` 只列出 `.agent/logs/command.log`、`.agent/logs/preview.log` 和 `.agent/logs/agent.log` 对应的 `/read ...` 草稿 / 命令,并提供首个 `/read` 草稿;该命令不直接读取日志,不新增普通用户日志面板,实际读取仍由聊天侧 `file.read` 权限流执行。
     - 聊天输入 `/tasks` 会读取 `.agent/manifest.json` 并在聊天里列出专业组、角色、任务状态、产物交接和下一步可执行任务;普通用户仍不暴露任务面板。
    @@ -1075,7 +1082,7 @@ game-project/
     - 内部 owner 验证只接受 GUI / CLI 完整 16 任务 DAG 中 `agent-ready-task-scheduler` 启动的确定性直接 child、当前活跃根和完整 project/source/profile/Agent/run/parent/root/binding 身份。错误 source、delegated run、历史或终态根、非当前活跃根、跨 Agent/run 凭证均失败关闭;再次 mutation 使旧凭证失效,相同身份恢复可按当前事实确定性重验。本阶段不扩到后置 `publish-package`。`code-prototype` 与 `preview-readiness` 继续执行真实 `game.static_smoke`,`preview-playtest` 继续独立执行浏览器验收;任何 owner 文件凭证都不能替代可玩证据。
     - `design-foundation` 的 2026-07-26 职责隔离继续有效:项目文件仍只允许 `memory/project.md`、`game/game_design.md` 和配置 Key 时的固定 `assets/ui-prototype.png`,禁止修改 `game/index.html`、调用 smoke / preview / process 或恢复整项目。未配置 External Editor API Key 时 `art-director` 保持只读协调;配置 Key 时它是条件 Canvas owner,必须生成并登记 `assets/art-spec.png`,成功 `canvas.asset_generate` 为本人当前 revision 形成普通验证凭证,不能被只读分类吞掉。配置 Key 时 UI 原型、透明图集、Canvas 登记和视觉门仍按既有合同执行,内部 owner 文件验证不替代图片证据。
     - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
    -- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。
    +- 视觉 Agent 只负责指导 Codex 选择合适的图片/编辑/图集工具并提供项目上下文,不再固定图片数量、文件槽位、素材类别或 spritesheet 布局;请求可按玩法需要生成单图、多图或任意切片布局。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;权限、计费、幂等、资源登记和安全校验保持不变。
     - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。
     - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。
     - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。
    @@ -1287,6 +1294,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
     - 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。
     - 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish`、`art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。
     
    +## 2026-09-04 AGC 项目聊天 Markdown 与流式回复渲染
    +
    +- 项目开发工作台的 `ProjectWorkspaceChatPane`、`ProjectSupervisorView` 与 `SupervisorChatOnlyView` 继续共享 `ChatMessage` / `visibleMessages` 数据结构;聊天 Markdown 只作为前端表现层能力,不新增消息字段、持久化格式、后端 DTO 或事件协议。
    +- 三个视图统一复用 `apps/ai-game-creator-shell/src/components/ChatMarkdownMessage`。assistant 历史消息和流式临时回复使用 `react-markdown + remark-gfm` 渲染;用户消息与命令草稿保持纯文本。调用方仍先执行现有的 `projectSupervisorVisibleConversationText` 安全文案归一化,再交给展示组件。
    +- 流式回复以事件中的 `accumulatedText` 作为当前完整草稿:每次更新替换上一版临时正文,不在渲染层自行拼接 `deltaText`。既有 `runId` / sequence 去重、最终 assistant 持久化和历史回放语义保持不变。
    +- Markdown 禁止原始 HTML;链接和图片只显示普通文本,不产生可点击或可加载的外部资源。后续若开放安全外链,必须另行评估协议白名单、窗口策略和审计边界,并保留实现 TODO。
    +- Markdown 元素样式使用 Tailwind 内联 class,限定在聊天消息组件内部,不改全局 `.message`、资源文档预览或启动器全局 Agent 聊天。组件异常按单条消息回退纯文本,不能使整个聊天面板崩溃。
    +- 流式更新沿用“仅在用户接近底部时跟随”的滚动语义;用户主动查看历史时不得被实时 Markdown 高度变化强制拉回底部。实现验收需覆盖 GFM、未闭合 Markdown 中间态、HTML/链接/图片安全、三视图一致性、流式去重、历史回放和桌面/移动视口。
    +
     ## 2026-08-29 DirectProject 受控联网搜索闭环
     
     - 本次正式产品范围只包含 `DirectProject` 单 Codex Agent;`Provider`、`ToolHost`、`DirectHome` 不新增联网工具桥,也不纳入本次联网路由覆盖。受控联网唯一实现为 `agc_tools.agc_web_search`:Codex app-server 通过审核的 STDIO MCP 工具目录发起调用,客户端 loopback 工具桥执行固定 Bing RSS HTTPS 请求,过滤非 HTTPS、凭据 URL、回环 / 私网 / 本地域名,返回有界标题、摘要和结果链接,并以“不可信网页内容”标签回传。
    diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
    new file mode 100644
    index 000000000..de0c1b0a2
    --- /dev/null
    +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md	
    @@ -0,0 +1,58 @@
    +# DirectProject Codex 原始历史与异常恢复
    +
    +更新时间:`2026-09-07`
    +
    +## 目标
    +
    +DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。
    +
    +本方案只适用于 DirectProject,不改变 DirectHome、Agent session 历史或 `runtime/direct-codex/turns` 审计账本。
    +
    +## 文件格式
    +
    +每行采用 Codex CLI rollout 的最小事件外壳,不保存 AGC 自有的顺序号或运行环境字段:
    +
    +```json
    +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}}
    +```
    +
    +`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
    +
    +DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。
    +
    +## 正常回合
    +
    +1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
    +2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。
    +3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
    +4. 正常 `turn/completed: completed` 不生成额外记录。
    +
    +## 异常回合收尾
    +
    +AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。
    +
    +`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item:
    +
    +```json
    +{"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}}
    +```
    +
    +合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。
    +
    +`rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。
    +
    +Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。过滤同时识别 role=user 的完整上下文标签包裹文本,即使该 item 没有 `internal_chat_message_metadata_passthrough` 元数据,也不能把它当成用户回合。
    +
    +## 恢复
    +
    +创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。新 thread 已进入连接池但历史读取或注入失败时,必须先从池中淘汰并取消订阅该 thread,重试只能创建新 thread 并重新注入。
    +
    +`clientUserMessageId` 仅作为 Codex 用户消息的稳定标识随 `turn/start` 发送,不等价于 turn 级 exactly-once 幂等。断线后的重试仍须由项目侧持久化 turn ledger 或服务端去重合同决定,不能仅凭该字段再次执行。
    +
    +聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。
    +
    +DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器;Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
    +
    +## 写入与损坏边界
    +
    +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。
    diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
    index bc6aeeafe..cf52261c4 100644
    --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
    +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
    @@ -14,6 +14,10 @@
     
     ## 3. 已确定的产品边界
     
    +### 3.0 客户端能力 MCP 暴露边界(2026-09-03)
    +
    +客户端仍由现有对话入口启动并驱动 Codex;MCP 只是把客户端已审核的项目、文件、资源、画布、生成和预览能力暴露给该 Codex 或其它 Host。客户端只负责账号、项目路径、权限、计费、幂等、锁和恢复等自身安全,不替 Codex 做高层意图/完成门禁。审核 Skill 的索引和正文可作为只读 MCP resource 提供,第三方扩展不得获得客户端会话凭据、内部路径或 bridge token;该能力与公网 `/api/external/v1/mcp` 保持独立。
    +
     ### 3.1 客户端安装、运行时注入
     
     - 扩展内容保存在 AGC 客户端的扩展仓库。
    diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
    index 789c00173..d8a570436 100644
    --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
    +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
    @@ -89,6 +89,10 @@ Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Rea
     
     不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
     
    +### 2026-09-03 客户端对话与 MCP 能力边界
    +
    +客户端对话仍由现有 Codex app-server 链路完成;MCP 只暴露客户端自身业务能力和审核 Skill 指导。客户端安全门禁限于账号、项目路径、权限、计费、幂等、锁、revision 与恢复,不根据 Codex 自然语言替代 Codex 决定业务动作。外部 Host 返回如需旁路归档,可使用显式记录工具,但不替代现有 conversation projection,也不触发资源、状态或完成判定。
    +
     jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
     
     ## 5. 记录合同
    diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
    index 828bee880..4b543bef8 100644
    --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
    +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
    @@ -76,6 +76,7 @@
     ### 失败生成任务归档与任务侧栏
     
     - 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。
    +- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。
     - 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived`。`archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。
     - 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。
     - 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中` 与 `已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。
    diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
    index 69b9c9325..bee46452e 100644
    --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
    +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
    @@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
     
     后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
     
    -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
    +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
     
     Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID  /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
     
    diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs
    index 198c31aef..949fadf99 100644
    --- a/server-rs/crates/api-server/src/editor_agent/tool.rs
    +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs
    @@ -863,6 +863,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
                 reference_id,
                 reference_image_srcs: Some(reference_image_srcs),
                 icon_descriptions: args.icon_descriptions,
    +            slice_count: None,
                 slice_layout: None,
                 style: None,
                 model: Some(args.model),
    diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs
    index d9f110905..b0663a4b1 100644
    --- a/server-rs/crates/api-server/src/editor_project.rs
    +++ b/server-rs/crates/api-server/src/editor_project.rs
    @@ -8579,6 +8579,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
                         spritesheet_height: source_height,
                         icon_image_srcs: Vec::new(),
                         slice_layout: None,
    +                    slice_count: None,
                         slice_warning: None,
                         prompt,
                         actual_prompt: generated.actual_prompt,
    @@ -8645,6 +8646,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
                     spritesheet_height: source_height,
                     icon_image_srcs: Vec::new(),
                     slice_layout: None,
    +                slice_count: None,
                     slice_warning: None,
                     prompt,
                     actual_prompt: generated.actual_prompt,
    @@ -8729,6 +8731,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
             slice_source,
             request_context.external_call_deadline(),
             None,
    +        None,
         )
         .await
         {
    @@ -8891,6 +8894,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
                 spritesheet_height,
                 icon_image_srcs,
                 slice_layout: None,
    +            slice_count: None,
                 slice_warning,
                 prompt,
                 actual_prompt: generated.actual_prompt,
    @@ -19059,7 +19063,7 @@ mod tests {
                 .checked_sub(Duration::from_millis(1))
                 .expect("expired deadline should be representable");
     
    -        let error = slice_editor_icon_spritesheet_all(source, Some(expired), None)
    +        let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None)
                 .await
                 .err()
                 .expect("expired CPU budget must fail before decoding");
    @@ -19748,6 +19752,7 @@ mod tests {
                 spritesheet_height: 512,
                 icon_image_srcs: Vec::new(),
                 slice_layout: None,
    +            slice_count: None,
                 slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
                     code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
                     reason: "图集中未识别到可拆分的独立素材。".to_string(),
    diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs
    index 9e8c307aa..f7f318b86 100644
    --- a/server-rs/crates/api-server/src/editor_project_icon.rs
    +++ b/server-rs/crates/api-server/src/editor_project_icon.rs
    @@ -251,6 +251,9 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
         pub(crate) reference_id: String,
         pub(crate) reference_image_srcs: Option>,
         pub(crate) icon_descriptions: Vec,
    +    /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub(crate) slice_count: Option,
         #[serde(default, skip_serializing_if = "Option::is_none")]
         pub(crate) slice_layout: Option,
         #[serde(default, skip_serializing_if = "Option::is_none")]
    @@ -267,8 +270,7 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
         pub(crate) canvas_completion: Option,
     }
     
    -/// Opt-in fixed atlas slicing. Existing callers remain on the default
    -/// connected-component path unless they explicitly request this layout.
    +/// Deprecated compatibility layout. New callers should use `sliceCount`。
     #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
     pub(crate) enum EditorIconSpritesheetSliceLayout {
         #[serde(rename = "grid-2x2")]
    @@ -313,6 +315,8 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse {
         #[serde(skip_serializing_if = "Option::is_none")]
         pub(crate) slice_layout: Option,
         #[serde(skip_serializing_if = "Option::is_none")]
    +    pub(crate) slice_count: Option,
    +    #[serde(skip_serializing_if = "Option::is_none")]
         pub(crate) slice_warning: Option,
         pub(crate) prompt: String,
         pub(crate) actual_prompt: Option,
    @@ -1783,6 +1787,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                         spritesheet_height: source_height,
                         icon_image_srcs: Vec::new(),
                         slice_layout: payload.slice_layout,
    +                    slice_count: Some(0),
                         slice_warning: None,
                         prompt,
                         actual_prompt: generated.actual_prompt,
    @@ -1864,6 +1869,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                     spritesheet_height: source_height,
                     icon_image_srcs: Vec::new(),
                     slice_layout: payload.slice_layout,
    +                slice_count: Some(0),
                     slice_warning: None,
                     prompt,
                     actual_prompt: generated.actual_prompt,
    @@ -1961,6 +1967,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
             slice_source,
             request_context.external_call_deadline(),
             payload.slice_layout,
    +        payload.slice_count,
         )
         .await
         {
    @@ -2053,6 +2060,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                 "spritesheetHeight": spritesheet_height,
                 "iconImageSrcs": &icon_image_srcs,
                 "sliceLayout": payload.slice_layout,
    +            "sliceCount": payload.slice_count,
                 "sliceWarning": &slice_warning,
                 "warning": &generation_warning,
                 "prompt": user_prompt.clone(),
    @@ -2121,6 +2129,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
             icon.asset = item.asset.map(editor_asset_payload_from_record);
         }
     
    +    let slice_count = icon_image_srcs.len();
         Ok(json_success_body(
             Some(&request_context),
             EditorIconSpritesheetGenerationResponse {
    @@ -2129,6 +2138,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                 spritesheet_height,
                 icon_image_srcs,
                 slice_layout: payload.slice_layout,
    +            slice_count: Some(slice_count),
                 slice_warning,
                 prompt,
                 actual_prompt: generated.actual_prompt,
    @@ -2323,6 +2333,7 @@ pub async fn split_editor_icon_spritesheet(
             processing_deadline,
             memory_admission,
             None,
    +        None,
         )
         .await?;
         let prompt = source_resource
    @@ -2398,6 +2409,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all(
         source: DownloadedImage,
         request_deadline: Option,
         slice_layout: Option,
    +    slice_count: Option,
     ) -> Result {
         let processing_deadline =
             resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline);
    @@ -2408,6 +2420,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all(
             processing_deadline,
             memory_admission,
             slice_layout,
    +        slice_count,
         )
         .await
     }
    @@ -2446,6 +2459,7 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
         processing_deadline: Instant,
         memory_admission: Arc,
         slice_layout: Option,
    +    slice_count: Option,
     ) -> Result {
         if Instant::now() >= processing_deadline {
             return Err(editor_icon_spritesheet_processing_timeout_error());
    @@ -2484,7 +2498,9 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
                 }
                 None => prepare_generated_icon_spritesheet_all_by_connected_components(
                     &source,
    -                EDITOR_ICON_SPRITESHEET_MAX_SLICES,
    +                slice_count
    +                    .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
    +                    .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
                     EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
                 ),
             }
    @@ -2505,6 +2521,15 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
                 }
                 Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
             };
    +    if let Some(expected) = slice_count {
    +        if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected
    +        {
    +            return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
    +                "provider": "editor-icon-spritesheet-slicing",
    +                "message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()),
    +            })));
    +        }
    +    }
         if plan.is_empty() {
             return Err(
                 AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
    @@ -2915,6 +2940,7 @@ mod tests {
                 source,
                 None,
                 Some(EditorIconSpritesheetSliceLayout::Grid2x2),
    +            None,
             )
             .await
             .expect("declared 2x2 sheet should slice");
    diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs
    index 0a15d26ef..de48a2d15 100644
    --- a/server-rs/crates/api-server/src/external_editor_api.rs
    +++ b/server-rs/crates/api-server/src/external_editor_api.rs
    @@ -2617,13 +2617,8 @@ mod tests {
                     .is_some_and(|description| description.contains("同步返回 400"))
             );
             assert_eq!(
    -            icon_spritesheet_request["properties"]["sliceLayout"]["enum"],
    -            json!(["grid-2x2"])
    -        );
    -        assert!(
    -            icon_spritesheet_request["properties"]["sliceLayout"]["description"]
    -                .as_str()
    -                .is_some_and(|description| description.contains("固定图集切片合同"))
    +            icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
    +            json!(1)
             );
             let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
                 ["properties"]["style"];
    @@ -2635,11 +2630,7 @@ mod tests {
                     ["sliceWarning"]["anyOf"][0]["$ref"],
                 "#/components/schemas/EditorIconSpritesheetSliceWarning"
             );
    -        assert_eq!(
    -            parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
    -                ["sliceLayout"]["enum"],
    -            json!(["grid-2x2"])
    -        );
    +        assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object());
             assert_eq!(
                 parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"]
                     ["anyOf"][0]["$ref"],
    diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs
    index fa80d9b32..722213247 100644
    --- a/server-rs/crates/api-server/src/external_generation_worker.rs
    +++ b/server-rs/crates/api-server/src/external_generation_worker.rs
    @@ -1365,6 +1365,7 @@ fn compact_external_api_generation_result(result: Value) -> Value {
                     | "spritesheetHeight"
                     | "iconImageSrcs"
                     | "sliceLayout"
    +                | "sliceCount"
                     | "frames"
                     | "frameCount"
                     | "frameWidth"