From e6173b539b214ba80d5ffb46ed78c8eec6fdc9de Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 16:58:48 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20AGC=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20MCP=20=E5=AE=9E=E6=96=BD=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预留独立 worktree 与 PR 边界 后续在本分支落地客户端 MCP 能力 -- 2.52.0 From a66b6c59a29c924c4dc17919c6515b50d90c8c3d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 16:14:41 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20MCP=20=E8=83=BD=E5=8A=9B=E4=B8=8E=20Skill=20?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留客户端对话链并扩展 AGC 工具 MCP 白名单 增加审核 Skill 索引与正文资源读取 补充 Codex 返回旁路记录及幂等安全校验 同步更新 AGC、DirectProject 与审计边界文档 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 778 +++++++++++++++++- .../src-tauri/src/agent/skill_pack.rs | 21 + .../shared-memory/decision-log.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + ...roject客户端Skill与MCP扩展导入方案-2026-08-31.md | 4 + ...术方案】Direct回合行为审计账本-2026-08-31.md | 6 +- 6 files changed, 820 insertions(+), 5 deletions(-) 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 b12fe889a..f277713a8 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,32 @@ 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(); + +pub(crate) struct ExternalMcpServer { + 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 { + 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 +75,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 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", @@ -971,9 +1059,41 @@ async fn call_agc_browser_playtest(arguments: &Value) -> Value { } async fn call_agc_web_search(arguments: &Value) -> Value { - if !controlled_web_search_enabled() { + 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); } + if let Err(error) = validate_tool_object_fields(arguments, &["query", "maxResults"]) { + return mcp_tool_result(error, Vec::new(), true); + } let query = match bounded_tool_string(arguments, "query", DIRECT_TOOLS_MCP_MAX_SEARCH_QUERY_CHARS) { Ok(query) => query, @@ -990,7 +1110,345 @@ async fn call_agc_web_search(arguments: &Value) -> Value { .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 { + 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(content.as_bytes())), + "summary": external_mcp_response_summary(&content), + "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() { @@ -1007,7 +1465,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") @@ -1016,6 +1477,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 @@ -1027,6 +1557,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, @@ -1115,6 +1652,100 @@ 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 = 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 state = ExternalMcpHttpState { + 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 { + 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::*; @@ -1193,7 +1824,7 @@ mod tests { DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, "MCP request envelope must fit the advertised file-write payload" ); - let specs = direct_tools_mcp_specs(); + let specs = direct_tools_mcp_specs_for(false); let names = specs["tools"] .as_array() .expect("tool array") @@ -1203,6 +1834,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", @@ -1491,4 +2127,138 @@ mod tests { assert_eq!(response["result"]["isError"], true); assert!(response.to_string().contains("未知或未审核")); } + + #[tokio::test] + async fn controlled_search_call_is_disabled_without_the_explicit_feature_flag() { + let response = call_agc_web_search_with_enabled( + &json!({ + "query": "tauri" + }), + false, + ) + .await; + assert_eq!(response["isError"], true); + assert!(response.to_string().contains("受控联网搜索未启用")); + } + + #[tokio::test] + async fn mcp_search_forwards_only_reviewed_arguments_to_the_client_bridge() { + use std::sync::Arc; + use tokio::sync::Mutex; + + let observed = Arc::new(Mutex::new(None::)); + let observed_for_handler = Arc::clone(&observed); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind bridge fixture"); + let port = listener + .local_addr() + .expect("bridge fixture address") + .port(); + let app = axum::Router::new().route( + "/tool-fixture", + axum::routing::post(move |axum::Json(payload): axum::Json| { + let observed = Arc::clone(&observed_for_handler); + async move { + *observed.lock().await = Some(payload); + axum::Json(json!({ + "content": [{ "type": "text", "text": "bridge-result" }], + "isError": false + })) + } + }), + ); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let previous_url = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV).ok(); + std::env::set_var( + DIRECT_TOOL_BRIDGE_URL_ENV, + format!("http://127.0.0.1:{port}/tool-fixture"), + ); + let response = call_agc_web_search_with_enabled( + &json!({ + "query": " tauri rust ", + "maxResults": 2 + }), + true, + ) + .await; + match previous_url { + Some(value) => std::env::set_var(DIRECT_TOOL_BRIDGE_URL_ENV, value), + None => std::env::remove_var(DIRECT_TOOL_BRIDGE_URL_ENV), + } + task.abort(); + + assert_eq!(response["isError"], false); + assert_eq!(response["content"][0]["text"], "bridge-result"); + let observed = observed.lock().await.clone().expect("bridge request"); + assert_eq!(observed["tool"], "agc_web_search"); + assert_eq!(observed["arguments"]["query"], "tauri rust"); + assert_eq!(observed["arguments"]["maxResults"], 2); + } + + #[tokio::test] + async fn mcp_search_rejects_unreviewed_arguments_before_bridge_call() { + let response = call_agc_web_search_with_enabled( + &json!({ + "query": "tauri", + "unexpected": "do-not-forward" + }), + true, + ) + .await; + 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/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/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b62a2d88a..e417c24cc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7959,3 +7959,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - UI 编辑器导出的 `ui/generated-*.js` 是派生本地产物。代码生成只写文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不能把生成文件写入冒充项目 mutation。 - 生成文件名保留可读清洗前缀,并追加 asset ID 的 SHA-256 摘要前缀以避免不同 ID 碰撞;不迁移既有旧路径,调用方需在采用新命名后使用新返回路径。 - Radial90 的前端预览与 Rust 导出统一使用角点映射和顺时针起始角规则,顺时针填充从角点前一条边开始,避免两端渲染偏移。 + +## 2026-09-03 AGC 客户端能力以 MCP 暴露 + +- 背景:客户端仍启动并驱动自己的 Codex 对话;同时需要把客户端自身的受控业务能力以 MCP 暴露给 Codex。客户端不应替 Codex 做业务语义门禁、意图判断或完成判定。 +- 决策:MCP 作为客户端能力层外挂,GUI 主进程提供 loopback MCP,stdio broker 只做协议转发;会话绑定 `accountId + projectId + clientInstanceId + sessionId`,项目切换、登出、重启、断开或令牌轮换失效。客户端保留现有对话/UI 和 `codex_app_server` 链路,仅暴露稳定业务工具白名单,不透传全部 Tauri command、任意路径、凭据、内部 URL、shell、数据库或管理能力。 +- 返回记录:客户端对话继续走现有 conversation projection;外部 Host 如需旁路归档,才调用 `conversation.record_codex_response` 写入有界、脱敏 journal。该记录不是客户端对话前置条件,UI 不从文本推断完成、规划、同步或其它副作用。 +- 迁移与兼容:`codex_app_server`、`codex_cli`、`provider`、既有 Agent Runtime 和客户端对话均保留;MCP 仅新增能力暴露,不替换现有 Codex 控制链。公网 `/api/external/v1/mcp` 与桌面 MCP facade 保持独立。 +- 影响范围:AGC 客户端 MCP facade、loopback/stdio 传输、DirectProject 会话绑定、Codex 返回 journal、前端对话数据源、运行模式与恢复/幂等测试;不新增 SpacetimeDB 表,不改变公网 External MCP 合同。 +- 验证方式:覆盖客户端对话驱动的 MCP 初始化/绑定/失效、跨项目与路径安全、工具大小和幂等、异步 operation 恢复、Skill resource 白名单以及旧 Provider/Codex 模式回归;执行对应 Rust/前端定向测试、MCP contract smoke、typecheck、`npm run check:encoding` 与 `git diff --check`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ce63e5020..4db2446dc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -233,6 +233,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 仍可由客户端对话入口驱动,同时其它 Codex Host 也可连接 loopback MCP facade/stdio broker;客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。 +- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。 +- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。 +- loopback MCP 在账号登出、会话代际变化、项目切换、客户端退出或令牌轮换后立即失效;未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。 + ### 2026-08-10 Supervisor 边做边聊与条件中断 - 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。 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 f16a4a5dc..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. 记录合同 @@ -291,7 +295,7 @@ chat_with_game_creator_direct_codex `direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject` 且 `audit` 为 Some 时抽取。 -`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`。 +`run_direct_game_creator_turn_inner` 的 UI observer 把 `AccumulatedText`(仅开启流式时)映射为 `streaming`,`IntermediateText` 与 `Activity` 一律映射为 `running`,只向前端暴露安全活动词与可见正文,不携带原始 item JSON。 回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`。 -- 2.52.0 From d0f9d6a88a53d31d9ef34bc6ca5c23fb98207c10 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 17:27:36 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=87=AA=E7=84=B6=E8=AF=AD=E8=A8=80=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 Codex 通过 MCP 参数选择美术操作 仅保留客户端回合绑定与稳定幂等保护 保留现有客户端对话和 Codex app-server 链路 --- .../src-tauri/src/agent/codex_app_server.rs | 2 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 125 ++---------------- 2 files changed, 15 insertions(+), 112 deletions(-) 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 13c863112..ac4e6adb0 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 @@ -2109,7 +2109,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 { 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 57e511bdb..d28973b5a 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 @@ -61,7 +61,6 @@ struct DirectToolBridgeTurnAuthorization { struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, - allows_regeneration: bool, brief_sha256: Option, completed_result: Option, resource_request_ids: BTreeMap, @@ -179,30 +178,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(), @@ -592,12 +584,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(_) => {} @@ -2600,106 +2589,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, @@ -2728,7 +2631,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()); } -- 2.52.0 From 5cabfa258b1d4be0e7038bd612ec95a3e42fbc2e Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 17:29:08 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E6=A0=A1=E6=AD=A3=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20MCP=20=E8=BF=90=E8=A1=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确保留客户端对话与 Codex app-server 将 MCP 能力限定为绑定项目的 stdio 子进程 同步决策日志与实施计划 --- docs/project-memory/shared-memory/decision-log.md | 2 +- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e417c24cc..aeb93530e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7963,7 +7963,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-03 AGC 客户端能力以 MCP 暴露 - 背景:客户端仍启动并驱动自己的 Codex 对话;同时需要把客户端自身的受控业务能力以 MCP 暴露给 Codex。客户端不应替 Codex 做业务语义门禁、意图判断或完成判定。 -- 决策:MCP 作为客户端能力层外挂,GUI 主进程提供 loopback MCP,stdio broker 只做协议转发;会话绑定 `accountId + projectId + clientInstanceId + sessionId`,项目切换、登出、重启、断开或令牌轮换失效。客户端保留现有对话/UI 和 `codex_app_server` 链路,仅暴露稳定业务工具白名单,不透传全部 Tauri command、任意路径、凭据、内部 URL、shell、数据库或管理能力。 +- 决策:MCP 作为客户端能力层外挂,由客户端启动的 Codex app-server 通过绑定项目工作目录的 stdio MCP 子进程调用;客户端回合结束、项目切换、登出、重启或退出时回收该子进程。账号和项目权限由客户端业务桥接层校验。客户端保留现有对话/UI 和 `codex_app_server` 链路,仅暴露稳定业务工具白名单,不透传全部 Tauri command、任意路径、凭据、内部 URL、shell、数据库或管理能力。 - 返回记录:客户端对话继续走现有 conversation projection;外部 Host 如需旁路归档,才调用 `conversation.record_codex_response` 写入有界、脱敏 journal。该记录不是客户端对话前置条件,UI 不从文本推断完成、规划、同步或其它副作用。 - 迁移与兼容:`codex_app_server`、`codex_cli`、`provider`、既有 Agent Runtime 和客户端对话均保留;MCP 仅新增能力暴露,不替换现有 Codex 控制链。公网 `/api/external/v1/mcp` 与桌面 MCP facade 保持独立。 - 影响范围:AGC 客户端 MCP facade、loopback/stdio 传输、DirectProject 会话绑定、Codex 返回 journal、前端对话数据源、运行模式与恢复/幂等测试;不新增 SpacetimeDB 表,不改变公网 External MCP 合同。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4db2446dc..08ac8f168 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -235,10 +235,10 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 ### 2026-09-03 AGC 客户端能力以 MCP 暴露 -- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。Codex 仍可由客户端对话入口驱动,同时其它 Codex Host 也可连接 loopback MCP facade/stdio broker;客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。 +- 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 只展示记录,不从文本推断业务状态或触发副作用。 -- loopback MCP 在账号登出、会话代际变化、项目切换、客户端退出或令牌轮换后立即失效;未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。 +- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。 ### 2026-08-10 Supervisor 边做边聊与条件中断 -- 2.52.0 From 82f4abc0d2b5512144d73f84eb8857d64b45e104 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 3 Sep 2026 17:40:29 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20MCP=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=9A=84=E4=B8=9A=E5=8A=A1=E9=80=89=E6=8B=A9=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确由 Codex 根据对话选择美术操作模式 移除过时的客户端自然语言授权描述 更新工具目录契约测试 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 f277713a8..5911c461b 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 @@ -155,7 +155,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": { @@ -169,7 +169,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"], @@ -1872,9 +1872,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") -- 2.52.0 From 2179224cfbf796c68aa1e35f781a85035f911267 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 4 Sep 2026 16:21:04 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E7=AB=AF=E5=8F=A3=E5=86=B2=E7=AA=81=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 backend 模式的 BgFilter worker 纳入端口漂移。 启动等待立即传播匹配后端状态中的失败服务。 补充启动器回归测试和本地开发排障说明。 --- .../scripts/start-dev-stack.mjs | 58 ++++++++++++++++++- .../tests/start-dev-stack.test.ts | 44 ++++++++++++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/dev.mjs | 1 + 4 files changed, 101 insertions(+), 4 deletions(-) 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 968c8927e..4a5bc81b6 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) { @@ -684,6 +735,7 @@ export { isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readChildFailure, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, 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/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 944fa7356..a86648451 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -58,7 +58,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/scripts/dev.mjs b/scripts/dev.mjs index 0b28a3b17..9b1e91939 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1433,6 +1433,7 @@ class DevRunner { if ( command === 'all' || + command === 'backend' || command === 'api-server' || command === 'bgfilter-worker' ) { -- 2.52.0 From dee04d883a1e0c6a7ac19999c7989ff85ca2f84f Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 4 Sep 2026 17:50:46 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=90=8E=E7=9A=84=E7=94=9F=E6=88=90=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确失败的旧 operation 仅通过只读状态查询后收口 请求快照变化时允许安全创建新的生成请求 保留排队运行完成及未知状态的幂等保护 --- .../src/agent/generation/canvas_generation.rs | 99 +++++++++++++++++++ ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 1 + 2 files changed, 100 insertions(+) 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..49bc62c8a 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 @@ -681,6 +681,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, @@ -2427,6 +2468,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 +2517,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} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); 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 的失败归档作为任务行扩展保留。 -- 2.52.0 From ea45de9805c21b7043dd6283ca2ae2525ebbeb81 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 5 Sep 2026 19:42:34 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E8=A7=A3=E9=99=A4=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=BA=A7=E7=89=A9=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=9B=BE=E9=9B=86=E5=88=87=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除视觉 Agent 固定图片路径与完成门禁 新增 sliceCount 请求参数并贯通客户端与服务端 允许按项目需求生成单图、多图和自定义图集 更新 OpenAPI、提示词和运行时合同 --- .../src-tauri/src/agent/direct_runtime.rs | 7 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 1 + .../src/agent/generation/canvas_generation.rs | 52 ++++----- .../src-tauri/src/agent/prompt.rs | 10 +- .../runtime_actions/autonomous_policy.rs | 13 +-- .../agent/runtime_actions/project_gates.rs | 101 +++++++++--------- .../src/agent/runtime_driver/task_start.rs | 14 +-- .../src/agent/runtime_tools/delegation.rs | 10 +- .../src/agent/runtime_tools/file_ops.rs | 33 ------ .../src/agent/runtime_tools/media.rs | 84 ++------------- .../src-tauri/src/agent_native_tools.rs | 2 +- .../genarrative-external-v1.openapi.json | 22 +++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- .../api-server/src/editor_agent/tool.rs | 1 + .../crates/api-server/src/editor_project.rs | 7 +- .../api-server/src/editor_project_icon.rs | 31 +++++- .../api-server/src/external_editor_api.rs | 15 +-- .../src/external_generation_worker.rs | 1 + 18 files changed, 154 insertions(+), 252 deletions(-) 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 fc36389d8..c7b698e56 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 @@ -2114,7 +2114,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()); @@ -2680,6 +2680,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)?; @@ -2783,9 +2784,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(), ); } 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 d28973b5a..ef2033a0f 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 @@ -2072,6 +2072,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( 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 49bc62c8a..1fafad453 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, } } } @@ -1755,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 { @@ -2084,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 @@ -2655,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, @@ -6358,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) @@ -6393,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 @@ -6733,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()); 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_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/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/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0e195b066..eb0ac3081 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1070,7 +1070,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 回归及诊断零正文。 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..519ea8a37 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,14 @@ 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 +2939,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" -- 2.52.0 From d7d6ca2ecc3491e0764869cf6954bfc8c4e36712 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 6 Sep 2026 20:07:14 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=88=87=E7=89=87=E5=8F=82=E6=95=B0=E7=9A=84=E6=8C=81?= =?UTF-8?q?=E7=BB=AD=E9=9B=86=E6=88=90=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐客户端与服务端新增字段的测试构造 同步更新图集 OpenAPI schema 与 CI 合同检查 --- .../src-tauri/src/agent/generation/canvas_generation.rs | 5 +++++ apps/ai-game-creator-shell/src-tauri/src/tests/project.rs | 2 ++ 2 files changed, 7 insertions(+) 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 1fafad453..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 @@ -7679,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) @@ -9487,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); @@ -10324,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); @@ -10775,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); @@ -11349,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/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 54f8eae14..ca5ce863e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1165,6 +1165,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }, ) .await; @@ -4856,6 +4857,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", -- 2.52.0 From d322d1111b362677975314cb5d0a9e795914e0fa Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 8 Sep 2026 12:39:20 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D274=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AFMCP=E5=AE=A1=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注册外部MCP启动与停止命令 绑定客户端工具桥并校验对话写权限 使用脱敏内容生成返回摘要与哈希 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 713 +++++++++++++++++- .../src-tauri/src/main.rs | 2 + 2 files changed, 707 insertions(+), 8 deletions(-) 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/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 90ffd9333..a110e50d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2486,6 +2486,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, -- 2.52.0 From 788089b33ae85cabe689d5bb3b599f924f8f4aff Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 8 Sep 2026 15:16:45 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D274=E5=89=A9=E4=BD=99CI?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐新增MCP命令的native-only配置白名单 格式化自定义图集切片数量校验代码 --- apps/ai-game-creator-shell/scripts/check-config.mjs | 2 ++ server-rs/crates/api-server/src/editor_project_icon.rs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c5e202e03..8ef63ae8d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -113,6 +113,8 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'stop_local_game_preview_if_matches', + 'start_game_creator_external_mcp', + 'stop_game_creator_external_mcp', ]; const sourceExtensions = new Set([ '.json', 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 519ea8a37..f7f318b86 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -2522,7 +2522,8 @@ 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 { + 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()), -- 2.52.0