From 1f904d28e9b011b8ff884daa966fed4874ea3242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Tue, 8 Sep 2026 22:01:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?AGC=20=E5=AE=A2=E6=88=B7=E7=AB=AF=20MCP=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=9A=B4=E9=9C=B2=20(#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 目标 保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。 ## 范围 - 客户端会话、项目文件、资源、画布、生成、预览等稳定能力 - 审核 Skill 的索引与按需指导资源 - 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界 ## 明确不做 - 不替换客户端对话入口或 Codex app-server - 不让客户端替 Codex 判断高层意图、完成状态或规划 - 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力 当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/274 --- .../scripts/check-config.mjs | 2 + .../scripts/start-dev-stack.mjs | 58 +- .../src-tauri/src/agent/codex_app_server.rs | 2 +- .../src-tauri/src/agent/direct_runtime.rs | 7 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 126 +--- .../src-tauri/src/agent/direct_tools_mcp.rs | 713 +++++++++++++++++- .../src/agent/generation/canvas_generation.rs | 156 +++- .../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/skill_pack.rs | 21 + .../src-tauri/src/agent_native_tools.rs | 2 +- .../src-tauri/src/main.rs | 2 + .../src-tauri/src/tests/project.rs | 2 + .../tests/start-dev-stack.test.ts | 44 ++ .../genarrative-external-v1.openapi.json | 22 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 9 +- ...roject客户端Skill与MCP扩展导入方案-2026-08-31.md | 4 + ...术方案】Direct回合行为审计账本-2026-08-31.md | 4 + ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.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 | 32 +- .../api-server/src/external_editor_api.rs | 15 +- .../src/external_generation_worker.rs | 1 + 30 files changed, 1122 insertions(+), 376 deletions(-) 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/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 397d88dcf..64577d35f 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) { }); } +function readBackendServiceFailure( + state, + { + expectedDatabase = backendDatabase, + expectedSpacetimeDataDir = backendSpacetimeDataDir, + } = {}, +) { + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir, + }); + if (!targets.hasMatchingBackend) { + return null; + } + + for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) { + const service = state?.services?.[serviceName]; + if (service?.status !== 'failed') { + continue; + } + + return { + serviceName, + failure: service.signal + ? `signal=${service.signal}` + : `code=${service.exitCode ?? 1}`, + }; + } + + return null; +} + async function isBackendReady({ state = readJson(devStackStatePath), isReady = isHttpReady, @@ -505,11 +538,29 @@ async function terminateChildTree( return { stopped, forced: true }; } -async function waitForBackendReady(backendChild, timeoutMs = 600_000) { +async function waitForBackendReady( + backendChild, + timeoutMs = 600_000, + { + checkBackendReady = isBackendReady, + readState = () => readJson(devStackStatePath), + resolveTargets = readBackendTargets, + } = {}, +) { + const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - if (await isBackendReady()) { - return readBackendTargets(); + if (await checkBackendReady()) { + return resolveTargets(); + } + const state = readState(); + if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) { + const serviceFailure = readBackendServiceFailure(state); + if (serviceFailure) { + throw new Error( + `配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`, + ); + } } const failure = readChildFailure(backendChild); if (failure) { @@ -686,6 +737,7 @@ export { isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readChildFailure, readExistingViteServer, readLinuxProcessGroupAlive, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 848a8882e..2410c4adf 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 @@ -2598,7 +2598,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_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index d5370f671..5498335b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2147,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Ok(validated_slices) = validated_art_slices(root) else { return Vec::new(); }; - if validated_slices.len() != 4 { + if validated_slices.is_empty() { return Vec::new(); } let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len()); @@ -2718,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at( asset_kind: asset_kind.to_string(), asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), + slice_count: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; @@ -2821,9 +2822,9 @@ fn direct_taonier_art_package_result( } else { Vec::new() }; - if includes_spritesheet && slice_paths.len() != 4 { + if includes_spritesheet && slice_paths.is_empty() { slice_warnings.push( - "当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" + "当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" .to_string(), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 5b9f4cfdd..bdbeb222c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization { struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, - allows_regeneration: bool, brief_sha256: Option, completed_result: Option, resource_request_ids: BTreeMap, @@ -181,30 +180,23 @@ impl DirectToolBridge { &self.url } - /// Arm exactly one client-owned Direct turn. The raw user message is used - /// only for this synchronous decision and is never retained by the bridge. - pub(crate) fn begin_user_turn( - &self, - user_prompt: &str, - ) -> Result { - self.state.begin_user_turn(user_prompt) + /// Arm exactly one client-owned Direct turn. Codex chooses the business + /// operation through the reviewed MCP tool and arguments; the bridge only + /// binds that call to the active client turn. + pub(crate) fn begin_user_turn(&self) -> Result { + self.state.begin_user_turn() } } impl DirectToolBridgeState { - fn begin_user_turn( - self: &Arc, - user_prompt: &str, - ) -> Result { + fn begin_user_turn(self: &Arc) -> Result { let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; - let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt); let mut authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; authorization.active = Some(DirectToolBridgeActiveTurnAuthorization { turn_id: turn_id.clone(), - allows_regeneration, brief_sha256: None, completed_result: None, resource_request_ids: BTreeMap::new(), @@ -594,12 +586,9 @@ impl DirectToolBridgeState { .active .as_mut() .ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?; - if !active.allows_regeneration { - return Err("当前用户消息未显式授权重新生成或替换美术".to_string()); - } match active.brief_sha256.as_deref() { Some(expected) if expected != brief_sha256 => { - return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string()) + return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string()) } None => active.brief_sha256 = Some(brief_sha256.clone()), Some(_) => {} @@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_kind: kind.clone(), asset_label: asset_name.clone(), replace_existing: false, + slice_count: None, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( @@ -2736,106 +2726,20 @@ mod tests { } #[test] - fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() { - for prompt in [ - "继续修复布局", - "解释一下重新生成美术是什么意思", - "不要重新生成美术,只调整代码", - "别换一套美术,继续用现在这套", - "解释一下换一套美术按钮", - "是否要改变视觉风格?", - "Do not regenerate the art; keep the current package.", - "I don't want to change the visual style.", - "What does use a new art set mean?", - "文案写着“换一套美术”", - "Yesterday I said regenerate art, but today keep it.", - "Please explain how to regenerate art.", - "重新生成美术以后再说,现在只修代码", - "重做美术先不做,先改玩法", - "Regenerate the art maybe later; for now just fix the code.", - "把按钮文案改成“请重新生成美术”,不要执行生成工具", - "把按钮文案改成‘请重新生成美术’,不要执行生成工具", - "Change the button label to 'please regenerate the art'; do not execute it.", - "用户之前说请重新生成美术,我只是在复述", - "Yesterday the user said please regenerate the art; I am just quoting it.", - "以后请重新生成美术,现在先改代码", - "你能不能帮我重新生成美术,顺便解释一下价格", - "请重新生成美术吗", - "请重新生成美术吗,还是只改代码", - "请重新生成美术或者只改代码", - "请重新生成美术以外的内容", - "请重新生成美术,但不要执行生成工具", - "不需要重新生成美术", - "界面上显示:请重新生成美术", - "界面标题是请重新生成美术", - "产品经理让我写请重新生成美术", - "下周请重新生成美术", - "他说«请重新生成美术»", - "Could you please regenerate the art", - "Please regenerate the art? Or only fix code.", - "Please regenerate the art except for the paid generation.", - "Please regenerate the art, but do not execute the tool.", - "Please regenerate the art, but don’t execute the tool.", - "Please regenerate the art, but I don't authorize this paid generation.", - "Please regenerate the art, but I don‘t authorize this paid generation.", - "Please regenerate the art, but do not execute the paid tool.", - "Please regenerate the art, but never execute the paid tool.", - "Please regenerate the art, but avoid executing the paid tool.", - "Please regenerate the art, but 'do not execute the tool", - "Please regenerate the art only if it is free.", - "Please regenerate the art only after I confirm the charge.", - "Please regenerate the art, but do “not” execute the paid tool.", - "请重新生成美术,三天后再执行。", - "请重新生成美术,得到我的许可再做。", - "请重新生成美术,地面需要无缝循环。", - "Please regenerate the art, but skip the paid generation.", - "请重新生成美术【生成操作跳过】", - "请重新生成美术【仅在零元时执行】", - "Please regenerate the art “but skip the paid generation”", - "Please regenerate the art; alternatively, just fix the code.", - "Please regenerate the art, but do n\u{200B}ot execute the paid tool.", - "Please regenerate the art with a clay style.", - "I don't need you to regenerate the art", - "The UI shows: please regenerate the art", - "Please regenerate the art next week", - "He said «please regenerate the art»", - ] { - assert!( - !direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must fail closed: {prompt}" - ); - } - for prompt in [ - "请重新生成美术。", - "那就请重新生成美术!", - "换一套美术", - "Please regenerate the art!", - ] { - assert!( - direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must explicitly authorize: {prompt}" - ); - } - + fn regenerate_uses_current_client_turn_and_one_stable_brief() { let root = tempfile::tempdir().expect("stable client turn root"); let state = direct_tool_bridge_state(root.path().to_path_buf()); - assert!(state.begin_user_turn("请重新生成美术").is_err()); + assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) .expect("client-owned stable invocation"); - let ordinary_turn = state - .begin_user_turn("继续优化交互") - .expect("ordinary turn authorization state"); - assert!(state.authorize_regeneration_call("陶泥风格").is_err()); - drop(ordinary_turn); - - let authorized_turn = state - .begin_user_turn("请重新生成美术") - .expect("authorized regeneration turn"); + let active_turn = state + .begin_user_turn() + .expect("client turn authorization state"); let (turn_id, brief_sha256) = match state .authorize_regeneration_call("陶泥风格") - .expect("first stable regeneration call") + .expect("MCP mode selects regeneration explicitly") { DirectToolBridgeRegenerationCall::Execute { turn_id, @@ -2864,7 +2768,7 @@ mod tests { panic!("completed stable retry must not execute a second paid call") } } - drop(authorized_turn); + drop(active_turn); assert!(state.authorize_regeneration_call("陶泥风格").is_err()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 30e3b0bea..3dde7a64a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1,7 +1,13 @@ use super::*; +use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str = @@ -14,6 +20,37 @@ const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const EXTERNAL_MCP_RESPONSE_MAX_CHARS: usize = 256 * 1024; +const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; +const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; +const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; +static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +tokio::task_local! { + static EXTERNAL_MCP_BRIDGE_URL: String; +} + +pub(crate) struct ExternalMcpServer { + _bridge: super::direct_tool_bridge::DirectToolBridge, + pub(crate) url: String, + pub(crate) token: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for ExternalMcpServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +struct ExternalMcpHttpState { + bridge_url: String, + root: PathBuf, + token: String, + session_user_id: String, + session_generation: u64, +} pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] @@ -43,6 +80,62 @@ fn direct_tools_mcp_specs() -> Value { fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { let tools = vec![ + json!({ + "name": "client.session.info", + "description": "返回当前已绑定的 AGC 客户端会话和项目安全摘要;不返回宿主路径、凭据或内部地址。", + "inputSchema": { "type": "object", "additionalProperties": false } + }), + json!({ + "name": "conversation.record_codex_response", + "description": "显式记录外部 Codex 的一条最终返回。客户端只保存有界、脱敏后的正文和安全摘要,不根据正文触发业务动作。", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sequence": { "type": "integer", "minimum": 0, "maximum": 1000000 }, + "content": { "type": "string", "minLength": 1, "maxLength": EXTERNAL_MCP_RESPONSE_MAX_CHARS } + }, + "required": ["requestId", "sequence", "content"], + "additionalProperties": false + } + }), + json!({ + "name": "conversation.list", + "description": "按序读取当前项目已记录的 Codex 返回摘要。", + "inputSchema": { + "type": "object", + "properties": { + "offset": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "additionalProperties": false + } + }), + json!({ + "name": "conversation.read", + "description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", + "inputSchema": { + "type": "object", + "properties": { + "recordId": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "required": ["recordId"], + "additionalProperties": false + } + }), + json!({ + "name": "agc_read_skill_resource", + "description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "required": ["skillName", "relativePath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": "把文本写入当前 AGC 项目的相对路径。Codex 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", @@ -67,7 +160,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "taonier_prepare_game_art", - "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。regenerate 还必须通过客户端对当前用户消息签发的单回合稳定调用授权;模型参数和 MCP 自动批准本身不构成替换授权。仅在用户意图确实需要新美术时调用。", + "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。Codex 根据当前对话决定是否调用 regenerate;客户端不解析用户文本,也不替 Codex 判断意图。", "inputSchema": { "type": "object", "properties": { @@ -81,7 +174,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "type": "string", "enum": ["reuse-or-create", "regenerate"], "default": "reuse-or-create", - "description": "缺省安全复用有效美术包;只有用户明确要求换一套或重新生成时使用 regenerate" + "description": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate" } }, "required": ["brief"], @@ -763,7 +856,9 @@ fn tool_search_max_results(arguments: &Value) -> Result { } fn direct_tool_bridge_url() -> Result { - let value = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV) + let value = EXTERNAL_MCP_BRIDGE_URL + .try_with(Clone::clone) + .or_else(|_| std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV)) .map_err(|_| "客户端受控工具桥未配置".to_string())?; let parsed = url::Url::parse(&value).map_err(|_| "客户端受控工具桥地址无效".to_string())?; let host = parsed @@ -974,6 +1069,31 @@ async fn call_agc_web_search(arguments: &Value) -> Value { call_agc_web_search_with_enabled(arguments, controlled_web_search_enabled()).await } +fn call_agc_read_skill_resource(arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["skillName", "relativePath"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let skill = match bounded_tool_string(arguments, "skillName", 120) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let relative = match bounded_tool_string(arguments, "relativePath", 240) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if Path::new(&relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') + { + return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); + } + match read_agc_skill_resource(&format!("{skill}/{relative}")) { + Ok(content) => mcp_tool_result(content, Vec::new(), false), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -997,7 +1117,348 @@ async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> V .await } -async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { +fn external_mcp_journal_path(root: &Path) -> PathBuf { + root.join(EXTERNAL_MCP_JOURNAL_RELATIVE_PATH) +} + +fn redact_external_mcp_response(content: &str) -> String { + content + .lines() + .map(|line| { + let lower = line.to_ascii_lowercase(); + let sensitive = [ + "authorization:", + "cookie:", + "set-cookie:", + "api_key", + "apikey", + "access_token", + "refresh_token", + "client_secret", + "password:", + "bearer ", + ] + .iter() + .any(|marker| lower.contains(marker)); + if sensitive { + "[redacted sensitive response line]".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +fn external_mcp_response_summary(content: &str) -> String { + let normalized = content.split_whitespace().collect::>().join(" "); + normalized + .chars() + .take(EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS) + .collect() +} + +fn external_mcp_session_id(root: &Path) -> String { + let mut material = root.to_string_lossy().into_owned(); + if let Some(session) = current_platform_session() { + material.push('\0'); + material.push_str(&session.user_id); + material.push('\0'); + material.push_str(&session.generation.to_string()); + } + format!("mcp-{:x}", Sha256::digest(material.as_bytes())) +} + +fn external_mcp_project_id(root: &Path) -> String { + std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .get("projectId") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| { + format!( + "project-{:x}", + Sha256::digest(root.to_string_lossy().as_bytes()) + ) + }) +} + +fn external_mcp_account_id() -> String { + current_platform_session() + .map(|session| format!("account-{:x}", Sha256::digest(session.user_id.as_bytes()))) + .unwrap_or_else(|| "account-unknown".to_string()) +} + +fn validate_external_mcp_record_arguments( + arguments: &Value, +) -> Result<(String, u64, String), String> { + validate_tool_object_fields(arguments, &["requestId", "sequence", "content"])?; + let request_id = bounded_tool_string(arguments, "requestId", 160)?; + let sequence = arguments + .get("sequence") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 sequence 必须是非负整数".to_string())?; + if sequence > 1_000_000 { + return Err("工具参数 sequence 超出安全边界".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.is_empty() || content.chars().count() > EXTERNAL_MCP_RESPONSE_MAX_CHARS { + return Err("工具参数 content 不能为空或超过大小上限".to_string()); + } + if content + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err("工具参数 content 不能包含控制字符".to_string()); + } + Ok((request_id, sequence, content.to_string())) +} + +fn read_external_mcp_journal(root: &Path) -> Result, String> { + let path = external_mcp_journal_path(root); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(Vec::new()); + }; + if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return Err("Codex 返回记录超过客户端保留上限".to_string()); + } + bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + }) + .collect() +} + +fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { + if let Err(error) = enforce_project_permission_policy(root, "conversation.write") { + return mcp_tool_result(error, Vec::new(), true); + } + let (request_id, sequence, content) = match validate_external_mcp_record_arguments(arguments) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let redacted = redact_external_mcp_response(&content); + let key = format!("{request_id}\u{0}{sequence}"); + let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes())); + let guard = EXTERNAL_MCP_JOURNAL_LOCK + .get_or_init(|| Mutex::new(())) + .lock(); + if guard.is_err() { + return mcp_tool_result("Codex 返回记录锁不可用".to_string(), Vec::new(), true); + } + let mut records = match read_external_mcp_journal(root) { + Ok(records) => records, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if let Some(existing) = records.iter().find(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + && record.get("sequence").and_then(Value::as_u64) == Some(sequence) + }) { + return mcp_tool_result(existing.to_string(), Vec::new(), false); + } + if let Some(max_sequence) = records + .iter() + .filter(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + }) + .filter_map(|record| record.get("sequence").and_then(Value::as_u64)) + .max() + { + if sequence != max_sequence.saturating_add(1) { + return mcp_tool_result( + "工具参数 sequence 必须按 requestId 连续递增".to_string(), + Vec::new(), + true, + ); + } + } else if sequence != 0 { + return mcp_tool_result( + "同一 requestId 的首条记录 sequence 必须为 0".to_string(), + Vec::new(), + true, + ); + } + let path = external_mcp_journal_path(root); + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + return mcp_tool_result( + format!("创建 Codex 返回记录目录失败:{error}"), + Vec::new(), + true, + ); + } + } + let record = json!({ + "recordId": uuid::Uuid::new_v4().to_string(), + "recordType": "codex.response", + "accountId": external_mcp_account_id(), + "projectId": external_mcp_project_id(root), + "sessionId": external_mcp_session_id(root), + "requestId": request_id, + "sequence": sequence, + "receivedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + "content": redacted, + "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), + "summary": external_mcp_response_summary(&redacted), + "truncated": false, + "status": "completed" + }); + let line = match serde_json::to_string(&record) { + Ok(line) => line, + Err(error) => { + return mcp_tool_result( + format!("序列化 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ) + } + }; + let current_size = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return mcp_tool_result( + "Codex 返回记录达到客户端保留上限".to_string(), + Vec::new(), + true, + ); + } + let _project_lock = match acquire_project_write_lock(root, "conversation.write") { + Ok(lock) => lock, + Err(error) => { + return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) + } + }; + let append_result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| { + use std::io::Write as _; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.sync_data() + }); + if let Err(error) = append_result { + return mcp_tool_result( + format!("写入 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ); + } + // Reuse the existing conversation projection so the current UI can read + // the explicit external response without treating it as business truth. + if let Err(error) = append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: record + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + agent_id: None, + }, + &message_id, + ) { + return mcp_tool_result( + format!("Codex 返回已写入但对话投影失败:{error}"), + Vec::new(), + true, + ); + } + records.push(record.clone()); + mcp_tool_result(record.to_string(), Vec::new(), false) +} + +fn external_mcp_session_info(root: &Path) -> Value { + let manifest = std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let project_id = manifest + .as_ref() + .and_then(|value| value.get("projectId")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + mcp_tool_result( + json!({ + "status": "bound", + "projectId": project_id, + "sessionId": external_mcp_session_id(root), + "transport": "loopback-or-stdio" + }) + .to_string(), + Vec::new(), + false, + ) +} + +fn external_mcp_conversation_list(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["offset", "limit"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let offset = arguments.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; + let limit = arguments.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize; + if offset > 10_000 || !(1..=100).contains(&limit) { + return mcp_tool_result( + "conversation.list 分页参数超出安全边界".to_string(), + Vec::new(), + true, + ); + } + match read_external_mcp_journal(root) { + Ok(records) => mcp_tool_result( + json!({ "entries": records.into_iter().skip(offset).take(limit).map(|record| json!({ + "recordId": record.get("recordId"), "requestId": record.get("requestId"), + "sequence": record.get("sequence"), "receivedAt": record.get("receivedAt"), + "summary": record.get("summary"), "status": record.get("status") + })).collect::>() }) + .to_string(), + Vec::new(), + false, + ), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +fn external_mcp_conversation_read(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["recordId"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let record_id = match bounded_tool_string(arguments, "recordId", 80) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + match read_external_mcp_journal(root) { + Ok(records) => records + .into_iter() + .find(|record| { + record.get("recordId").and_then(Value::as_str) == Some(record_id.as_str()) + }) + .map(|record| mcp_tool_result(record.to_string(), Vec::new(), false)) + .unwrap_or_else(|| { + mcp_tool_result("未找到 Codex 返回记录".to_string(), Vec::new(), true) + }), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -1014,7 +1475,10 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option id, json!({ "protocolVersion": requested_protocol, - "capabilities": { "tools": { "listChanged": false } }, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, "serverInfo": { "name": "genarrative-agc-tools", "version": env!("CARGO_PKG_VERSION") @@ -1023,6 +1487,75 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option )) } "ping" => Some(mcp_success(id, json!({}))), + "resources/list" => Some(mcp_success( + id, + json!({ + "resources": [{ + "uri": "agc://skills/index", + "name": "AGC Skill 索引", + "description": "审核通过的客户端 Skill 与工具使用指导", + "mimeType": "text/plain" + }, { + "uri": "agc://conversation/codex-responses", + "name": "Codex 返回记录", + "description": "当前项目中由 conversation.record_codex_response 写入的只读 journal", + "mimeType": "application/x-ndjson" + }] + }), + )), + "resources/read" => { + let uri = request + .pointer("/params/uri") + .and_then(Value::as_str) + .unwrap_or_default(); + if uri == "agc://skills/index" { + let text = render_agc_skill_pack_index() + .map_err(|_| ()) + .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); + return Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )); + } + if let Some(resource) = uri.strip_prefix("agc://skills/") { + return match read_agc_skill_resource(resource) { + Ok(text) if text.len() <= DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES => { + Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )) + } + Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), + Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + }; + } + if uri != "agc://conversation/codex-responses" { + Some(mcp_error(id, -32602, "未知资源")) + } else { + let text = read_external_mcp_journal(root) + .map(|records| { + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { + return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); + } + Some(mcp_success( + id, + json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/x-ndjson", + "text": text + }] + }), + )) + } + } "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), "tools/call" => { let tool = request @@ -1034,6 +1567,13 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option .cloned() .unwrap_or_else(|| json!({})); let result = match tool { + "client.session.info" => external_mcp_session_info(root), + "conversation.record_codex_response" => { + external_mcp_record_response(root, &arguments) + } + "conversation.list" => external_mcp_conversation_list(root, &arguments), + "conversation.read" => external_mcp_conversation_read(root, &arguments), + "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_write_file" => call_agc_write_file(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, @@ -1122,6 +1662,108 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { Ok(()) } +fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| value == token) +} + +async fn handle_external_mcp_http_request( + AxumState(state): AxumState, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + if !external_mcp_authorized(&headers, &state.token) { + return Err(StatusCode::UNAUTHORIZED); + } + let Some(session) = current_platform_session() else { + return Err(StatusCode::UNAUTHORIZED); + }; + if session.user_id != state.session_user_id || session.generation != state.session_generation { + return Err(StatusCode::UNAUTHORIZED); + } + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + state.bridge_url.clone(), + handle_direct_tools_mcp_request(&state.root, request), + ) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + Ok(Json(response)) +} + +pub(crate) async fn start_external_mcp_loopback( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + let root = validate_direct_tools_project_root(root)?; + let session = current_platform_session() + .ok_or_else(|| "启动客户端 MCP 前必须先完成账号会话绑定".to_string())?; + let token = uuid::Uuid::new_v4().to_string(); + let route = format!("/mcp-{}", uuid::Uuid::new_v4().simple()); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动客户端 MCP loopback 失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; + let bridge = + super::direct_tool_bridge::start_direct_tool_bridge(&root, controlled_web_search).await?; + let state = ExternalMcpHttpState { + bridge_url: bridge.url().to_string(), + root, + token: token.clone(), + session_user_id: session.user_id, + session_generation: session.generation, + }; + let app = Router::new() + .route(&route, post(handle_external_mcp_http_request)) + .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!("http://127.0.0.1:{}{route}", address.port()); + let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let mut guard = registry + .lock() + .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; + if let Some(previous) = guard.take() { + drop(previous); + } + *guard = Some(ExternalMcpServer { + _bridge: bridge, + url: url.clone(), + token: token.clone(), + task, + }); + Ok((url, token)) +} + +pub(crate) fn stop_external_mcp_loopback() { + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + guard.take(); + } + } +} + +#[tauri::command] +pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let (url, token) = start_external_mcp_loopback(root, false).await?; + Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) +} + +#[tauri::command] +pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { + stop_external_mcp_loopback(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1210,6 +1852,11 @@ mod tests { assert_eq!( names, vec![ + "client.session.info", + "conversation.record_codex_response", + "conversation.list", + "conversation.read", + "agc_read_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -1243,9 +1890,10 @@ mod tests { "reuse-or-create" ); assert_eq!(art_tool["inputSchema"]["required"], json!(["brief"])); - assert!(art_tool["description"].as_str().is_some_and( - |description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权") - )); + assert!(art_tool["description"].as_str().is_some_and(|description| { + description.contains("Codex 根据当前对话决定是否调用 regenerate") + && description.contains("客户端不解析用户文本") + })); assert!(art_tool["description"].as_str().is_some_and(|description| { description.contains("用户不需要提供、配置、粘贴或创建 API Key") && description.contains("不得向用户索要凭据或暴露内部 URL") @@ -1583,4 +2231,53 @@ mod tests { assert_eq!(response["isError"], true); assert!(response.to_string().contains("未审核字段")); } + + #[test] + fn skill_resource_tool_rejects_unreviewed_paths() { + let accepted = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md" + })); + assert_eq!(accepted["isError"], false); + assert!(accepted.to_string().contains("drive prefix")); + + let denied = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json" + })); + assert_eq!(denied["isError"], true); + + let denied_windows_absolute = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md" + })); + assert_eq!(denied_windows_absolute["isError"], true); + } + + #[test] + fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() { + let response = redact_external_mcp_response( + "完成了页面布局\nAuthorization: Bearer secret-value\n下一步请运行试玩", + ); + assert!(response.contains("完成了页面布局")); + assert!(response.contains("下一步请运行试玩")); + assert!(!response.contains("secret-value")); + } + + #[test] + fn external_codex_response_arguments_reject_unknown_fields_and_control_bytes() { + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "ok", + "unexpected": true + })) + .is_err()); + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "bad\u{0001}" + })) + .is_err()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index e3cee4f47..0e7a97084 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_kind: String, pub(crate) asset_label: String, pub(crate) replace_existing: bool, + pub(crate) slice_count: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions { asset_kind: "game-art".to_string(), asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, + slice_count: None, } } } @@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) } +/// Observe an accepted operation once without waiting. A single GET that +/// reports `failed` is authoritative and allows a changed retry to release +/// the old local slot; queued/running/unknown outcomes remain protected. +async fn accepted_generation_is_authoritatively_failed_once( + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + submission_payload: &serde_json::Value, +) -> Result { + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?; + access.validate_frozen_session()?; + let payload = tokio::time::timeout( + Duration::from_secs(3), + external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) + )) + .bearer_auth(access.bearer_token()), + "查询平台图片生成任务", + ), + ) + .await + .map_err(|_| "查询平台图片生成任务超时".to_string())??; + access.validate_frozen_session()?; + let generation = platform_generation_status_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("failed") => Ok(true), + Some("queued" | "running" | "completed") => Ok(false), + Some(status) => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )), + None => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" + )), + } +} + pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -1714,24 +1757,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // long creation request cannot reject the atlas before it is queued. const MAX_DESCRIPTION_CHARS: usize = 200; const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - [ - "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", - "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", - "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材", - "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", - ] - .into_iter() - .map(|category| { - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - format!("{category}{CONTEXT_PREFIX}{project_context}") - }) - .collect() + let category = + "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] } fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { @@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = - standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?; + standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( root, prompt, briefs, options, - true, + false, &runtime_context, ) .await @@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.generation_prompt != generation_prompt { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.reference_resource_ids != [current_reference] { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = + platform_art_generation_runtime_submission_payload(&state) + { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "sliceLayout": "grid-2x2", + "sliceCount": options.slice_count, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract( has_transparent_pixels: bool, has_visible_pixels: bool, ) -> Result<(), String> { - if slices.len() != 4 { - return Err(format!( - "strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个", - slices.len() - )); + if slices.is_empty() { + return Err("spritesheet 图集至少需要一个独立切片".to_string()); } let resource_id = resource_id .map(str::trim) @@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { - return Err( - "strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成" - .to_string(), - ); - } + let _requested_slice_layout = spritesheet_slice_layout; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete( manifest: &GameCreationAppManifest, registrations: &[PlatformArtSliceManifestRegistration], ) -> Result { - if registrations.len() != 4 { + if registrations.is_empty() { return Ok(false); } let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len()); @@ -7594,6 +7679,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "手工背景".to_string(), replace_existing: true, + slice_count: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -9402,6 +9488,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "整包规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10239,6 +10326,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "整包背景图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10690,6 +10778,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11264,6 +11353,7 @@ mod canvas_generation_tests { asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, + slice_count: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 7648408da..460d8dfad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -488,15 +488,13 @@ fn game_creator_design_foundation_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, ) -> String { - let role_boundary = "角色边界:项目文件写入只允许 memory/project.md 与 game/game_design.md;配置 External Editor API Key 且任务要求界面原型时,可额外产出 assets/ui-prototype.png 与 Runtime 发现清单要求的 assets/ui-pages/*.png;UI 设计图生成后只能通过受控 ui.workflow.run 写入或关联 UI JSON、保存工作流阶段并应用页面,不得绕过该工具直接写入 UI State。不得创建、修改、删除或补丁 game/index.html,也不得改动任何其他程序实现、发布、音频或美术素材文件。完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人 owner 产物;不得调用 project.verify、command.run_limited、game.static_smoke、preview.start 或 preview.validate,也不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright,或执行任何桌面端、移动端试玩验证。完整 DAG 的最终静态验收仍属于 preview-readiness,浏览器验收仍属于 preview-playtest。"; + let role_boundary = "角色边界:只负责玩法规格、界面建议和视觉工具使用指导。项目文件与图片输出必须服从当前任务明确要求;不创建固定图片槽位,不规定固定数量或布局,不修改 game/index.html,不启动预览或试玩。"; if !editor_api_key_is_configured { return format!( "{prompt}\n\n你负责玩法规格与界面原型基础交付。当前未配置 External Editor API Key,因此本轮必须完成 memory/project.md 与 game/game_design.md,不调用 canvas.asset_generate,也不伪造 assets/ui-prototype.png。把界面结构、控件、状态和双视口要求写进玩法规格;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},供 Runtime 自动发现和后续程序组实现;完成写入后直接交付,不要自行运行任何验证命令。{role_boundary}" ); } - format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},作为 Runtime 自动发现的权威设计声明。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。八项视觉检查通过后,先调用 ui.workflow.run 的 discover 自动读取受控页面声明,不得凭空猜页面;再按返回的每个 pageId 逐页调用 canvas.asset_generate,以固定 16:9、2K、assetKind=ui-prototype、replaceExisting=false 生成并登记对应 assets/ui-pages/{{pageId}}.png 设计图,assetLabel 使用该页标题,使用发现的真实 applicationPath 依次执行 prepare、recognize、status,确认所有页面均无 blockers 后再执行 finalize。该工具会创建并关联 kind=UI 的 JSON 编辑资源、持久化每一阶段 State、同步 manifest/客户端,并在完成后返回 visual-binding 最终编辑器路由;只登记 ui-prototype 图片或只写计划不算完成。由 Runtime 在收束门内同时核对固定 owner 文档、当前 revision 与视觉证据。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" - ) + format!("{prompt}\n\n根据当前玩法需求编写规格和界面建议;如需图片,明确说明用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet,再调用 canvas.asset_generate。不要使用固定图片合同。{role_boundary}") } fn game_creator_art_director_tool_plan_prompt( @@ -506,7 +504,7 @@ fn game_creator_art_director_tool_plan_prompt( if !editor_api_key_is_configured { return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,这是只读协调任务:只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。seed task 中生成规范图的图片产物与验收条款在本轮不适用。"); } - format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。canvas.asset_generate 成功只表示固定候选已生成并登记,不等于视觉门已经通过;生成成功后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") + format!("{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词;可以生成一张或多张图片,也可以不生成图片。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态;不要假设固定图片名称、数量、素材类别或布局。") } fn game_creator_art_asset_plan_tool_plan_prompt( @@ -519,7 +517,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。透明证据核对完成后直接交付,由 Runtime 在收束门内验证本人固定 manifest 产物并复核 Canvas 证据。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定,不得套用固定图片包或固定 2x2。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。" ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 3fa69f520..a617d7f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 art-director 必须是非只读规范图生成任务", )); } - let art_artifacts = + // 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。 + let _art_artifacts = autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } let code_director = code_director.ok_or_else(|| { autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") @@ -1976,7 +1969,7 @@ mod tests { plan: Vec::new(), actions: vec![ autonomous_initial_delegate("design-director", &[]), - autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("art-director", &[]), autonomous_initial_delegate("code-director", &[]), ], response: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index a3eac4093..d9d062c0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1199,32 +1199,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( agent_id: &str, required_run_id: Option<&str>, ) -> Option { - if !editor_api_key_is_configured() { - return None; - } - let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), - "art-asset-plan" => ( - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图", - ), - _ => return None, - }; - let manifest = match read_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: format!("无法核对{label},不能完成任务"), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }); + // 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。 + return None; + #[allow(unreachable_code)] + { + if !editor_api_key_is_configured() { + return None; } - }; - if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { - return Some(AgentRuntimeToolObservation { + let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), + "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "art-spritesheet", + "首版美术素材图", + ), + _ => return None, + }; + let manifest = match read_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: format!("无法核对{label},不能完成任务"), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { + return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), @@ -1234,29 +1238,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( redact_agent_runtime_project_paths(root, &error, 300), )), }); - } - if agent_id != "design-foundation" { - return None; - } - match ui_prototype_visual_inspection_blocker_detail_at_locked( - root, - agent_id, - required_run_id, - expected_path, - ) { - Ok(None) => None, - Ok(Some(detail)) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), - detail: Some(detail), - }), - Err(error) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }), + } + if agent_id != "design-foundation" { + return None; + } + match ui_prototype_visual_inspection_blocker_detail_at_locked( + root, + agent_id, + required_run_id, + expected_path, + ) { + Ok(None) => None, + Ok(Some(detail)) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), + detail: Some(detail), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 45571d8c9..326eab51a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke } pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { - editor_api_key_is_configured() - && matches!( - task_id, - "art-director" | "design-foundation" | "art-asset-plan" - ) + false } fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { @@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; - let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() { - "art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。" - } else { - "任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。" - }; + let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。"; let verification_requirement = match task.id.as_str() { "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", @@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( if task.id == "art-director" { if autonomous_manifest_ready_task_requires_visual_asset(&task.id) { return format!( - "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论。" ); } return format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 948eda19a..211da5829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = if editor_api_key_is_configured() { - match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, - } - } else { - None - }; + let required_visual_artifact: Option<&str> = None; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { !expected_artifacts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 220dae8ed..4d4616f77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); } } - if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" { - let manifest = match read_existing_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "无法确认首版美术素材登记状态,未执行删除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; - let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { - asset.local_path == "assets/art-spritesheet.png" - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if registered_fixed_asset_exists { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(), - detail: Some( - "path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费" - .to_string(), - ), - }; - } - } if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index fa17cbfcc..48ba9b4e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -540,6 +540,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("replace_existing")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let slice_count = input + .get("sliceCount") + .or_else(|| input.get("slice_count")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as usize); let requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, @@ -547,83 +552,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio asset_kind, asset_label, replace_existing, + slice_count, }; - let canonical_options = match agent_id { - "art-director" => Some(PlatformArtAssetGenerationOptions { - output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), - asset_label: "游戏统一视觉规范图".to_string(), - replace_existing: false, - }), - "design-foundation" - if requested_options - .output_path - .as_deref() - .is_some_and(design_foundation_ui_page_output_path_is_valid) => - { - Some(PlatformArtAssetGenerationOptions { - output_path: requested_options.output_path.clone(), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: if requested_options.asset_label.trim().is_empty() { - "游戏功能页面设计图".to_string() - } else { - requested_options.asset_label.clone() - }, - replace_existing: false, - }) - } - "design-foundation" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/ui-prototype.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: "游戏横屏界面原型图".to_string(), - replace_existing: false, - }), - "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/art-spritesheet.png".to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), - asset_label: "游戏首版核心美术素材".to_string(), - replace_existing: false, - }), - _ => None, - }; - let mut options = if let Some(canonical) = canonical_options { - let mismatch = requested_options - .output_path - .as_deref() - .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) - || (!requested_options.aspect_ratio.is_empty() - && requested_options.aspect_ratio != canonical.aspect_ratio) - || (!requested_options.image_size.is_empty() - && requested_options.image_size != canonical.image_size) - || (!requested_options.asset_kind.is_empty() - && requested_options.asset_kind != canonical.asset_kind) - || (!requested_options.asset_label.is_empty() - && requested_options.asset_label != canonical.asset_label); - if mismatch { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: format!( - "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", - canonical.output_path.as_deref().unwrap_or("null"), - canonical.aspect_ratio, - canonical.image_size, - canonical.asset_kind, - canonical.asset_label, - ), - detail: None, - }; - } - canonical - } else { + let mut options = { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { output_path: requested_options.output_path, @@ -648,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio requested_options.asset_label }, replace_existing, + slice_count, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index aec1efbc2..9cb4fda28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result { Ok(lines.join("\n")) } +pub(crate) fn read_agc_skill_resource(resource: &str) -> Result { + let manifest = validated_skill_pack_manifest()?; + let normalized = resource.trim().trim_start_matches('/').replace('\\', "/"); + let (skill_name, relative) = normalized + .split_once('/') + .ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?; + let entry = manifest + .skills + .iter() + .find(|entry| entry.name == skill_name) + .ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?; + if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) { + return Err("未登记或不安全的 AGC Skill 资源".to_string()); + } + let bundled_path = format!("{skill_name}/{relative}"); + let bytes = + bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?; + let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?; + String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string()) +} + pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result { let manifest = validated_skill_pack_manifest()?; let skills_root = isolated_os_home.join(".agents").join("skills"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 3f34dc890..9bab62192 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 1e7cd504d..8e39c1712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2484,6 +2484,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, 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、触控方向键、失败与重开", 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/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 ea07fb2a2..e9fd06398 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -246,6 +246,13 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。 - 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。 +### 2026-09-03 AGC 客户端能力以 MCP 暴露 + +- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。客户端对话入口继续驱动 Codex app-server;app-server 通过客户端随附的 stdio MCP 子进程调用审核后的客户端能力。客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。 +- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。 +- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。 +- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。 + ### 2026-08-10 Supervisor 边做边聊与条件中断 - 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。 @@ -1075,7 +1082,7 @@ game-project/ - 内部 owner 验证只接受 GUI / CLI 完整 16 任务 DAG 中 `agent-ready-task-scheduler` 启动的确定性直接 child、当前活跃根和完整 project/source/profile/Agent/run/parent/root/binding 身份。错误 source、delegated run、历史或终态根、非当前活跃根、跨 Agent/run 凭证均失败关闭;再次 mutation 使旧凭证失效,相同身份恢复可按当前事实确定性重验。本阶段不扩到后置 `publish-package`。`code-prototype` 与 `preview-readiness` 继续执行真实 `game.static_smoke`,`preview-playtest` 继续独立执行浏览器验收;任何 owner 文件凭证都不能替代可玩证据。 - `design-foundation` 的 2026-07-26 职责隔离继续有效:项目文件仍只允许 `memory/project.md`、`game/game_design.md` 和配置 Key 时的固定 `assets/ui-prototype.png`,禁止修改 `game/index.html`、调用 smoke / preview / process 或恢复整项目。未配置 External Editor API Key 时 `art-director` 保持只读协调;配置 Key 时它是条件 Canvas owner,必须生成并登记 `assets/art-spec.png`,成功 `canvas.asset_generate` 为本人当前 revision 形成普通验证凭证,不能被只读分类吞掉。配置 Key 时 UI 原型、透明图集、Canvas 登记和视觉门仍按既有合同执行,内部 owner 文件验证不替代图片证据。 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 -- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。 +- 视觉 Agent 只负责指导 Codex 选择合适的图片/编辑/图集工具并提供项目上下文,不再固定图片数量、文件槽位、素材类别或 spritesheet 布局;请求可按玩法需要生成单图、多图或任意切片布局。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;权限、计费、幂等、资源登记和安全校验保持不变。 - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。 diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index bc6aeeafe..cf52261c4 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -14,6 +14,10 @@ ## 3. 已确定的产品边界 +### 3.0 客户端能力 MCP 暴露边界(2026-09-03) + +客户端仍由现有对话入口启动并驱动 Codex;MCP 只是把客户端已审核的项目、文件、资源、画布、生成和预览能力暴露给该 Codex 或其它 Host。客户端只负责账号、项目路径、权限、计费、幂等、锁和恢复等自身安全,不替 Codex 做高层意图/完成门禁。审核 Skill 的索引和正文可作为只读 MCP resource 提供,第三方扩展不得获得客户端会话凭据、内部路径或 bridge token;该能力与公网 `/api/external/v1/mcp` 保持独立。 + ### 3.1 客户端安装、运行时注入 - 扩展内容保存在 AGC 客户端的扩展仓库。 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md index 789c00173..d8a570436 100644 --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -89,6 +89,10 @@ Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Rea 不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。 +### 2026-09-03 客户端对话与 MCP 能力边界 + +客户端对话仍由现有 Codex app-server 链路完成;MCP 只暴露客户端自身业务能力和审核 Skill 指导。客户端安全门禁限于账号、项目路径、权限、计费、幂等、锁、revision 与恢复,不根据 Codex 自然语言替代 Codex 决定业务动作。外部 Host 返回如需旁路归档,可使用显式记录工具,但不替代现有 conversation projection,也不触发资源、状态或完成判定。 + jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。 ## 5. 记录合同 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index 828bee880..4b543bef8 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -76,6 +76,7 @@ ### 失败生成任务归档与任务侧栏 - 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。 +- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。 - 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived`。`archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。 - 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。 - 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中` 与 `已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 69b9c9325..bee46452e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 198c31aef..949fadf99 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -863,6 +863,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_id, reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, + slice_count: None, slice_layout: None, style: None, model: Some(args.model), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d9f110905..b0663a4b1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8579,6 +8579,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -8645,6 +8646,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -8729,6 +8731,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( slice_source, request_context.external_call_deadline(), None, + None, ) .await { @@ -8891,6 +8894,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height, icon_image_srcs, slice_layout: None, + slice_count: None, slice_warning, prompt, actual_prompt: generated.actual_prompt, @@ -19059,7 +19063,7 @@ mod tests { .checked_sub(Duration::from_millis(1)) .expect("expired deadline should be representable"); - let error = slice_editor_icon_spritesheet_all(source, Some(expired), None) + let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None) .await .err() .expect("expired CPU budget must fail before decoding"); @@ -19748,6 +19752,7 @@ mod tests { spritesheet_height: 512, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, reason: "图集中未识别到可拆分的独立素材。".to_string(), diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 9e8c307aa..f7f318b86 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -251,6 +251,9 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) reference_id: String, pub(crate) reference_image_srcs: Option>, pub(crate) icon_descriptions: Vec, + /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) slice_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_layout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -267,8 +270,7 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) canvas_completion: Option, } -/// Opt-in fixed atlas slicing. Existing callers remain on the default -/// connected-component path unless they explicitly request this layout. +/// Deprecated compatibility layout. New callers should use `sliceCount`。 #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) enum EditorIconSpritesheetSliceLayout { #[serde(rename = "grid-2x2")] @@ -313,6 +315,8 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_layout: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) slice_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_warning: Option, pub(crate) prompt: String, pub(crate) actual_prompt: Option, @@ -1783,6 +1787,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: payload.slice_layout, + slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -1864,6 +1869,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: payload.slice_layout, + slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -1961,6 +1967,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( slice_source, request_context.external_call_deadline(), payload.slice_layout, + payload.slice_count, ) .await { @@ -2053,6 +2060,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, "sliceLayout": payload.slice_layout, + "sliceCount": payload.slice_count, "sliceWarning": &slice_warning, "warning": &generation_warning, "prompt": user_prompt.clone(), @@ -2121,6 +2129,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( icon.asset = item.asset.map(editor_asset_payload_from_record); } + let slice_count = icon_image_srcs.len(); Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { @@ -2129,6 +2138,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height, icon_image_srcs, slice_layout: payload.slice_layout, + slice_count: Some(slice_count), slice_warning, prompt, actual_prompt: generated.actual_prompt, @@ -2323,6 +2333,7 @@ pub async fn split_editor_icon_spritesheet( processing_deadline, memory_admission, None, + None, ) .await?; let prompt = source_resource @@ -2398,6 +2409,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, slice_layout: Option, + slice_count: Option, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); @@ -2408,6 +2420,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( processing_deadline, memory_admission, slice_layout, + slice_count, ) .await } @@ -2446,6 +2459,7 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( processing_deadline: Instant, memory_admission: Arc, slice_layout: Option, + slice_count: Option, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); @@ -2484,7 +2498,9 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( } None => prepare_generated_icon_spritesheet_all_by_connected_components( &source, - EDITOR_ICON_SPRITESHEET_MAX_SLICES, + slice_count + .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) + .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, ), } @@ -2505,6 +2521,15 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; + if let Some(expected) = slice_count { + if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected + { + return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()), + }))); + } + } if plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ @@ -2915,6 +2940,7 @@ mod tests { source, None, Some(EditorIconSpritesheetSliceLayout::Grid2x2), + None, ) .await .expect("declared 2x2 sheet should slice"); diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 0a15d26ef..de48a2d15 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2617,13 +2617,8 @@ mod tests { .is_some_and(|description| description.contains("同步返回 400")) ); assert_eq!( - icon_spritesheet_request["properties"]["sliceLayout"]["enum"], - json!(["grid-2x2"]) - ); - assert!( - icon_spritesheet_request["properties"]["sliceLayout"]["description"] - .as_str() - .is_some_and(|description| description.contains("固定图集切片合同")) + icon_spritesheet_request["properties"]["sliceCount"]["minimum"], + json!(1) ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; @@ -2635,11 +2630,7 @@ mod tests { ["sliceWarning"]["anyOf"][0]["$ref"], "#/components/schemas/EditorIconSpritesheetSliceWarning" ); - assert_eq!( - parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] - ["sliceLayout"]["enum"], - json!(["grid-2x2"]) - ); + assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object()); assert_eq!( parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"] ["anyOf"][0]["$ref"], diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index fa80d9b32..722213247 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1365,6 +1365,7 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetHeight" | "iconImageSrcs" | "sliceLayout" + | "sliceCount" | "frames" | "frameCount" | "frameWidth" From 01e057d04766deced3953565e178215897e2db6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 8 Sep 2026 22:03:37 +0800 Subject: [PATCH 2/4] =?UTF-8?q?Feat/AGC=E5=AF=B9=E8=AF=9D=E6=B8=B2?= =?UTF-8?q?=E6=9F=93markdown=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before: ![image.png](/attachments/ca6f46f7-dcbd-4132-bad2-cf5c83c49096) after: ![shotmd-1788500615.jpg](/attachments/719539a1-9dfb-412a-a9b9-298547b1791c) ![shotmd-1788500598.jpg](/attachments/4656815f-caca-4325-bca3-b29fe5742b6e) 考虑到在AGC场景下不是很有理由, 暂不实现链接, 图片, 内嵌html --------- Co-authored-by: 段舒康 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/281 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../components/ChatMarkdownMessage/index.tsx | 281 ++++++++++++++++++ .../src/features/agent-runtime/model.ts | 8 + .../projectArtifactSummaries.ts | 8 +- .../ProjectSupervisorView.tsx | 36 ++- .../ProjectWorkspaceChatPane.tsx | 25 +- .../SupervisorChatOnlyView.tsx | 63 +++- .../tests/ChatMarkdownMessage.test.tsx | 230 ++++++++++++++ .../tests/appSurface/project-assets.suite.ts | 8 +- .../appSurface/project-commands.suite.ts | 8 +- .../assert-planning-and-status-shortcuts.ts | 71 +++-- .../assert-playtest-and-release-shortcuts.ts | 66 ++-- .../assert-project-and-design-shortcuts.ts | 73 +++-- .../assert-project-tools-and-preview.ts | 39 +-- .../preview-shortcuts/message-bubble.ts | 7 + ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 11 +- 16 files changed, 804 insertions(+), 132 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx create mode 100644 apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx new file mode 100644 index 000000000..3d79df338 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -0,0 +1,281 @@ +import type { ErrorInfo, ReactNode } from 'react'; +import { + Children, + Component, + createContext, + isValidElement, + useContext, +} from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +export type ChatMarkdownMessageProps = { + text: string; + role: 'assistant' | 'user'; + streaming?: boolean; +}; + +type MarkdownErrorBoundaryProps = { + fallbackText: string; + children: ReactNode; +}; + +type MarkdownErrorBoundaryState = { + hasError: boolean; +}; + +export class MarkdownErrorBoundary extends Component< + MarkdownErrorBoundaryProps, + MarkdownErrorBoundaryState +> { + state: MarkdownErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): MarkdownErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + // Keep the original message visible without logging its potentially sensitive content. + const errorName = + error instanceof Error && error.name ? error.name : 'UnknownError'; + console.error('[chat-markdown] render failed', { + errorName, + hasComponentStack: Boolean(errorInfo.componentStack?.trim()), + }); + } + + componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) { + if ( + this.state.hasError && + prevProps.fallbackText !== this.props.fallbackText + ) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) { + return ( + + {this.props.fallbackText} + + ); + } + return this.props.children; + } +} + +const ListDepthContext = createContext(0); +const ListKindContext = createContext<'unordered' | 'ordered' | null>(null); +type ListItemParagraphPosition = 'first' | 'continuation'; + +const ListItemContext = createContext(null); + +function MarkdownUnorderedList({ children }: { children?: ReactNode }) { + const depth = useContext(ListDepthContext); + return ( + + +
    0 ? 'pl-4' : 'pl-0' + }`} + > + {children} +
+
+
+ ); +} + +function MarkdownOrderedList({ + children, + start, +}: { + children?: ReactNode; + start?: number; +}) { + const depth = useContext(ListDepthContext); + return ( + + +
    + {children} +
+
+
+ ); +} + +function MarkdownParagraph({ children }: { children?: ReactNode }) { + const paragraphPosition = useContext(ListItemContext); + return ( +

+ {children} +

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

+ {children} +

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

    {children}

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

    {children}

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

    {children}

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

    {children}

    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    + {children} +
    + ), + p: MarkdownParagraph, + ul: MarkdownUnorderedList, + ol: MarkdownOrderedList, + li: MarkdownListItem, + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + code: ({ className, children, node: _node, ...props }) => { + const isBlock = + Boolean(className?.includes('language-')) || + String(children).includes('\n'); + return isBlock ? ( + + {children} + + ) : ( + + {children} + + ); + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () => ( +
    + ), +}; + +const streamingMarkdownComponents: Components = { + ...markdownComponents, + p: StreamingMarkdownParagraph, +}; + +export function ChatMarkdownMessage({ + text, + role, + streaming = false, +}: ChatMarkdownMessageProps) { + if (role === 'user') { + return {text}; + } + + return ( + + + {text} + + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index d8df6bc98..451316d2e 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2140,6 +2140,14 @@ export function projectSupervisorVisibleConversationText( ); } +export function projectSupervisorChatMessageText( + message: Pick, +) { + return message.role === 'assistant' + ? projectSupervisorVisibleConversationText(message.text, message.role) + : message.text; +} + export function projectRuntimeVisibleToolSummary(summary: string) { return summary .split('·') diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts index f256883f0..2181c5aac 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts @@ -272,7 +272,13 @@ export function summarizeProjectFileContent(result: LocalProjectFileResult) { } 字符` : result.content; - return `文件:${result.path}\n${content || '空文件'}`; + const visibleContent = content || '空文件'; + let longestBacktickRun = 0; + for (const match of visibleContent.matchAll(/`+/gu)) { + longestBacktickRun = Math.max(longestBacktickRun, match[0].length); + } + const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); + return `文件:${result.path}\n\n${fence}text\n${visibleContent}\n${fence}`; } export function inferProjectFileAssetDraft( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 2c04e0227..b0fde2c0e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -18,11 +18,12 @@ import type { PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; +import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import { projectProfessionalAgentLabel, projectRuntimeVisibleError, + projectSupervisorChatMessageText, ProjectSupervisorRuntimePanel, - projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; @@ -178,15 +179,15 @@ export function ProjectSupervisorView({ ) : null} {visibleMessages.map((message, index) => ( -

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

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

    ) : null} - {transientReply ? ( -

    - {transientReply} -

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

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

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

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

    + + ))} {transientReply ? ( -

    - {transientReply} -

    + + ) : null} {running && !transientReply ? (
    markdown recovered; +} + +describe('ChatMarkdownMessage', () => { + it('渲染 assistant 的 GFM 内容与代码块', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h2')?.textContent).toBe('标题'); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.querySelector('pre code')?.textContent).toContain( + 'const answer = 42;', + ); + }); + + it('为嵌套无序列表保留逐层缩进', () => { + const { container } = render( + , + ); + + const lists = container.querySelectorAll('ul'); + expect(lists).toHaveLength(3); + expect(lists[0]?.className).toContain('pl-0'); + expect(lists[1]?.className).toContain('pl-4'); + expect(lists[2]?.className).toContain('pl-4'); + }); + + it('为四到六级标题提供递进的字号与字重', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h4')?.className).toContain('text-sm'); + expect(container.querySelector('h4')?.className).toContain('font-semibold'); + expect(container.querySelector('h5')?.className).toContain('font-medium'); + expect(container.querySelector('h6')?.className).toContain('text-xs'); + expect(container.querySelector('h6')?.className).toContain('tracking-wide'); + }); + + it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => { + const { container } = render( + , + ); + + const items = container.querySelectorAll('ol > li'); + expect(items[0]?.textContent?.trim()).toBe('第一件事'); + expect(items[1]?.textContent?.trim()).toContain('第二件事'); + expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline'); + expect(items[0]?.className).toContain('whitespace-normal'); + expect(items[0]?.textContent).not.toContain('-'); + }); + + it('保留 Markdown 有序列表的起始编号', () => { + const { container } = render( + , + ); + + expect(container.querySelector('ol')?.getAttribute('start')).toBe('3'); + expect(container.querySelectorAll('ol > li')).toHaveLength(2); + }); + + it('为列表项后续段落保留段落间距', () => { + const { container } = render( + , + ); + + const paragraphs = container.querySelectorAll('ul > li p'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]?.className).toContain('inline'); + expect(paragraphs[1]?.className).toContain('mt-2'); + expect(paragraphs[1]?.className).not.toContain('inline'); + }); + + it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('pre code')?.getAttribute('node'), + ).toBeNull(); + }); + + it('无语言标记的多行围栏代码仍使用代码块样式', () => { + const { container } = render( + , + ); + + const code = container.querySelector('pre code'); + expect(code?.className).toContain('whitespace-pre'); + expect(code?.className).not.toContain('rounded'); + }); + + it('保留行内代码中的 HTML 字面量', () => { + const { container } = render( + `'} />, + ); + + expect(container.querySelector('code')?.textContent).toBe(''); + }); + + it('保留围栏代码中的 HTML 字面量而不双重转义', () => { + const { container } = render( +
    \n```'} + />, + ); + + expect(container.querySelector('pre code')?.textContent).toContain( + '
    ', + ); + expect(container.querySelector('pre code')?.textContent).not.toContain( + '<div', + ); + }); + + it('累计文本变化后可从 Markdown 错误回退中恢复', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + const { container, rerender } = render( + + + , + ); + + expect(container.textContent).toBe('partial'); + + rerender( + + + , + ); + + expect(container.textContent).toBe('markdown recovered'); + } finally { + consoleError.mockRestore(); + } + }); + + it('保留流式 assistant 文本并标记 streaming 状态', () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain('正在生成'); + expect(container.textContent).toContain('结果'); + }); + + it('用户消息保持纯文本,不解析 Markdown', () => { + const { container } = render( + , + ); + + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('code')).toBeNull(); + expect(container.textContent).toContain('**不要解析**'); + expect(container.textContent).toContain('`/read game`'); + }); + + it('不输出原始 HTML、可点击链接或图片节点', () => { + const { container } = render( + alert(1)\n\n[外链](https://example.com) ![示意图](x.png)' + } + />, + ); + + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain('外链'); + expect(container.textContent).toContain('图片:示意图'); + }); + + it('支持表格并允许窄视口横向滚动', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('table th')).toHaveLength(2); + expect(container.querySelector('div.overflow-x-auto')).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts index 1df0e257a..ef3335a59 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts @@ -923,7 +923,9 @@ export function registerProjectAssetTests() { submitChat('/read game/index.html'); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', @@ -992,7 +994,9 @@ export function registerProjectAssetTests() { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index c4f51ff77..528710a1e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -3805,7 +3805,13 @@ export function registerProjectCommandTests() { submitChat('/files'); expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); - expect(screen.getByText(/- game\//)).not.toBeNull(); + expect( + screen.getByText( + (_, element) => + element?.tagName === 'LI' && + element.textContent?.trim() === '- game/', + ), + ).not.toBeNull(); expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); expect(screen.getByText(/- assets\/uploads\/hero\.png/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('list_local_project_files', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index cfb73e050..30d180a03 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlanningAndStatusShortcutFlow( invoke: PreviewShortcutInvoke, @@ -70,7 +71,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/blockers'); const blockerMessages = await screen.findAllByText(/当前阻塞项:/); - const blockerMessage = blockerMessages[blockerMessages.length - 1]; + const blockerMessage = messageBubble( + blockerMessages[blockerMessages.length - 1], + ); expect(blockerMessage.textContent).toContain('项目:未命名游戏原型'); expect(blockerMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -170,7 +173,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/ready'); const readyMessages = await screen.findAllByText(/试玩就绪度:/); - const readyMessage = readyMessages[readyMessages.length - 1]; + const readyMessage = messageBubble(readyMessages[readyMessages.length - 1]); expect(readyMessage.textContent).toContain('项目:未命名游戏原型'); expect(readyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -270,7 +273,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/evidence'); const evidenceMessages = await screen.findAllByText(/验证证据台账:/); - const evidenceMessage = evidenceMessages[evidenceMessages.length - 1]; + const evidenceMessage = messageBubble( + evidenceMessages[evidenceMessages.length - 1], + ); expect(evidenceMessage.textContent).toContain('项目:未命名游戏原型'); expect(evidenceMessage.textContent).toContain( 'Run trace:已有 run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -377,7 +382,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/deps'); const dependencyMessages = await screen.findAllByText(/任务依赖链:/); - const dependencyMessage = dependencyMessages[dependencyMessages.length - 1]; + const dependencyMessage = messageBubble( + dependencyMessages[dependencyMessages.length - 1], + ); expect(dependencyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -479,7 +486,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/revise'); const revisionMessages = await screen.findAllByText(/改版草稿:/); - const revisionMessage = revisionMessages[revisionMessages.length - 1]; + const revisionMessage = messageBubble( + revisionMessages[revisionMessages.length - 1], + ); expect(revisionMessage.textContent).toContain('项目:未命名游戏原型'); expect(revisionMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -604,7 +613,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/privacy'); const privacyMessages = await screen.findAllByText(/隐私与导出边界:/); - const privacyMessage = privacyMessages[privacyMessages.length - 1]; + const privacyMessage = messageBubble( + privacyMessages[privacyMessages.length - 1], + ); expect(privacyMessage.textContent).toContain('项目:未命名游戏原型'); expect(privacyMessage.textContent).toContain( '本地目录:/tmp/authorized-game', @@ -616,10 +627,10 @@ export async function assertPlanningAndStatusShortcutFlow( '本地预览:未启动;仅限 127.0.0.1 本机访问', ); expect(privacyMessage.textContent).toContain( - '试玩包:尚未导出;只应包含 game/**、assets/** 和 exports/README.md', + '试玩包:尚未导出;只应包含 game/、assets/ 和 exports/README.md', ); expect(privacyMessage.textContent).toContain( - '内部文件:.agent/**、memory/**、日志、trace、配置和密钥不得进入试玩包', + '内部文件:.agent/、memory/、日志、trace、配置和密钥不得进入试玩包', ); expect(privacyMessage.textContent).toContain( '素材来源:2 个;上传 1 / 画板 1', @@ -697,7 +708,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/criteria'); expect(await screen.findByText(/当前验收标准:/)).not.toBeNull(); const criteriaMessages = screen.getAllByText(/当前验收标准:/); - const criteriaMessage = criteriaMessages[criteriaMessages.length - 1]; + const criteriaMessage = messageBubble( + criteriaMessages[criteriaMessages.length - 1], + ); expect(criteriaMessage.textContent).toContain( 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan)', ); @@ -740,7 +753,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/groups'); expect(await screen.findByText(/专业组进度:/)).not.toBeNull(); const groupMessages = screen.getAllByText(/专业组进度:/); - const groupMessage = groupMessages[groupMessages.length - 1]; + const groupMessage = messageBubble(groupMessages[groupMessages.length - 1]); expect(groupMessage.textContent).toContain( '美术组:完成 0/3 · active 0 · carry 0 · ready 1', ); @@ -793,7 +806,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/balance'); expect(await screen.findByText(/数值状态:/)).not.toBeNull(); const balanceMessages = screen.getAllByText(/数值状态:/); - const balanceMessage = balanceMessages[balanceMessages.length - 1]; + const balanceMessage = messageBubble( + balanceMessages[balanceMessages.length - 1], + ); expect(balanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(balanceMessage.textContent).toContain( 'balance-director:Director 确定数值口径 · 待处理', @@ -874,7 +889,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/budget'); expect(await screen.findByText(/运行预算:/)).not.toBeNull(); const budgetMessages = screen.getAllByText(/运行预算:/); - const budgetMessage = budgetMessages[budgetMessages.length - 1]; + const budgetMessage = messageBubble( + budgetMessages[budgetMessages.length - 1], + ); expect(budgetMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -923,7 +940,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/qa'); expect(await screen.findByText(/质量检查:/)).not.toBeNull(); const qaMessages = screen.getAllByText(/质量检查:/); - const qaMessage = qaMessages[qaMessages.length - 1]; + const qaMessage = messageBubble(qaMessages[qaMessages.length - 1]); expect(qaMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -988,7 +1005,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/changes'); expect(await screen.findByText(/最近变更:/)).not.toBeNull(); const changeMessages = screen.getAllByText(/最近变更:/); - const changeMessage = changeMessages[changeMessages.length - 1]; + const changeMessage = messageBubble( + changeMessages[changeMessages.length - 1], + ); expect(changeMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -1064,7 +1083,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/review'); expect(await screen.findByText(/评审状态:/)).not.toBeNull(); const reviewMessages = screen.getAllByText(/评审状态:/); - const reviewMessage = reviewMessages[reviewMessages.length - 1]; + const reviewMessage = messageBubble( + reviewMessages[reviewMessages.length - 1], + ); expect(reviewMessage.textContent).toContain('Evaluator:通过'); expect(reviewMessage.textContent).toContain('返工焦点:暂无'); expect(reviewMessage.textContent).toContain( @@ -1150,7 +1171,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/timeline'); expect(await screen.findByText(/项目时间线:/)).not.toBeNull(); const timelineMessages = screen.getAllByText(/项目时间线:/); - const timelineMessage = timelineMessages[timelineMessages.length - 1]; + const timelineMessage = messageBubble( + timelineMessages[timelineMessages.length - 1], + ); expect(timelineMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done', ); @@ -1209,7 +1232,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/handoff'); expect(await screen.findByText(/项目交接:/)).not.toBeNull(); const handoffMessages = screen.getAllByText(/项目交接:/); - const handoffMessage = handoffMessages[handoffMessages.length - 1]; + const handoffMessage = messageBubble( + handoffMessages[handoffMessages.length - 1], + ); expect(handoffMessage.textContent).toContain( '- Run:run-main-shortcut-trace', ); @@ -1335,14 +1360,14 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/todo'); const todoMessages = await screen.findAllByText(/下一轮小步:/); - const todoMessage = todoMessages[todoMessages.length - 1]; + const todoMessage = messageBubble(todoMessages[todoMessages.length - 1]); expect(todoMessage.textContent).toContain('项目:未命名游戏原型'); expect(todoMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(todoMessage.textContent).toContain('编排下一步:preview'); expect(todoMessage.textContent).toContain( - '1. ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', + 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', ); expect(todoMessage.textContent).toContain( '边界:只整理下一步;不读取任务文件;不启动 run;不修改项目', @@ -1428,7 +1453,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/plan'); const planMessages = await screen.findAllByText(/下一轮分工计划:/); - const planMessage = planMessages[planMessages.length - 1]; + const planMessage = messageBubble(planMessages[planMessages.length - 1]); expect(planMessage.textContent).toContain('项目:未命名游戏原型'); expect(planMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -1533,7 +1558,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/guide'); const guideMessages = await screen.findAllByText(/使用导引:/); - const guideMessage = guideMessages[guideMessages.length - 1]; + const guideMessage = messageBubble(guideMessages[guideMessages.length - 1]); expect(guideMessage.textContent).toContain('项目:未命名游戏原型'); expect(guideMessage.textContent).toContain('当前阶段:可导出'); expect(guideMessage.textContent).toContain( @@ -1627,7 +1652,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/progress'); const progressMessages = await screen.findAllByText(/项目进度:/); - const progressMessage = progressMessages[progressMessages.length - 1]; + const progressMessage = messageBubble( + progressMessages[progressMessages.length - 1], + ); expect(progressMessage.textContent).toContain('项目:未命名游戏原型'); expect(progressMessage.textContent).toContain('当前阶段:已生成'); expect(progressMessage.textContent).toMatch( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts index acb85a840..2e7cf976c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlaytestAndReleaseShortcutFlow( invoke: PreviewShortcutInvoke, @@ -170,7 +171,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/listing'); const listingMessages = await screen.findAllByText(/作品页草稿:/); - const listingMessage = listingMessages[listingMessages.length - 1]; + const listingMessage = messageBubble( + listingMessages[listingMessages.length - 1], + ); expect(listingMessage.textContent).toContain('标题:未命名游戏原型'); expect(listingMessage.textContent).toContain( '一句话卖点:做一个厨房弹幕游戏', @@ -262,7 +265,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( ).length; submitChat('/playtest'); const playtestMessages = await screen.findAllByText(/试玩状态:/); - const playtestMessage = playtestMessages[playtestMessages.length - 1]; + const playtestMessage = messageBubble( + playtestMessages[playtestMessages.length - 1], + ); expect(playtestMessage.textContent).toContain( '原型:最近 run 已通过 run-main-shortcut-trace', ); @@ -329,26 +334,26 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/test-plan'); const testPlanMessages = await screen.findAllByText(/手动测试计划:/); - const testPlanMessage = testPlanMessages[testPlanMessages.length - 1]; + const testPlanMessage = messageBubble( + testPlanMessages[testPlanMessages.length - 1], + ); expect(testPlanMessage.textContent).toContain('项目:未命名游戏原型'); expect(testPlanMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(testPlanMessage.textContent).toContain( '当前证据:最近 run 已通过 run-main-shortcut-trace;预览 未启动;入口 未见 trace 产物', ); expect(testPlanMessage.textContent).toContain( - '1. 启动预览:/run 后确认首屏不空白', + '启动预览:/run 后确认首屏不空白', ); expect(testPlanMessage.textContent).toContain( - '2. 30 秒理解:目标、操作、得分/失败和重开可见', + '30 秒理解:目标、操作、得分/失败和重开可见', ); expect(testPlanMessage.textContent).toContain( - '3. 输入验证:键盘/点击/触屏至少一种可完成核心动作', + '输入验证:键盘/点击/触屏至少一种可完成核心动作', ); + expect(testPlanMessage.textContent).toContain('结局验证:胜利或失败后可重开'); expect(testPlanMessage.textContent).toContain( - '4. 结局验证:胜利或失败后可重开', - ); - expect(testPlanMessage.textContent).toContain( - '5. 回归检查:/mobile;/accessibility;/performance;/audio', + '回归检查:/mobile;/accessibility;/performance;/audio', ); expect(testPlanMessage.textContent).toContain( 'preview-readiness:程序组 / Preview 执行静态自检 · 待处理', @@ -432,7 +437,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/audience'); const audienceMessages = await screen.findAllByText(/首批试玩对象:/); - const audienceMessage = audienceMessages[audienceMessages.length - 1]; + const audienceMessage = messageBubble( + audienceMessages[audienceMessages.length - 1], + ); expect(audienceMessage.textContent).toContain('项目:未命名游戏原型'); expect(audienceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(audienceMessage.textContent).toContain( @@ -545,7 +552,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/invite'); const inviteMessages = await screen.findAllByText(/试玩邀请:/); - const inviteMessage = inviteMessages[inviteMessages.length - 1]; + const inviteMessage = messageBubble( + inviteMessages[inviteMessages.length - 1], + ); expect(inviteMessage.textContent).toContain('项目:未命名游戏原型'); expect(inviteMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(inviteMessage.textContent).toContain( @@ -723,7 +732,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/survey'); const surveyMessages = await screen.findAllByText(/试玩问卷:/); - const surveyMessage = surveyMessages[surveyMessages.length - 1]; + const surveyMessage = messageBubble( + surveyMessages[surveyMessages.length - 1], + ); expect(surveyMessage.textContent).toContain('项目:未命名游戏原型'); expect(surveyMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(surveyMessage.textContent).toContain( @@ -830,7 +841,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/cover'); const coverMessages = await screen.findAllByText(/封面与缩略图:/); - const coverMessage = coverMessages[coverMessages.length - 1]; + const coverMessage = messageBubble(coverMessages[coverMessages.length - 1]); expect(coverMessage.textContent).toContain('项目:未命名游戏原型'); expect(coverMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -939,7 +950,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/screenshots'); const screenshotMessages = await screen.findAllByText(/宣传截图:/); - const screenshotMessage = screenshotMessages[screenshotMessages.length - 1]; + const screenshotMessage = messageBubble( + screenshotMessages[screenshotMessages.length - 1], + ); expect(screenshotMessage.textContent).toContain('项目:未命名游戏原型'); expect(screenshotMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -1048,7 +1061,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/trailer'); const trailerMessages = await screen.findAllByText(/试玩短视频:/); - const trailerMessage = trailerMessages[trailerMessages.length - 1]; + const trailerMessage = messageBubble( + trailerMessages[trailerMessages.length - 1], + ); expect(trailerMessage.textContent).toContain('项目:未命名游戏原型'); expect(trailerMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(trailerMessage.textContent).toContain( @@ -1158,7 +1173,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/faq'); const faqMessages = await screen.findAllByText(/试玩 FAQ:/); - const faqMessage = faqMessages[faqMessages.length - 1]; + const faqMessage = messageBubble(faqMessages[faqMessages.length - 1]); expect(faqMessage.textContent).toContain('项目:未命名游戏原型'); expect(faqMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(faqMessage.textContent).toContain( @@ -1263,7 +1278,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/post'); const postMessages = await screen.findAllByText(/社区发布文案:/); - const postMessage = postMessages[postMessages.length - 1]; + const postMessage = messageBubble(postMessages[postMessages.length - 1]); expect(postMessage.textContent).toContain('项目:未命名游戏原型'); expect(postMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(postMessage.textContent).toContain( @@ -1374,7 +1389,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/store'); const storeMessages = await screen.findAllByText(/上架资料:/); - const storeMessage = storeMessages[storeMessages.length - 1]; + const storeMessage = messageBubble(storeMessages[storeMessages.length - 1]); expect(storeMessage.textContent).toContain('项目:未命名游戏原型'); expect(storeMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(storeMessage.textContent).toContain( @@ -1484,7 +1499,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/media-kit'); const mediaKitMessages = await screen.findAllByText(/媒体资料包:/); - const mediaKitMessage = mediaKitMessages[mediaKitMessages.length - 1]; + const mediaKitMessage = messageBubble( + mediaKitMessages[mediaKitMessages.length - 1], + ); expect(mediaKitMessage.textContent).toContain('项目:未命名游戏原型'); expect(mediaKitMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(mediaKitMessage.textContent).toContain( @@ -1591,8 +1608,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/release-notes'); const releaseNotesMessages = await screen.findAllByText(/试玩更新说明:/); - const releaseNotesMessage = - releaseNotesMessages[releaseNotesMessages.length - 1]; + const releaseNotesMessage = messageBubble( + releaseNotesMessages[releaseNotesMessages.length - 1], + ); expect(releaseNotesMessage.textContent).toContain('项目:未命名游戏原型'); expect(releaseNotesMessage.textContent).toContain( '一句话:做一个厨房弹幕游戏', @@ -1709,7 +1727,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/known-issues'); const knownIssueMessages = await screen.findAllByText(/已知问题清单:/); - const knownIssueMessage = knownIssueMessages[knownIssueMessages.length - 1]; + const knownIssueMessage = messageBubble( + knownIssueMessages[knownIssueMessages.length - 1], + ); expect(knownIssueMessage.textContent).toContain('项目:未命名游戏原型'); expect(knownIssueMessage.textContent).toContain( '当前状态:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed;预览 未启动', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index 6da7eacc0..4d0019dff 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectAndDesignShortcutFlow( invoke: PreviewShortcutInvoke, @@ -43,11 +44,12 @@ export async function assertProjectAndDesignShortcutFlow( ), ).toBe(true); submitChat('/agent-conversations'); - expect( - await screen.findByText( - /Agent 对话读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read \.agent\/conversations\/agents\/art-asset-plan\.jsonl/, - ), - ).not.toBeNull(); + const agentConversationMessage = + await screen.findByText('Agent 对话读取命令:'); + const agentConversationBubble = messageBubble(agentConversationMessage); + expect(agentConversationBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read .agent/conversations/agents/art-asset-plan.jsonl', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向对话' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -60,11 +62,12 @@ export async function assertProjectAndDesignShortcutFlow( }), ); submitChat('/agent-memories'); - expect( - await screen.findByText( - /Agent 私有记忆读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read memory\/agents\/art\/asset\.md/, - ), - ).not.toBeNull(); + const agentMemoryMessage = + await screen.findByText('Agent 私有记忆读取命令:'); + const agentMemoryBubble = messageBubble(agentMemoryMessage); + expect(agentMemoryBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read memory/agents/art/asset.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向记忆' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -126,7 +129,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/goal'); expect(await screen.findByText(/创作目标:/)).not.toBeNull(); const goalMessages = screen.getAllByText(/创作目标:/); - const goalMessage = goalMessages[goalMessages.length - 1]; + const goalMessage = messageBubble(goalMessages[goalMessages.length - 1]); expect(goalMessage.textContent).toContain('Manifest:做一个厨房弹幕游戏'); expect(goalMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -184,7 +187,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/spec'); const specMessages = await screen.findAllByText(/创作规格包:/); - const specMessage = specMessages[specMessages.length - 1]; + const specMessage = messageBubble(specMessages[specMessages.length - 1]); expect(specMessage.textContent).toContain('项目:未命名游戏原型'); expect(specMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(specMessage.textContent).toContain( @@ -284,7 +287,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/mvp'); expect(await screen.findByText(/MVP 范围:/)).not.toBeNull(); const mvpMessages = screen.getAllByText(/MVP 范围:/); - const mvpMessage = mvpMessages[mvpMessages.length - 1]; + const mvpMessage = messageBubble(mvpMessages[mvpMessages.length - 1]); expect(mvpMessage.textContent).toContain('项目:未命名游戏原型'); expect(mvpMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mvpMessage.textContent).toContain( @@ -380,7 +383,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/pitch'); const pitchMessages = await screen.findAllByText(/试玩定位:/); - const pitchMessage = pitchMessages[pitchMessages.length - 1]; + const pitchMessage = messageBubble(pitchMessages[pitchMessages.length - 1]); expect(pitchMessage.textContent).toContain('项目:未命名游戏原型'); expect(pitchMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(pitchMessage.textContent).toContain( @@ -475,7 +478,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/demo'); const demoMessages = await screen.findAllByText(/试玩讲解稿:/); - const demoMessage = demoMessages[demoMessages.length - 1]; + const demoMessage = messageBubble(demoMessages[demoMessages.length - 1]); expect(demoMessage.textContent).toContain('项目:未命名游戏原型'); expect(demoMessage.textContent).toContain( '30 秒开场:这是《未命名游戏原型》,目标是做一个厨房弹幕游戏', @@ -575,7 +578,9 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/rules'); expect(await screen.findByText(/玩法操作:/)).not.toBeNull(); const controlMessages = screen.getAllByText(/玩法操作:/); - const controlMessage = controlMessages[controlMessages.length - 1]; + const controlMessage = messageBubble( + controlMessages[controlMessages.length - 1], + ); expect(controlMessage.textContent).toContain('项目:未命名游戏原型'); expect(controlMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(controlMessage.textContent).toContain( @@ -679,7 +684,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/tutorial'); const tutorialMessages = await screen.findAllByText(/新手引导:/); - const tutorialMessage = tutorialMessages[tutorialMessages.length - 1]; + const tutorialMessage = messageBubble( + tutorialMessages[tutorialMessages.length - 1], + ); expect(tutorialMessage.textContent).toContain('项目:未命名游戏原型'); expect(tutorialMessage.textContent).toContain('首屏目标:做一个厨房弹幕游戏'); expect(tutorialMessage.textContent).toContain( @@ -782,7 +789,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/mobile'); const mobileMessages = await screen.findAllByText(/移动试玩:/); - const mobileMessage = mobileMessages[mobileMessages.length - 1]; + const mobileMessage = messageBubble( + mobileMessages[mobileMessages.length - 1], + ); expect(mobileMessage.textContent).toContain('项目:未命名游戏原型'); expect(mobileMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mobileMessage.textContent).toContain( @@ -891,8 +900,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/compatibility'); const compatibilityMessages = await screen.findAllByText(/兼容性说明:/); - const compatibilityMessage = - compatibilityMessages[compatibilityMessages.length - 1]; + const compatibilityMessage = messageBubble( + compatibilityMessages[compatibilityMessages.length - 1], + ); expect(compatibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(compatibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1006,8 +1016,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/accessibility'); const accessibilityMessages = await screen.findAllByText(/可读性与无障碍:/); - const accessibilityMessage = - accessibilityMessages[accessibilityMessages.length - 1]; + const accessibilityMessage = messageBubble( + accessibilityMessages[accessibilityMessages.length - 1], + ); expect(accessibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(accessibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1129,8 +1140,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/localization'); const localizationMessages = await screen.findAllByText(/本地化与文案:/); - const localizationMessage = - localizationMessages[localizationMessages.length - 1]; + const localizationMessage = messageBubble( + localizationMessages[localizationMessages.length - 1], + ); expect(localizationMessage.textContent).toContain('项目:未命名游戏原型'); expect(localizationMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(localizationMessage.textContent).toContain( @@ -1259,8 +1271,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/performance'); const performanceMessages = await screen.findAllByText(/性能与加载:/); - const performanceMessage = - performanceMessages[performanceMessages.length - 1]; + const performanceMessage = messageBubble( + performanceMessages[performanceMessages.length - 1], + ); expect(performanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(performanceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(performanceMessage.textContent).toContain( @@ -1376,7 +1389,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/polish'); const polishMessages = await screen.findAllByText(/试玩前打磨:/); - const polishMessage = polishMessages[polishMessages.length - 1]; + const polishMessage = messageBubble( + polishMessages[polishMessages.length - 1], + ); expect(polishMessage.textContent).toContain('项目:未命名游戏原型'); expect(polishMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(polishMessage.textContent).toContain( @@ -1488,7 +1503,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/credits'); const creditMessages = await screen.findAllByText(/素材署名:/); - const creditMessage = creditMessages[creditMessages.length - 1]; + const creditMessage = messageBubble( + creditMessages[creditMessages.length - 1], + ); expect(creditMessage.textContent).toContain('当前资产:2 个'); expect(creditMessage.textContent).toContain('来源分布:上传 1 / 画板 1'); expect(creditMessage.textContent).toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index ce71e30ed..408b6041e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -7,6 +7,7 @@ import { within, } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectToolsAndPreviewFlow( invoke: PreviewShortcutInvoke, @@ -36,7 +37,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/feedback'); const feedbackMessages = await screen.findAllByText(/试玩反馈:/); - const feedbackMessage = feedbackMessages[feedbackMessages.length - 1]; + const feedbackMessage = messageBubble( + feedbackMessages[feedbackMessages.length - 1], + ); expect(feedbackMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -125,7 +128,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/retention'); const retentionMessages = await screen.findAllByText(/复玩观察:/); - const retentionMessage = retentionMessages[retentionMessages.length - 1]; + const retentionMessage = messageBubble( + retentionMessages[retentionMessages.length - 1], + ); expect(retentionMessage.textContent).toContain('项目:未命名游戏原型'); expect(retentionMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(retentionMessage.textContent).toContain( @@ -233,7 +238,7 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/share'); const shareMessages = await screen.findAllByText(/试玩交付:/); - const shareMessage = shareMessages[shareMessages.length - 1]; + const shareMessage = messageBubble(shareMessages[shareMessages.length - 1]); expect(shareMessage.textContent).toContain('项目:未命名游戏原型'); expect(shareMessage.textContent).toContain('目录:/tmp/authorized-game'); expect(shareMessage.textContent).toContain( @@ -438,11 +443,11 @@ export async function assertProjectToolsAndPreviewFlow( expect(composerInput).toHaveProperty('value', '/read game/index.html'); await waitFor(() => expect(document.activeElement).toBe(composerInput)); submitChat('/run-artifacts'); - expect( - await screen.findByText( - /最近 Run 产物读取命令:[\s\S]*exports\/README\.md · 128B · fnv1a64:exports:\/read exports\/README\.md/, - ), - ).not.toBeNull(); + const runArtifactsHeading = + await screen.findByText('最近 Run 产物读取命令:'); + expect(messageBubble(runArtifactsHeading).textContent).toContain( + 'exports/README.md · 128B · fnv1a64:exports:/read exports/README.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取首个 Run 产物' })); expect(composerInput).toHaveProperty('value', '/read exports/README.md'); expect(invoke).not.toHaveBeenCalledWith( @@ -457,8 +462,9 @@ export async function assertProjectToolsAndPreviewFlow( submitChat('/passes'); const passArtifactMessages = await screen.findAllByText(/Agent 轮次产物读取命令:/); - const passArtifactMessage = - passArtifactMessages[passArtifactMessages.length - 1]; + const passArtifactMessage = messageBubble( + passArtifactMessages[passArtifactMessages.length - 1], + ); expect(passArtifactMessage.textContent).toContain( '.agent/passes/pass-1/agenda.md · 96B · fnv1a64:agenda', ); @@ -564,13 +570,12 @@ export async function assertProjectToolsAndPreviewFlow( expect(screen.getByText(/下一步:设计实现组 \/ Director/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Trace' })); - expect( - await screen.findByText( - (content) => - content.trimStart().startsWith('Run:run-main-shortcut-trace') && - content.includes('产物快照:'), - ), - ).not.toBeNull(); + const traceHeading = await screen.findByText( + (content, element) => + element?.tagName === 'P' && + content.trimStart().startsWith('Run:run-main-shortcut-trace'), + ); + expect(messageBubble(traceHeading).textContent).toContain('产物快照:'); expect(screen.getByText(/产物快照:/)).not.toBeNull(); expect( screen.getByText(/exports\/README\.md · fnv1a64:exports/), diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts new file mode 100644 index 000000000..60bd9e43b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts @@ -0,0 +1,7 @@ +export function messageBubble(element: Element): HTMLElement { + const bubble = element.closest('.message'); + if (!bubble) { + throw new Error('Expected chat message bubble'); + } + return bubble; +} diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 13bb6403e..14980e6d7 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -185,7 +185,7 @@ completed -> starting(nextSlice) idle -> focused(document|art|audio|version) -> idle ``` -- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。 +- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `
    `,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown` 的 `skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `` 双重转义为字面量 `<tag>`。
     - 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
     - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
     - 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
    diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    index e9fd06398..79a527af8 100644
    --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    @@ -889,7 +889,7 @@ game-project/
     - 聊天输入 `/status` 会读取 `.agent/manifest.json` 并在聊天里汇总项目目录、任务状态、资产数量、预览状态和最近命令,不向普通用户暴露任务或文件面板。
     - 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;主窗口最近项目文件可一键读取,也可一键填入 `/read` 或 `/asset-register` 草稿,但资产登记仍必须走聊天确认;`/checkpoints` 复用 `file.list` / `file.read` 只读列出最近 checkpoint id、文件数、大小和可复制的 `/diff` / `/restore` 命令,主窗口最近 checkpoint 列表也可填入对应草稿;普通用户仍不暴露文件读写面板。
     - 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源,资产列表消息和主窗口最近项目资产入口都可一键填入对应资产的 `/read` 草稿;聊天输入 `/art` 只使用当前已加载 manifest 盘点美术素材,并提供首版美术生成或读取美术清单草稿,不直接读取文件、不触发平台生成或画板同步;聊天输入 `/audio` 只使用当前已加载 manifest 盘点音频素材,并提供登记音效或读取音频清单草稿,不直接读取文件、不触发资产写入;`/asset-register 路径 [kind] [mediaType]` 可确认后登记项目内已有资产;主窗口音效快捷入口只填入 `/asset-register assets/audio/sfx.wav audio audio/wav` 草稿,不直接写 manifest;普通用户仍不暴露资产面板。
    -- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;普通用户仍不暴露文件写入或删除能力。
    +- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;聊天回执中的正文必须包装为安全的 `
    ` 代码块,保留源码字面量但不得执行 HTML;普通用户仍不暴露文件写入或删除能力。
     - 主窗口常用生成产物入口只把 `game/index.html`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json` 和 `exports/README.md` 的 `/read` 草稿填入聊天输入框;聊天输入 `/artifacts` 只列出这组固定读取命令并提供首个 `/read` 草稿,聊天输入 `/run-artifacts` 只列出最近 run trace 里的产物读取命令并提供首个 `/read` 草稿,聊天输入 `/run-files` 只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 的读取命令并提供首个 `/read` 草稿;读取仍由聊天侧 `file.read` 权限流执行。
     - 聊天输入 `/logs` 只列出 `.agent/logs/command.log`、`.agent/logs/preview.log` 和 `.agent/logs/agent.log` 对应的 `/read ...` 草稿 / 命令,并提供首个 `/read` 草稿;该命令不直接读取日志,不新增普通用户日志面板,实际读取仍由聊天侧 `file.read` 权限流执行。
     - 聊天输入 `/tasks` 会读取 `.agent/manifest.json` 并在聊天里列出专业组、角色、任务状态、产物交接和下一步可执行任务;普通用户仍不暴露任务面板。
    @@ -1294,6 +1294,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
     - 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。
     - 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish`、`art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。
     
    +## 2026-09-04 AGC 项目聊天 Markdown 与流式回复渲染
    +
    +- 项目开发工作台的 `ProjectWorkspaceChatPane`、`ProjectSupervisorView` 与 `SupervisorChatOnlyView` 继续共享 `ChatMessage` / `visibleMessages` 数据结构;聊天 Markdown 只作为前端表现层能力,不新增消息字段、持久化格式、后端 DTO 或事件协议。
    +- 三个视图统一复用 `apps/ai-game-creator-shell/src/components/ChatMarkdownMessage`。assistant 历史消息和流式临时回复使用 `react-markdown + remark-gfm` 渲染;用户消息与命令草稿保持纯文本。调用方仍先执行现有的 `projectSupervisorVisibleConversationText` 安全文案归一化,再交给展示组件。
    +- 流式回复以事件中的 `accumulatedText` 作为当前完整草稿:每次更新替换上一版临时正文,不在渲染层自行拼接 `deltaText`。既有 `runId` / sequence 去重、最终 assistant 持久化和历史回放语义保持不变。
    +- Markdown 禁止原始 HTML;链接和图片只显示普通文本,不产生可点击或可加载的外部资源。后续若开放安全外链,必须另行评估协议白名单、窗口策略和审计边界,并保留实现 TODO。
    +- Markdown 元素样式使用 Tailwind 内联 class,限定在聊天消息组件内部,不改全局 `.message`、资源文档预览或启动器全局 Agent 聊天。组件异常按单条消息回退纯文本,不能使整个聊天面板崩溃。
    +- 流式更新沿用“仅在用户接近底部时跟随”的滚动语义;用户主动查看历史时不得被实时 Markdown 高度变化强制拉回底部。实现验收需覆盖 GFM、未闭合 Markdown 中间态、HTML/链接/图片安全、三视图一致性、流式去重、历史回放和桌面/移动视口。
    +
     ## 2026-08-29 DirectProject 受控联网搜索闭环
     
     - 本次正式产品范围只包含 `DirectProject` 单 Codex Agent;`Provider`、`ToolHost`、`DirectHome` 不新增联网工具桥,也不纳入本次联网路由覆盖。受控联网唯一实现为 `agc_tools.agc_web_search`:Codex app-server 通过审核的 STDIO MCP 工具目录发起调用,客户端 loopback 工具桥执行固定 Bing RSS HTTPS 请求,过滤非 HTTPS、凭据 URL、回环 / 私网 / 本地域名,返回有界标题、摘要和结果链接,并以“不可信网页内容”标签回传。
    
    From d3f5d0a355c24fae031c7fb43980133642d81281 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
    Date: Tue, 8 Sep 2026 22:04:37 +0800
    Subject: [PATCH 3/4] =?UTF-8?q?feat/AGC=20codex=E7=9A=84=E5=B7=A5=E5=85=B7?=
     =?UTF-8?q?=E8=B0=83=E7=94=A8=20=E6=8C=81=E4=B9=85=E5=8C=96=E5=A4=84?=
     =?UTF-8?q?=E7=90=86=20(#282)?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    重构directproject的thread持久化,  保存response items用来恢复thread
    
    修改了DirectProject的对话jsonl格式,  不兼容不迁移. (测试需要手动清理旧对话jsonl文件)
    
    AGC显式写入user msg, 对codex 返回的user msg忽略
    清理原来的上下文滑动窗口, 因为codex内部会自动compact
    
    工具调用后重启,继续对话:
    ![shotmd-1788579616.jpg](/attachments/8350c924-9498-47cd-b3da-8be6b79696f9)
    
    close #249
    close #277
    
    ---------
    
    Co-authored-by: 段舒康 
    Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/282
    Co-authored-by: 王德宇 
    Co-committed-by: 王德宇 
    ---
     .../scripts/check-config.mjs                  |   2 +
     .../src-tauri/src/agent.rs                    |   4 +
     .../src-tauri/src/agent/codex_app_server.rs   | 502 +++++++-----------
     .../src/agent/direct_project_history.rs       | 467 ++++++++++++++++
     .../src/agent/direct_project_turn_history.rs  |  54 ++
     .../src-tauri/src/agent/direct_runtime.rs     | 100 ++--
     .../src-tauri/src/commands.rs                 |  24 +
     .../src-tauri/src/main.rs                     |   2 +
     .../src-tauri/src/project/agent_db.rs         |  10 +-
     apps/ai-game-creator-shell/src/App.tsx        | 327 ++++--------
     .../tests/appSurface/home.suite.ts            |  84 ++-
     docs/README.md                                |   1 +
     .../shared-memory/decision-log.md             |   6 +
     .../shared-memory/document-map.md             |  17 +-
     ...案】AI游戏创作智能体App实施计划-2026-06-24.md |   2 +-
     ...ectProject Codex原始历史与异常恢复-2026-09-04.md |  58 ++
     16 files changed, 1048 insertions(+), 612 deletions(-)
     create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs
     create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs
     create mode 100644 docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
    
    diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
    index 8ef63ae8d..0149d953a 100644
    --- a/apps/ai-game-creator-shell/scripts/check-config.mjs
    +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
    @@ -107,11 +107,13 @@ const rustSharedContractSource = fs.readFileSync(
       'utf8',
     );
     const allowedUncalledTauriCommands = [
    +  'append_direct_project_conversation_message',
       'chat_with_game_creator_agent',
       'check_ui_editor_font_glyph_coverage',
       'create_ui_design_resource',
       'open_game_creator_launcher_window',
       'open_game_creator_workspace_window',
    +  'read_direct_project_conversation',
       'stop_local_game_preview_if_matches',
       'start_game_creator_external_mcp',
       'stop_game_creator_external_mcp',
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
    index bb0f70aee..cc37945ca 100644
    --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs
    +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs
    @@ -14,6 +14,8 @@ mod codex_cli;
     mod codex_provider_proxy;
     mod direct_codex_attachments;
     mod direct_codex_audit;
    +mod direct_project_history;
    +mod direct_project_turn_history;
     mod direct_runtime;
     mod direct_tool_bridge;
     mod direct_tools_mcp;
    @@ -38,6 +40,8 @@ pub(crate) use codex_cli::{
     pub(crate) use codex_provider_proxy::*;
     pub(crate) use direct_codex_attachments::*;
     pub(crate) use direct_codex_audit::*;
    +pub(crate) use direct_project_history::*;
    +pub(crate) use direct_project_turn_history::*;
     pub(crate) use direct_runtime::*;
     pub(crate) use direct_tool_bridge::*;
     pub(crate) use direct_tools_mcp::*;
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
    index 2410c4adf..b31ae894a 100644
    --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
    +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
    @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
     use std::sync::{Arc, OnceLock, Weak};
     use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
     use tokio::sync::{mpsc, oneshot, Mutex, Notify};
    +use uuid::Uuid;
     
     const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
     const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
    @@ -443,13 +444,17 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey {
     
     #[derive(Clone, Debug)]
     enum CodexTurnEvent {
    -    AgentMessageDelta(String),
    +    AgentMessageDelta {
    +        item_id: String,
    +        delta: String,
    +    },
         IntermediateText(String),
         Activity(&'static str),
         Item {
             completed: bool,
             params: serde_json::Value,
         },
    +    RawItem(serde_json::Value),
         Terminal(serde_json::Value),
         TransportClosed(String),
     }
    @@ -869,11 +874,28 @@ fn direct_codex_notification_event(
                 .get("delta")
                 .and_then(serde_json::Value::as_str)
                 .filter(|value| !value.trim().is_empty())
    -            .map(|delta| CodexTurnEvent::AgentMessageDelta(delta.to_string())),
    +            .map(|delta| {
    +                let item_id = params
    +                    .get("itemId")
    +                    .and_then(serde_json::Value::as_str)
    +                    .filter(|value| !value.is_empty())
    +                    .map(str::to_string)
    +                    .unwrap_or_else(|| "direct-missing-item".to_string());
    +                CodexTurnEvent::AgentMessageDelta {
    +                    item_id,
    +                    delta: delta.to_string(),
    +                }
    +            }),
             "item/started" | "item/completed" => Some(CodexTurnEvent::Item {
                 completed: method == "item/completed",
                 params: params.clone(),
             }),
    +        "rawResponseItem/completed" => Some(CodexTurnEvent::RawItem(
    +            params
    +                .get("item")
    +                .cloned()
    +                .unwrap_or(serde_json::Value::Null),
    +        )),
             _ => Some(CodexTurnEvent::Terminal(params.clone())),
         }
     }
    @@ -1312,6 +1334,9 @@ fn codex_app_server_thread_start_params(
             params["modelProvider"] =
                 serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string());
         }
    +    if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
    +        params["experimentalRawEvents"] = serde_json::Value::Bool(true);
    +    }
         params
     }
     
    @@ -1321,6 +1346,7 @@ fn codex_app_server_turn_start_params(
         model: &str,
         workspace_path: &std::path::Path,
         workspace_mode: CodexAppServerWorkspaceMode,
    +    client_user_message_id: Option<&str>,
     ) -> serde_json::Value {
         let approval_policy = "never";
         let mut params = serde_json::json!({
    @@ -1339,6 +1365,13 @@ fn codex_app_server_turn_start_params(
                 "networkAccess": true
             });
         }
    +    if let Some(client_user_message_id) = client_user_message_id
    +        .map(str::trim)
    +        .filter(|value| !value.is_empty())
    +    {
    +        params["clientUserMessageId"] =
    +            serde_json::Value::String(client_user_message_id.to_string());
    +    }
         params
     }
     
    @@ -2551,32 +2584,48 @@ impl CodexAppServerConnection {
             mut audit: Option<&mut DirectCodexTurnAudit>,
         ) -> Result {
             let _turn_guard = self.inner.turn_gate.lock().await;
    +        let mut request = request;
    +        let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
    +        if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
    +            let current_prompt = direct_codex_current_user_prompt(&request).trim();
    +            if current_prompt.is_empty() {
    +                return Err(platform_llm::LlmError::InvalidRequest(
    +                    "DirectProject 用户消息不能为空".to_string(),
    +                ));
    +            }
    +            if let Some(client_turn_id) = direct_client_turn_id {
    +                let user_item = direct_project_local_message_item(
    +                    "user",
    +                    current_prompt,
    +                    Some(&format!("direct-codex:{client_turn_id}:user")),
    +                )
    +                .map_err(platform_llm::LlmError::InvalidRequest)?;
    +                append_direct_project_user_message_at(history_root, &user_item)
    +                    .map_err(platform_llm::LlmError::InvalidRequest)?;
    +            }
    +        }
             let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
             self.wait_for_initial_client_mcp_startup().await;
             let thread_id = thread_lease.thread_id.clone();
    -        let mut request = request;
    -        if thread_created && self.inner.workspace_mode.uses_direct_conversation() {
    -            // DirectProject owns the append-only project history in AGC.  The
    -            // replay builder derives a bounded prompt without mutating that
    -            // durable fact source, so a new ephemeral thread can recover the
    -            // newest contiguous context within the model budget.
    -            let current_prompt = direct_codex_current_user_prompt(&request).to_string();
    -            let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
    -            let history_prompt = build_direct_codex_history_prompt(
    -                history_root,
    -                direct_client_turn_id.unwrap_or("__none__"),
    -                ¤t_prompt,
    -                &request,
    -                llm,
    -            )
    -            .map_err(platform_llm::LlmError::InvalidRequest)?;
    -            if let Some(message) = request
    -                .messages
    -                .iter_mut()
    -                .rev()
    -                .find(|message| message.role == LlmMessageRole::User)
    -            {
    -                message.content = history_prompt;
    +        if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
    +            if thread_created {
    +                let items = match read_direct_project_history_items_at(history_root) {
    +                    Ok(items) => items,
    +                    Err(error) => {
    +                        self.release_thread(snapshot, &thread_id).await;
    +                        return Err(platform_llm::LlmError::InvalidRequest(error));
    +                    }
    +                };
    +                if let Err(error) = self
    +                    .request(
    +                        "thread/inject_items",
    +                        serde_json::json!({"threadId": thread_id, "items": items}),
    +                    )
    +                    .await
    +                {
    +                    self.release_thread(snapshot, &thread_id).await;
    +                    return Err(platform_llm::LlmError::Transport(error));
    +                }
                 }
             }
             let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
    @@ -2615,6 +2664,7 @@ impl CodexAppServerConnection {
                 model,
                 &self.inner.workspace_path,
                 self.inner.workspace_mode,
    +            direct_client_turn_id,
             );
             apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request);
             if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) {
    @@ -2659,6 +2709,7 @@ impl CodexAppServerConnection {
             };
             turn_start_guard.armed = false;
             let mut receiver = self.register_turn(&turn_id).await;
    +        let mut direct_project_history = DirectProjectHistoryAccumulator::default();
             let mut guard = CodexTurnGuard {
                 connection: self.clone(),
                 thread_id: thread_id.clone(),
    @@ -2705,7 +2756,10 @@ impl CodexAppServerConnection {
                         }
                     };
                     match event {
    -                    Some(CodexTurnEvent::AgentMessageDelta(delta)) => {
    +                    Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => {
    +                        if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
    +                            direct_project_history.observe_delta(&item_id, &delta);
    +                        }
                             streamed_text.push_str(&delta);
                             if let Some(observer) = direct_observer.as_deref_mut() {
                                 observer(DirectCodexTurnObservation::AccumulatedText(
    @@ -2725,6 +2779,28 @@ impl CodexAppServerConnection {
                                 observer(DirectCodexTurnObservation::IntermediateText(text));
                             }
                         }
    +                    Some(CodexTurnEvent::RawItem(item)) => {
    +                        if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
    +                            if item.is_null() {
    +                                return Err(platform_llm::LlmError::Deserialize(
    +                                    "rawResponseItem/completed 缺少 item".to_string(),
    +                                ));
    +                            }
    +                            let history_root = history_root.to_path_buf();
    +                            let history_item = item.clone();
    +                            tokio::task::spawn_blocking(move || {
    +                                append_direct_project_history_item_at(&history_root, &history_item)
    +                            })
    +                            .await
    +                            .map_err(|error| {
    +                                platform_llm::LlmError::Transport(format!(
    +                                    "DirectProject 历史落盘任务失败:{error}"
    +                                ))
    +                            })?
    +                            .map_err(platform_llm::LlmError::InvalidRequest)?;
    +                            direct_project_history.complete_item(&item);
    +                        }
    +                    }
                         Some(CodexTurnEvent::Activity(activity)) => {
                             if let Some(observer) = direct_observer.as_deref_mut() {
                                 observer(DirectCodexTurnObservation::Activity(activity));
    @@ -2843,7 +2919,22 @@ impl CodexAppServerConnection {
                     }
                 }
             };
    -        let text = collect.await?;
    +        let text = match collect.await {
    +            Ok(text) => text,
    +            Err(error) => {
    +                if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
    +                    if let Err(persist_error) = persist_direct_project_partial_items_at(
    +                        history_root,
    +                        &mut direct_project_history,
    +                    ) {
    +                        return Err(platform_llm::LlmError::Transport(format!(
    +                            "DirectProject 异常收尾历史失败:{persist_error};原始回合错误:{error}"
    +                        )));
    +                    }
    +                }
    +                return Err(error);
    +            }
    +        };
             guard.armed = false;
             self.inner.turns.lock().await.remove(&turn_id);
             let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?;
    @@ -3220,7 +3311,11 @@ async fn read_game_creator_codex_app_server_stdout(
             );
             if !matches!(
                 method,
    -            "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed"
    +            "item/agentMessage/delta"
    +                | "item/started"
    +                | "item/completed"
    +                | "rawResponseItem/completed"
    +                | "turn/completed"
             ) && safe_activity.is_none()
                 && intermediate_text.is_none()
             {
    @@ -3258,14 +3353,57 @@ async fn read_game_creator_codex_app_server_stdout(
                     continue;
                 }
             }
    -        let event = match direct_codex_notification_event(
    -            method,
    -            ¶ms,
    -            intermediate_text,
    -            safe_activity,
    -        ) {
    -            Some(event) => event,
    -            None => continue,
    +        let event = if let Some(activity) = safe_activity {
    +            // Preparing notifications may carry private plan/reasoning text;
    +            // expose only the safe activity category. Other categories may
    +            // retain their bounded, redacted intermediate text below.
    +            if activity == "preparing" {
    +                CodexTurnEvent::Activity(activity)
    +            } else if let Some(text) = intermediate_text {
    +                CodexTurnEvent::IntermediateText(text)
    +            } else {
    +                CodexTurnEvent::Activity(activity)
    +            }
    +        } else if let Some(text) = intermediate_text {
    +            CodexTurnEvent::IntermediateText(text)
    +        } else {
    +            match method {
    +                "item/agentMessage/delta" => {
    +                    let Some(delta) = params
    +                        .get("delta")
    +                        .and_then(serde_json::Value::as_str)
    +                        .filter(|value| !value.is_empty())
    +                    else {
    +                        continue;
    +                    };
    +                    let item_id = params
    +                        .get("itemId")
    +                        .and_then(serde_json::Value::as_str)
    +                        .filter(|value| !value.is_empty())
    +                        .map(str::to_string)
    +                        .unwrap_or_else(|| {
    +                            eprintln!(
    +                                "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id"
    +                            );
    +                            format!("direct-missing-item:{turn_id}")
    +                        });
    +                    CodexTurnEvent::AgentMessageDelta {
    +                        item_id,
    +                        delta: delta.to_string(),
    +                    }
    +                }
    +                "item/started" | "item/completed" => CodexTurnEvent::Item {
    +                    completed: method == "item/completed",
    +                    params,
    +                },
    +                "rawResponseItem/completed" => CodexTurnEvent::RawItem(
    +                    params
    +                        .get("item")
    +                        .cloned()
    +                        .unwrap_or(serde_json::Value::Null),
    +                ),
    +                _ => CodexTurnEvent::Terminal(params),
    +            }
             };
             let sender = if method == "turn/completed" {
                 last_direct_activity_by_turn.remove(&turn_id);
    @@ -3657,13 +3795,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
             .with_model(config.llm.model.clone())
             .with_request_timeout_ms(config.llm.request_timeout_ms)
             .with_max_output_tokens(16_000);
    +    let generated_client_turn_id;
    +    let effective_client_turn_id = match client_turn_id {
    +        Some(client_turn_id) => Some(client_turn_id),
    +        None => {
    +            generated_client_turn_id = format!("direct-cli-{}", Uuid::new_v4());
    +            Some(generated_client_turn_id.as_str())
    +        }
    +    };
         connection
             .run_turn_with_direct_observer_and_history(
                 &snapshot,
                 &config.llm,
                 request,
                 Some(&codex_root),
    -            client_turn_id,
    +            effective_client_turn_id,
                 None,
                 observer,
                 audit,
    @@ -3673,155 +3819,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
             .map_err(|error| error.to_string())
     }
     
    -/// Builds the prompt used when a new DirectProject thread needs replay.
    -///
    -/// The JSONL history remains immutable; only the derived prompt is bounded.
    -/// When the full request exceeds the replay target, the oldest contiguous
    -/// records are omitted and a plain `system:` marker is prepended.
    -pub(crate) fn build_direct_codex_history_prompt(
    -    root: &std::path::Path,
    -    client_turn_id: &str,
    -    current_prompt: &str,
    -    base_request: &LlmRunRequest,
    -    llm: &GameCreatorLlmConfig,
    -) -> Result {
    -    let conversation = read_local_conversation_for_session_at(root, None, None)?;
    -    let current_message_id = format!("direct-codex:{client_turn_id}:user");
    -    // The deliberately simple role-prefix format is part of the Direct
    -    // replay contract.  Do not introduce an envelope or implicit escaping
    -    // here without updating the persisted-history compatibility decision.
    -    let lines = conversation
    -        .messages
    -        .iter()
    -        .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str()))
    -        .map(|message| format!("{}: {}", message.role, message.content))
    -        .collect::>();
    -    let current_line = format!("user: {}", current_prompt.trim());
    -    let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None);
    -    let target_budget = direct_codex_replay_target_budget(llm, base_request)?;
    -    if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? {
    -        return Ok(full_prompt);
    -    }
    -
    -    // The persisted project conversation is immutable.  We only derive a
    -    // bounded prompt for this replay, keeping the newest contiguous records.
    -    let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER;
    -    let mut selected_reversed = Vec::new();
    -    for line in lines.iter().rev() {
    -        let mut candidate_reversed = selected_reversed.clone();
    -        candidate_reversed.push(line.as_str());
    -        let candidate_lines = candidate_reversed.iter().rev().copied().collect::>();
    -        let candidate_prompt = format_direct_codex_replay_prompt(
    -            &candidate_lines,
    -            ¤t_line,
    -            Some(omission_marker),
    -        );
    -        if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? {
    -            selected_reversed.push(line.as_str());
    -        } else {
    -            break;
    -        }
    -    }
    -
    -    let selected_lines = selected_reversed.iter().rev().copied().collect::>();
    -    let marked_prompt =
    -        format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker));
    -    if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? {
    -        return Ok(marked_prompt);
    -    }
    -
    -    // If the marker itself would push the request over the target, preserve
    -    // the current user request and omit only the marker.
    -    let current_only_prompt =
    -        format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None);
    -    if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? {
    -        return Ok(current_only_prompt);
    -    }
    -    direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt)
    -}
    -
    -const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str =
    -    "system: Earlier conversation history was omitted due to context budget.";
    -
    -fn format_direct_codex_replay_prompt(
    -    history_lines: &[impl AsRef],
    -    current_line: &str,
    -    omission_marker: Option<&str>,
    -) -> String {
    -    let mut lines = Vec::with_capacity(history_lines.len() + 2);
    -    if let Some(marker) = omission_marker {
    -        lines.push(marker.to_string());
    -    }
    -    lines.extend(history_lines.iter().map(|line| line.as_ref().to_string()));
    -    lines.push(current_line.to_string());
    -    lines.join("\n")
    -}
    -
    -fn direct_codex_replay_target_budget(
    -    llm: &GameCreatorLlmConfig,
    -    request: &LlmRunRequest,
    -) -> Result {
    -    const SAFETY_MARGIN_TOKENS: u64 = 4_096;
    -    let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0));
    -    let hard_budget = llm
    -        .context_window_tokens
    -        .checked_sub(max_output_tokens)
    -        .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS))
    -        .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?;
    -    Ok(llm.auto_compact_token_limit.min(hard_budget))
    -}
    -
    -fn direct_codex_replay_estimate(
    -    base_request: &LlmRunRequest,
    -    prompt: &str,
    -) -> Result {
    -    let mut request = base_request.clone();
    -    let user = request
    -        .messages
    -        .iter_mut()
    -        .rev()
    -        .find(|message| message.role == LlmMessageRole::User)
    -        .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?;
    -    user.content = prompt.to_string();
    -    Ok(request)
    -}
    -
    -fn direct_codex_replay_prompt_fits(
    -    base_request: &LlmRunRequest,
    -    llm: &GameCreatorLlmConfig,
    -    prompt: &str,
    -    target_budget: u64,
    -) -> Result {
    -    let request = direct_codex_replay_estimate(base_request, prompt)?;
    -    let estimated = estimate_game_creator_llm_request_tokens(&request)?;
    -    if estimated > target_budget {
    -        return Ok(false);
    -    }
    -    validate_game_creator_llm_request_context_budget(
    -        llm,
    -        &request,
    -        estimated,
    -        "Direct replay 请求",
    -    )?;
    -    Ok(true)
    -}
    -
    -fn direct_codex_replay_validate_context_budget(
    -    base_request: &LlmRunRequest,
    -    llm: &GameCreatorLlmConfig,
    -    prompt: &str,
    -) -> Result {
    -    let request = direct_codex_replay_estimate(base_request, prompt)?;
    -    let estimated = estimate_game_creator_llm_request_tokens(&request)?;
    -    validate_game_creator_llm_request_context_budget(
    -        llm,
    -        &request,
    -        estimated,
    -        "Direct replay 请求",
    -    )?;
    -    Ok(prompt.to_string())
    -}
    -
     /// Direct home-page chat never binds Codex to a user project.  It gets a
     /// fresh isolated read-only workspace and a stable in-process thread so a
     /// normal conversation can continue without creating a project, assets, a
    @@ -3903,123 +3900,27 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
         })
     }
     
    +#[cfg(test)]
    +pub(crate) fn build_direct_codex_history_prompt(
    +    root: &Path,
    +    _client_turn_id: &str,
    +    current_prompt: &str,
    +    _request: &LlmRunRequest,
    +    _llm: &GameCreatorLlmConfig,
    +) -> Result {
    +    let mut lines = read_direct_project_chat_history_at(root)?
    +        .messages
    +        .into_iter()
    +        .map(|message| format!("{}: {}", message.role, message.content))
    +        .collect::>();
    +    lines.push(format!("user: {}", current_prompt.trim()));
    +    Ok(lines.join("\n"))
    +}
    +
     #[cfg(test)]
     mod tests {
         use super::*;
     
    -    #[test]
    -    fn direct_history_prompt_replays_all_project_messages_in_order() {
    -        let root = tempfile::tempdir().expect("temp dir");
    -        init_local_game_project_at(root.path(), "history-project", "history").expect("init");
    -        for (role, content, message_id) in [
    -            ("user", "hello", "direct-codex:old:user"),
    -            (
    -                "assistant",
    -                "partial\nunexpected interrupt happened here",
    -                "partial-id",
    -            ),
    -            ("tool", "file-read result", "tool-id"),
    -            ("user", "stored raw request", "direct-codex:new-turn:user"),
    -        ] {
    -            append_local_conversation_message_for_session_idempotent_at(
    -                root.path(),
    -                None,
    -                None,
    -                LocalConversationMessage {
    -                    role: role.to_string(),
    -                    content: content.to_string(),
    -                    agent_id: None,
    -                },
    -                message_id,
    -            )
    -            .expect("append history");
    -        }
    -        let request = LlmRunRequest::single_turn("system", "new request")
    -            .with_model("fixture-model")
    -            .with_max_output_tokens(16_000);
    -        let prompt = build_direct_codex_history_prompt(
    -            root.path(),
    -            "new-turn",
    -            "new request",
    -            &request,
    -            &test_llm(),
    -        )
    -        .expect("build prompt");
    -        assert_eq!(
    -            prompt,
    -            "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request"
    -        );
    -    }
    -
    -    #[test]
    -    fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() {
    -        let root = tempfile::tempdir().expect("temp dir");
    -        init_local_game_project_at(root.path(), "window-project", "window").expect("init");
    -        for (role, content, message_id) in [
    -            ("user", "old ".repeat(1_000), "old-user"),
    -            ("assistant", "middle ".repeat(100), "middle-assistant"),
    -            ("tool", "newest ".repeat(100), "newest-tool"),
    -        ] {
    -            append_local_conversation_message_for_session_idempotent_at(
    -                root.path(),
    -                None,
    -                None,
    -                LocalConversationMessage {
    -                    role: role.to_string(),
    -                    content,
    -                    agent_id: None,
    -                },
    -                message_id,
    -            )
    -            .expect("append history");
    -        }
    -        let mut llm = test_llm();
    -        llm.auto_compact_token_limit = 800;
    -        let request = LlmRunRequest::single_turn("system", "new request")
    -            .with_model("fixture-model")
    -            .with_max_output_tokens(16_000);
    -        let before =
    -            std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
    -                .expect("read history");
    -        let prompt = build_direct_codex_history_prompt(
    -            root.path(),
    -            "new-turn",
    -            "new request",
    -            &request,
    -            &llm,
    -        )
    -        .expect("build prompt");
    -        let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
    -            .expect("read history");
    -
    -        assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER));
    -        assert!(prompt.contains("tool: newest"));
    -        assert!(!prompt.contains("user: old"));
    -        assert!(prompt.ends_with("user: new request"));
    -        assert_eq!(before, after);
    -    }
    -
    -    #[test]
    -    fn direct_history_prompt_rejects_current_request_that_exceeds_context() {
    -        let root = tempfile::tempdir().expect("temp dir");
    -        init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init");
    -        let mut llm = test_llm();
    -        llm.context_window_tokens = 5_000;
    -        llm.auto_compact_token_limit = 1_000;
    -        let request = LlmRunRequest::single_turn("system", "new request")
    -            .with_model("fixture-model")
    -            .with_max_output_tokens(100);
    -        let error = build_direct_codex_history_prompt(
    -            root.path(),
    -            "new-turn",
    -            &"request ".repeat(10_000),
    -            &request,
    -            &llm,
    -        )
    -        .expect_err("oversized request should fail");
    -        assert!(error.contains("Direct replay 请求"));
    -    }
    -
         #[test]
         fn direct_item_activities_are_closed_safe_categories() {
             let allowed = [
    @@ -4459,6 +4360,7 @@ mod tests {
                 "fixture-model",
                 workspace,
                 CodexAppServerWorkspaceMode::DirectHome,
    +            None,
             );
             assert_eq!(turn["threadId"], "home-thread");
             assert_eq!(turn["approvalPolicy"], "never");
    @@ -4519,7 +4421,9 @@ mod tests {
                 "fixture-model",
                 &workspace,
                 CodexAppServerWorkspaceMode::DirectProject,
    +            Some("direct-turn-0001"),
             );
    +        assert_eq!(turn["clientUserMessageId"], "direct-turn-0001");
             assert_eq!(
                 turn.pointer("/sandboxPolicy/writableRoots/0"),
                 Some(&serde_json::json!(workspace))
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs
    new file mode 100644
    index 000000000..42777dcfa
    --- /dev/null
    +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs
    @@ -0,0 +1,467 @@
    +use crate::config::prepare_game_creator_private_path_for_read;
    +use crate::project::{
    +    append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
    +};
    +use crate::{LocalConversationMessageRecord, LocalConversationResult};
    +use serde_json::Value;
    +use std::fs::File;
    +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
    +use std::path::{Path, PathBuf};
    +
    +const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
    +const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
    +    "host_skills.instructions",
    +    "permissions.instructions",
    +    "environments.environment_context",
    +];
    +
    +const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
    +    ("# AGENTS.md instructions", ""),
    +    ("", ""),
    +    ("", ""),
    +    ("", ""),
    +    ("", ""),
    +    ("", ""),
    +    ("", ""),
    +    ("", ""),
    +];
    +
    +fn is_known_contextual_user_text(text: &str) -> bool {
    +    let text = text.trim();
    +    if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
    +        .iter()
    +        .any(|(start, end)| text.starts_with(start) && text.ends_with(end))
    +    {
    +        return true;
    +    }
    +    if text.starts_with("") {
    +        return true;
    +    }
    +    text.starts_with("')
    +            .and_then(|(start, _)| start.strip_prefix("")))
    +}
    +
    +pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
    +    if matches!(
    +        item.get("role").and_then(Value::as_str),
    +        Some("developer" | "system")
    +    ) {
    +        return true;
    +    }
    +    if item
    +        .pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
    +        .and_then(Value::as_array)
    +        .is_some_and(|kinds| {
    +            kinds
    +                .iter()
    +                .filter_map(Value::as_str)
    +                .any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
    +        })
    +    {
    +        return true;
    +    }
    +    item.get("content")
    +        .and_then(Value::as_array)
    +        .is_some_and(|parts| {
    +            parts.iter().any(|part| {
    +                let Some(text) = part.get("text").and_then(Value::as_str) else {
    +                    return false;
    +                };
    +                is_known_contextual_user_text(text)
    +            })
    +        })
    +}
    +
    +fn history_path(root: &Path) -> PathBuf {
    +    root.join(".agent/conversations/project.jsonl")
    +}
    +
    +fn record(item: &Value) -> Result {
    +    serde_json::to_string(&serde_json::json!({
    +        "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
    +        "payload": item,
    +    }))
    +    .map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
    +}
    +
    +const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
    +
    +fn find_direct_project_history_item_by_id_at(
    +    path: &Path,
    +    item_id: &str,
    +) -> Result, String> {
    +    let mut file = File::open(path)
    +        .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
    +    let mut position = file
    +        .metadata()
    +        .map_err(|error| {
    +            format!(
    +                "读取 DirectProject 历史元数据失败:{}: {error}",
    +                path.display()
    +            )
    +        })?
    +        .len();
    +    let mut pending = Vec::new();
    +    let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
    +
    +    loop {
    +        if position == 0 {
    +            break;
    +        }
    +        let read_len = usize::try_from(position)
    +            .unwrap_or(usize::MAX)
    +            .min(chunk.len());
    +        position -= read_len as u64;
    +        file.seek(SeekFrom::Start(position))
    +            .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
    +        file.read_exact(&mut chunk[..read_len])
    +            .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
    +
    +        let mut combined = Vec::with_capacity(read_len + pending.len());
    +        combined.extend_from_slice(&chunk[..read_len]);
    +        combined.extend_from_slice(&pending);
    +        let mut line_end = combined.len();
    +        while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
    +            let line = &combined[newline + 1..line_end];
    +            if !line.is_empty() {
    +                if let Some(item) = direct_project_history_item_from_line(path, line)? {
    +                    if item.get("id").and_then(Value::as_str) == Some(item_id) {
    +                        return Ok(Some(item));
    +                    }
    +                }
    +            }
    +            line_end = newline;
    +        }
    +        pending = combined[..line_end].to_vec();
    +    }
    +
    +    if !pending.is_empty() {
    +        // The append path repairs an unterminated final JSONL record before
    +        // writing. A duplicate scan must not reject that repairable tail.
    +        if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
    +            if item.get("id").and_then(Value::as_str) == Some(item_id) {
    +                return Ok(Some(item));
    +            }
    +        }
    +    }
    +    Ok(None)
    +}
    +
    +fn direct_project_history_item_from_line(
    +    path: &Path,
    +    line: &[u8],
    +) -> Result, String> {
    +    if line.iter().all(|byte| byte.is_ascii_whitespace()) {
    +        return Ok(None);
    +    }
    +    let parsed: Value = serde_json::from_slice(line)
    +        .map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
    +    if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
    +        return Err(format!(
    +            "DirectProject 历史记录类型无效:{}",
    +            path.display()
    +        ));
    +    }
    +    let item = parsed
    +        .get("payload")
    +        .cloned()
    +        .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
    +    if is_direct_project_internal_context_item(&item) {
    +        return Ok(None);
    +    }
    +    Ok(Some(item))
    +}
    +
    +pub(crate) fn append_direct_project_history_item_at(
    +    root: &Path,
    +    item: &Value,
    +) -> Result<(), String> {
    +    append_direct_project_history_item_at_with_user_policy(root, item, false)
    +}
    +
    +/// Appends a user message authored by AGC itself.  Codex response items use
    +/// the default path above, which deliberately ignores echoed user messages;
    +/// this explicit entry point keeps the two sources distinct.
    +pub(crate) fn append_direct_project_user_message_at(
    +    root: &Path,
    +    item: &Value,
    +) -> Result<(), String> {
    +    append_direct_project_history_item_at_with_user_policy(root, item, true)
    +}
    +
    +fn append_direct_project_history_item_at_with_user_policy(
    +    root: &Path,
    +    item: &Value,
    +    allow_user_item: bool,
    +) -> Result<(), String> {
    +    enforce_project_permission_policy(root, "conversation.write")?;
    +    if is_direct_project_internal_context_item(item) {
    +        return Ok(());
    +    }
    +    if !allow_user_item && is_direct_project_codex_user_item(item) {
    +        return Ok(());
    +    }
    +    let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
    +    let path = history_path(root);
    +    let history_exists =
    +        prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
    +    let lock = project_append_lock_for(&path)?;
    +    let _append_guard = lock.lock("DirectProject 历史追加写")?;
    +    if history_exists {
    +        if let Some(item_id) = item.get("id").and_then(Value::as_str) {
    +            if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
    +                if &existing == item {
    +                    return Ok(());
    +                }
    +                return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
    +            }
    +        }
    +    }
    +    let line = record(item)?;
    +    append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
    +}
    +
    +fn is_direct_project_codex_user_item(item: &Value) -> bool {
    +    if item.get("type").and_then(Value::as_str) == Some("userMessage") {
    +        return true;
    +    }
    +    if item.get("role").and_then(Value::as_str) != Some("user") {
    +        return false;
    +    }
    +    !item
    +        .get("id")
    +        .and_then(Value::as_str)
    +        .is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
    +}
    +
    +pub(crate) fn direct_project_local_message_item(
    +    role: &str,
    +    content: &str,
    +    message_id: Option<&str>,
    +) -> Result {
    +    let role = role.trim();
    +    let content = content.trim();
    +    if content.is_empty() || !matches!(role, "user" | "assistant") {
    +        return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
    +    }
    +    let mut item = serde_json::json!({
    +        "type": "message",
    +        "role": role,
    +        "content": [{
    +            "type": if role == "user" { "input_text" } else { "output_text" },
    +            "text": content,
    +        }],
    +    });
    +    if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
    +        item["id"] = Value::String(message_id.to_string());
    +    }
    +    Ok(item)
    +}
    +
    +pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> {
    +    let path = history_path(root);
    +    if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
    +        return Ok(Vec::new());
    +    }
    +    let file = File::open(&path)
    +        .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
    +    let mut items = Vec::new();
    +    let mut reader = BufReader::new(file);
    +    loop {
    +        let mut line = String::new();
    +        let bytes = reader
    +            .read_line(&mut line)
    +            .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
    +        if bytes == 0 {
    +            break;
    +        }
    +        let had_newline = line.ends_with('\n');
    +        let trimmed = line.trim();
    +        if trimmed.is_empty() {
    +            continue;
    +        }
    +        let parsed: Value = match serde_json::from_str(trimmed) {
    +            Ok(value) => value,
    +            Err(_error) if !had_newline => break,
    +            Err(error) => {
    +                return Err(format!(
    +                    "解析 DirectProject 历史失败:{}: {error}",
    +                    path.display()
    +                ));
    +            }
    +        };
    +        if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
    +            return Err(format!(
    +                "DirectProject 历史记录类型无效:{}",
    +                path.display()
    +            ));
    +        }
    +        let item = parsed
    +            .get("payload")
    +            .cloned()
    +            .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
    +        if is_direct_project_internal_context_item(&item) {
    +            continue;
    +        }
    +        items.push(item);
    +    }
    +    Ok(items)
    +}
    +
    +pub(crate) fn read_direct_project_chat_history_at(
    +    root: &Path,
    +) -> Result {
    +    let path = history_path(root);
    +    let items = read_direct_project_history_items_at(root)?;
    +    let messages = items
    +        .into_iter()
    +        .filter_map(|item| {
    +            let role = item.get("role").and_then(Value::as_str)?;
    +            if !matches!(role, "user" | "assistant") {
    +                return None;
    +            }
    +            let content = item
    +                .get("content")
    +                .and_then(Value::as_array)
    +                .map(|parts| {
    +                    parts
    +                        .iter()
    +                        .filter_map(|part| part.get("text").and_then(Value::as_str))
    +                        .collect::>()
    +                        .join("")
    +                })
    +                .unwrap_or_default();
    +            (!content.is_empty()).then(|| LocalConversationMessageRecord {
    +                schema_version: "agc-direct-project-context.v1".to_string(),
    +                role: role.to_string(),
    +                content,
    +                agent_id: None,
    +                message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
    +                updated_at: 0,
    +            })
    +        })
    +        .collect();
    +    Ok(LocalConversationResult {
    +        path: path.to_string_lossy().into_owned(),
    +        agent_id: None,
    +        session_id: None,
    +        messages,
    +    })
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::{
    +        append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
    +        is_direct_project_internal_context_item, read_direct_project_history_items_at,
    +    };
    +    use serde_json::json;
    +
    +    #[test]
    +    fn filters_host_context_but_keeps_real_user_items() {
    +        assert!(is_direct_project_internal_context_item(&json!({
    +            "type": "message",
    +            "role": "developer",
    +            "content": [{"type": "input_text", "text": ""}]
    +        })));
    +        assert!(is_direct_project_internal_context_item(&json!({
    +            "type": "message",
    +            "role": "user",
    +            "content": [{"type": "input_text", "text": ""}],
    +            "internal_chat_message_metadata_passthrough": {
    +                "content_item_kinds": ["environments.environment_context"]
    +            }
    +        })));
    +        assert!(!is_direct_project_internal_context_item(&json!({
    +            "type": "message",
    +            "role": "user",
    +            "content": [{"type": "input_text", "text": "ls ui"}],
    +            "internal_chat_message_metadata_passthrough": {
    +                "content_item_kinds": ["user.text"]
    +            }
    +        })));
    +    }
    +
    +    #[test]
    +    fn filters_codex_contextual_user_item_without_passthrough_metadata() {
    +        assert!(is_direct_project_internal_context_item(&json!({
    +            "type": "message",
    +            "role": "user",
    +            "content": [{
    +                "type": "input_text",
    +                "text": "\n/tmp/project\n"
    +            }]
    +        })));
    +        assert!(!is_direct_project_internal_context_item(&json!({
    +            "type": "message",
    +            "role": "user",
    +            "content": [{
    +                "type": "input_text",
    +                "text": "请读取  中的说明"
    +            }]
    +        })));
    +    }
    +
    +    #[test]
    +    fn append_repairs_truncated_tail_before_idempotency_scan() {
    +        let root = tempfile::tempdir().expect("temp project");
    +        crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
    +            .expect("init project");
    +        let path = history_path(root.path());
    +        std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
    +        std::fs::write(
    +            &path,
    +            br#"{"type":"response_item","payload":{"type":"message"}"#,
    +        )
    +        .expect("write truncated history tail");
    +        let item = serde_json::json!({
    +            "type": "message",
    +            "role": "assistant",
    +            "id": "tail-repair-item",
    +            "content": [{"type": "output_text", "text": "已修复"}]
    +        });
    +        append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
    +        let items =
    +            read_direct_project_history_items_at(root.path()).expect("read repaired history");
    +        assert_eq!(items, vec![item]);
    +    }
    +
    +    #[test]
    +    fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
    +        let root = tempfile::tempdir().expect("temp project");
    +        crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
    +            .expect("init project");
    +        let user = serde_json::json!({
    +            "type": "message",
    +            "role": "user",
    +            "id": "direct-codex:turn-0001:user",
    +            "content": [{"type": "input_text", "text": "请创建菜单"}]
    +        });
    +        append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
    +        append_direct_project_history_item_at(
    +            root.path(),
    +            &serde_json::json!({
    +                "type": "userMessage",
    +                "id": "codex-user-item-1",
    +                "clientId": "turn-0001",
    +                "content": [{"type": "text", "text": "请创建菜单"}]
    +            }),
    +        )
    +        .expect("ignore Codex echo");
    +        append_direct_project_history_item_at(
    +            root.path(),
    +            &serde_json::json!({
    +                "type": "message",
    +                "role": "user",
    +                "id": "codex-raw-user-item-1",
    +                "content": [{"type": "input_text", "text": "请创建菜单"}]
    +            }),
    +        )
    +        .expect("ignore raw Codex user echo");
    +        let items = read_direct_project_history_items_at(root.path()).expect("read history");
    +        assert_eq!(items, vec![user]);
    +    }
    +}
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs
    new file mode 100644
    index 000000000..c2c317692
    --- /dev/null
    +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs
    @@ -0,0 +1,54 @@
    +use super::direct_project_history::append_direct_project_history_item_at;
    +use serde_json::Value;
    +use std::collections::BTreeMap;
    +use std::path::Path;
    +
    +#[derive(Default)]
    +pub(crate) struct DirectProjectHistoryAccumulator {
    +    text_by_item_id: BTreeMap,
    +}
    +
    +impl DirectProjectHistoryAccumulator {
    +    pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
    +        self.text_by_item_id
    +            .entry(item_id.to_string())
    +            .or_default()
    +            .push_str(delta);
    +    }
    +
    +    pub(crate) fn complete_item(&mut self, item: &Value) {
    +        if let Some(item_id) = item.get("id").and_then(Value::as_str) {
    +            self.text_by_item_id.remove(item_id);
    +        }
    +    }
    +
    +    fn take_partial_items(&mut self) -> impl Iterator + '_ {
    +        std::mem::take(&mut self.text_by_item_id)
    +            .into_iter()
    +            .filter(|(_, text)| !text.is_empty())
    +            .map(|(item_id, text)| {
    +                serde_json::json!({
    +                    "type": "message",
    +                    "role": "assistant",
    +                    "id": item_id,
    +                    "content": [{"type": "output_text", "text": text}],
    +                })
    +            })
    +    }
    +}
    +
    +pub(crate) fn persist_direct_project_partial_items_at(
    +    root: &Path,
    +    accumulator: &mut DirectProjectHistoryAccumulator,
    +) -> Result<(), String> {
    +    let items = accumulator.take_partial_items().collect::>();
    +    let mut first_error = None;
    +    for item in items {
    +        if let Err(error) = append_direct_project_history_item_at(root, &item) {
    +            if first_error.is_none() {
    +                first_error = Some(error);
    +            }
    +        }
    +    }
    +    first_error.map_or(Ok(()), Err)
    +}
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs
    index 5498335b2..e7974ba25 100644
    --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs
    +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs
    @@ -4407,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result Result<(), String> {
    -    enforce_project_permission_policy(root, "conversation.write")?;
    -    let _lock = acquire_project_write_lock(root, "conversation.write")?;
    -    append_local_conversation_message_for_session_idempotent_at(
    -        root,
    -        None,
    -        None,
    -        LocalConversationMessage {
    -            role: "assistant".to_string(),
    -            content: reply.to_string(),
    -            agent_id: None,
    -        },
    -        &format!("direct-codex:{client_turn_id}:assistant"),
    -    )
    -    .map(|_| ())
    -}
    -
    -fn persist_direct_codex_user_prompt_at(
    -    root: &Path,
    -    client_turn_id: &str,
    -    prompt: &str,
    -) -> Result<(), String> {
    -    enforce_project_permission_policy(root, "conversation.write")?;
    -    let _lock = acquire_project_write_lock(root, "conversation.write")?;
    -    append_local_conversation_message_for_session_idempotent_at(
    -        root,
    -        None,
    -        None,
    -        LocalConversationMessage {
    -            role: "user".to_string(),
    -            content: prompt.trim().to_string(),
    -            agent_id: None,
    -        },
    -        &format!("direct-codex:{client_turn_id}:user"),
    -    )
    -    .map(|_| ())
    -}
    -
     #[tauri::command]
     pub(crate) async fn chat_with_game_creator_direct_codex(
         project_path: String,
    @@ -4480,15 +4438,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
                 return Err(error);
             }
         };
    -    if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
    -        audit.finish(false);
    -        turn_emitter.emit("failed", Some("none"), None);
    -        return Err(redact_agent_runtime_error(
    -            root,
    -            &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
    -            500,
    -        ));
    -    }
         let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
             root,
             &user_prompt,
    @@ -4504,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
                 return Err(error);
             }
         };
    -    if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
    -        audit.finish(false);
    -        turn_emitter.emit("failed", Some("none"), None);
    -        return Err(redact_agent_runtime_error(
    -            root,
    -            &format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
    -            500,
    -        ));
    -    }
         audit.finish(true);
         turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
         Ok(reply)
    @@ -4526,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
         run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
     }
     
    +#[cfg(test)]
    +fn persist_direct_codex_user_prompt_at(
    +    root: &Path,
    +    client_turn_id: &str,
    +    prompt: &str,
    +) -> Result<(), String> {
    +    let item = direct_project_local_message_item(
    +        "user",
    +        prompt,
    +        Some(&format!("direct-codex:{client_turn_id}:user")),
    +    )?;
    +    append_direct_project_user_message_at(root, &item)
    +}
    +
    +#[cfg(test)]
    +fn persist_direct_codex_assistant_reply_at(
    +    root: &Path,
    +    client_turn_id: &str,
    +    reply: &str,
    +) -> Result<(), String> {
    +    let item = direct_project_local_message_item(
    +        "assistant",
    +        reply,
    +        Some(&format!("direct-codex:{client_turn_id}:assistant")),
    +    )?;
    +    append_direct_project_history_item_at(root, &item)
    +}
    +
     #[cfg(test)]
     mod tests {
         use super::*;
    @@ -4610,8 +4578,8 @@ mod tests {
             persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
                 .expect("the App's same-id write converges idempotently");
     
    -        let conversation = read_local_conversation_for_session_at(root.path(), None, None)
    -            .expect("read project conversation");
    +        let conversation =
    +            read_direct_project_chat_history_at(root.path()).expect("read project conversation");
             let message_id = format!("direct-codex:{turn_id}:assistant");
             let persisted = conversation
                 .messages
    @@ -4624,7 +4592,7 @@ mod tests {
             assert!(
                 persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
                     .expect_err("same message identity cannot be rebound")
    -                .contains("messageId 冲突")
    +                .contains("item id 冲突")
             );
         }
     
    @@ -4641,8 +4609,8 @@ mod tests {
             persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
                 .expect("retry reuses the same user message identity");
     
    -        let conversation = read_local_conversation_for_session_at(root.path(), None, None)
    -            .expect("read project conversation");
    +        let conversation =
    +            read_direct_project_chat_history_at(root.path()).expect("read project conversation");
             let message_id = format!("direct-codex:{turn_id}:user");
             let persisted = conversation
                 .messages
    @@ -4655,7 +4623,7 @@ mod tests {
             assert!(
                 persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
                     .expect_err("same turn identity cannot be rebound")
    -                .contains("messageId 冲突")
    +                .contains("item id 冲突")
             );
         }
     
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
    index 2f564e863..87ada105d 100644
    --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs
    +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
    @@ -1,4 +1,5 @@
     use super::*;
    +use crate::agent::read_direct_project_chat_history_at;
     use crate::ui_editor::resource::font::FontAsset;
     use sha2::{Digest, Sha256};
     use std::collections::{BTreeMap, HashSet};
    @@ -4748,6 +4749,15 @@ pub(crate) fn read_local_conversation(
         read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
     }
     
    +#[tauri::command]
    +pub(crate) fn read_direct_project_conversation(
    +    project_path: String,
    +) -> Result {
    +    let root = Path::new(project_path.trim());
    +    enforce_project_permission_policy(root, "conversation.read")?;
    +    read_direct_project_chat_history_at(root)
    +}
    +
     #[tauri::command]
     pub(crate) fn append_local_conversation_message(
         project_path: String,
    @@ -4776,6 +4786,20 @@ pub(crate) fn append_local_conversation_message(
         }
     }
     
    +#[tauri::command]
    +pub(crate) fn append_direct_project_conversation_message(
    +    project_path: String,
    +    message: LocalConversationMessage,
    +    message_id: Option,
    +) -> Result {
    +    let root = Path::new(project_path.trim());
    +    enforce_project_permission_policy(root, "conversation.write")?;
    +    let item =
    +        direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?;
    +    append_direct_project_history_item_at(root, &item)?;
    +    read_direct_project_chat_history_at(root)
    +}
    +
     #[tauri::command]
     pub(crate) fn build_local_project_index(
         project_path: String,
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs
    index 8e39c1712..e163cad21 100644
    --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs
    +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs
    @@ -2591,7 +2591,9 @@ fn main() {
                 set_active_game_creator_agent_session,
                 archive_game_creator_agent_session,
                 read_local_conversation,
    +            read_direct_project_conversation,
                 append_local_conversation_message,
    +            append_direct_project_conversation_message,
                 build_local_project_index,
                 create_local_project_checkpoint,
                 export_local_project_package,
    diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs
    index bd2f3e18b..4fd654a80 100644
    --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs
    +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs
    @@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex>>> {
         PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new()))
     }
     
    -pub(super) struct ProjectAppendLock {
    +pub(crate) struct ProjectAppendLock {
         process_lock: Arc>,
         os_lock_path: PathBuf,
     }
     
    -pub(super) struct ProjectAppendGuard<'a> {
    +pub(crate) struct ProjectAppendGuard<'a> {
         _process_guard: std::sync::MutexGuard<'a, ()>,
         _os_lock: File,
     }
    @@ -4335,7 +4335,7 @@ impl ProjectAppendLock {
                 .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
         }
     
    -    pub(super) fn lock(&self, error_label: &str) -> Result, String> {
    +    pub(crate) fn lock(&self, error_label: &str) -> Result, String> {
             let process_guard = self
                 .process_lock
                 .lock()
    @@ -4348,7 +4348,7 @@ impl ProjectAppendLock {
         }
     }
     
    -pub(super) fn project_append_lock_for(path: &Path) -> Result {
    +pub(crate) fn project_append_lock_for(path: &Path) -> Result {
         let mut locks = project_append_locks()
             .lock()
             .map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?;
    @@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result(null);
       const [
         directCodexTransientReplyUpdatedAt,
         setDirectCodexTransientReplyUpdatedAt,
    @@ -617,10 +613,6 @@ export function App({
         receivedDirectUpdate: boolean;
       } | null>(null);
       const lastDirectCodexActivityRef = useRef(null);
    -  const recoveredDirectCodexTurnClaimsRef = useRef(new Set());
    -  const directCodexClaimReleaseOnConversationWriteFailureRef = useRef(
    -    new Map(),
    -  );
       const directCodexConversationTurnSequenceRef = useRef(0);
       const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
         string | null
    @@ -1801,6 +1793,16 @@ export function App({
           projectConversationWriteConfirmedRef.current = null;
           projectConversationWriteCancelledRef.current = null;
         }
    +    // DirectProject history is written by Rust from raw app-server items.
    +    // The browser only renders that projection and must not append chat rows.
    +    if (projectSupervisorOnly && directCodexProductRuntime) {
    +      // `messages` is only an optimistic UI projection in Direct mode; it is
    +      // intentionally not proof of durability. Rust owns the raw response
    +      // history, so this effect must not route these rows through the generic
    +      // browser conversation writer.
    +      savedConversationCountRef.current = messages.length;
    +      return;
    +    }
         const start = savedConversationCountRef.current;
         const pendingMessages = messages.slice(start);
         if (pendingMessages.length === 0) {
    @@ -1871,7 +1873,6 @@ export function App({
           return;
         }
         conversationWriteInFlightRef.current = true;
    -    let failedDirectTerminalMessageCount: number | null = null;
         void (async () => {
           let wroteMessage = false;
           for (const [index, message] of pendingMessages.entries()) {
    @@ -1886,56 +1887,22 @@ export function App({
               savedConversationCountRef.current = start + index + 1;
               continue;
             }
    -        try {
    -          await invoke(
    -            'append_local_conversation_message',
    -            {
    -              projectPath: nextProjectPath,
    +        await invoke(
    +          'append_local_conversation_message',
    +          {
    +            projectPath: nextProjectPath,
    +            agentId: null,
    +            ...(message.messageId ? { messageId: message.messageId } : {}),
    +            message: {
    +              role: message.role,
    +              content: message.text,
                   agentId: null,
    -              ...(message.messageId ? { messageId: message.messageId } : {}),
    -              message: {
    -                role: message.role,
    -                content: message.text,
    -                agentId: null,
    -                ...(typeof message.updatedAt === 'number'
    -                  ? { updatedAt: message.updatedAt }
    -                  : {}),
    -              },
    +              ...(typeof message.updatedAt === 'number'
    +                ? { updatedAt: message.updatedAt }
    +                : {}),
                 },
    -          );
    -        } catch (error) {
    -          const claimKey = message.messageId
    -            ? directCodexClaimReleaseOnConversationWriteFailureRef.current.get(
    -                message.messageId,
    -              )
    -            : undefined;
    -          if (claimKey) {
    -            directCodexClaimReleaseOnConversationWriteFailureRef.current.delete(
    -              message.messageId!,
    -            );
    -            recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
    -            failedDirectTerminalMessageCount = start + index + 1;
    -          }
    -          throw error;
    -        }
    -        if (message.messageId) {
    -          const claimKey =
    -            directCodexClaimReleaseOnConversationWriteFailureRef.current.get(
    -              message.messageId,
    -            );
    -          if (claimKey) {
    -            directCodexClaimReleaseOnConversationWriteFailureRef.current.delete(
    -              message.messageId,
    -            );
    -            if (localProjectPathRef.current === nextProjectPath) {
    -              // Any history read started before this terminal append may hold
    -              // A stale history snapshot may still be missing this terminal
    -              // append. Invalidate it before releasing the in-memory claim.
    -              projectSupervisorHistoryLoadVersionRef.current += 1;
    -            }
    -            recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
    -          }
    -        }
    +          },
    +        );
             wroteMessage = true;
             savedConversationCountRef.current = start + index + 1;
           }
    @@ -1950,12 +1917,10 @@ export function App({
           }
         })()
           .catch((error) => {
    -        savedConversationCountRef.current = failedDirectTerminalMessageCount
    -          ? Math.max(
    -              savedConversationCountRef.current,
    -              failedDirectTerminalMessageCount,
    -            )
    -          : Math.min(savedConversationCountRef.current, start);
    +        savedConversationCountRef.current = Math.min(
    +          savedConversationCountRef.current,
    +          start,
    +        );
             setWorkspaceStatus(
               `项目对话保存失败:${
                 error instanceof Error ? error.message : String(error)
    @@ -1985,6 +1950,7 @@ export function App({
         conversationWriteVersion,
         pendingUiConfirmation,
         projectSupervisorOnly,
    +    directCodexProductRuntime,
       ]);
     
       function appendLocalPermissionLog(
    @@ -2741,11 +2707,12 @@ export function App({
             : await readProjectSupervisorActiveSession(invoke, nextProjectPath);
           let runtimeError = '';
           const projectConversation = await invoke(
    -        'read_local_conversation',
    -        {
    -          projectPath: nextProjectPath,
    -          agentId: null,
    -        },
    +        directCodexProductRuntime
    +          ? 'read_direct_project_conversation'
    +          : 'read_local_conversation',
    +        directCodexProductRuntime
    +          ? { projectPath: nextProjectPath }
    +          : { projectPath: nextProjectPath, agentId: null },
           );
           let supervisorConversation: LocalConversationResult | null = null;
           let runtime: AgentRuntimeState | null = null;
    @@ -5450,6 +5417,7 @@ export function App({
         clientTurnId: directConversationTurnId,
         creationType,
         attachments,
    +    directPolicyChecked = false,
       }: ExecuteChatAgentReplyInput) {
         // Product default: send the conversation directly to Codex app-server.
         // The legacy Supervisor/harness path remains below for rollback and tests.
    @@ -5459,6 +5427,44 @@ export function App({
           if (directProjectPath && directInvoke) {
             const clientTurnId =
               directConversationTurnId ?? createDirectCodexConversationTurnId();
    +        if (
    +          !directPolicyChecked &&
    +          projectConversationWriteConfirmedRef.current !== directProjectPath
    +        ) {
    +          try {
    +            const policyPaused = await queueProjectPolicyConfirmationIfNeeded(
    +              directInvoke,
    +              'conversation.write',
    +              directProjectPath,
    +              '写入 DirectProject 对话历史',
    +              'DirectProject 对话写入需要确认。',
    +              () => {
    +                projectConversationWriteConfirmedRef.current =
    +                  directProjectPath;
    +                void executeChatAgentReply({
    +                  prompt,
    +                  clientTurnId,
    +                  creationType,
    +                  attachments,
    +                  directPolicyChecked: true,
    +                });
    +              },
    +            );
    +            if (policyPaused) {
    +              return;
    +            }
    +            projectConversationWriteConfirmedRef.current = directProjectPath;
    +          } catch (error) {
    +            if (localProjectPathRef.current === directProjectPath) {
    +              setProjectSupervisorRuntimeError(
    +                `DirectProject 对话权限检查失败:${
    +                  error instanceof Error ? error.message : String(error)
    +                }`,
    +              );
    +            }
    +            return;
    +          }
    +        }
             const directUserMessageId = directCodexConversationMessageId(
               clientTurnId,
               'user',
    @@ -5470,20 +5476,45 @@ export function App({
             const appendDirectUserMessageIfMissing = (
               current: ChatMessage[],
             ): ChatMessage[] => {
    -          return current.some(
    -            (message) => message.messageId === directUserMessageId,
    -          )
    -            ? current
    -            : [
    -                ...current,
    -                {
    -                  role: 'user' as const,
    -                  text: prompt,
    -                  runtimeOwned: true,
    -                  messageId: directUserMessageId,
    -                  updatedAt: Date.now(),
    -                },
    -              ];
    +          if (
    +            current.some((message) => message.messageId === directUserMessageId)
    +          ) {
    +            return current;
    +          }
    +          let optimisticIndex = -1;
    +          for (let index = current.length - 1; index >= 0; index -= 1) {
    +            const message = current[index];
    +            if (
    +              message?.role === 'user' &&
    +              message.text === prompt &&
    +              !message.messageId
    +            ) {
    +              optimisticIndex = index;
    +              break;
    +            }
    +          }
    +          if (optimisticIndex >= 0) {
    +            return current.map((message, index) =>
    +              index === optimisticIndex
    +                ? {
    +                    ...message,
    +                    runtimeOwned: true,
    +                    messageId: directUserMessageId,
    +                    updatedAt: Date.now(),
    +                  }
    +                : message,
    +            );
    +          }
    +          return [
    +            ...current,
    +            {
    +              role: 'user' as const,
    +              text: prompt,
    +              runtimeOwned: true,
    +              messageId: directUserMessageId,
    +              updatedAt: Date.now(),
    +            },
    +          ];
             };
             const appendDirectAssistantMessage = (
               current: ChatMessage[],
    @@ -5507,41 +5538,6 @@ export function App({
                 index === existingIndex ? nextMessage : message,
               );
             };
    -        const persistDirectAssistantMessage = (text: string) =>
    -          directInvoke(
    -            'append_local_conversation_message',
    -            {
    -              projectPath: directProjectPath,
    -              agentId: null,
    -              messageId: directAssistantMessageId,
    -              message: {
    -                role: 'assistant',
    -                content: text,
    -                agentId: null,
    -              },
    -            },
    -          );
    -        const persistDirectPartialMessage = (messageId: string, text: string) =>
    -          directInvoke(
    -            'append_local_conversation_message',
    -            {
    -              projectPath: directProjectPath,
    -              agentId: null,
    -              messageId,
    -              message: {
    -                role: 'assistant',
    -                // An interrupted partial is intentionally a normal assistant
    -                // record so replay sees exactly what Codex emitted before the
    -                // disconnect; the marker is product data, not UI metadata.
    -                content: `${text.trim()}\nunexpected interrupt happened here`,
    -                agentId: null,
    -              },
    -            },
    -          );
    -        const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`;
    -        recoveredDirectCodexTurnClaimsRef.current.add(
    -          recoveredDirectCodexTurnClaimKey,
    -        );
             activeDirectCodexTurnRef.current = {
               projectPath: directProjectPath,
               turnId: clientTurnId,
    @@ -5558,36 +5554,6 @@ export function App({
             setDirectCodexTransientReplyUpdatedAt(null);
             setProjectSupervisorRuntimeError('');
             try {
    -          const interruptedPartial = directCodexInterruptedPartialRef.current;
    -          if (
    -            interruptedPartial?.projectPath === directProjectPath &&
    -            interruptedPartial.text.trim()
    -          ) {
    -            await persistDirectPartialMessage(
    -              interruptedPartial.messageId,
    -              interruptedPartial.text,
    -            );
    -            directCodexInterruptedPartialRef.current = null;
    -          }
    -          // Rust owns the normalized user record for attachment turns so the
    -          // durable message includes the same bounded project mapping that is
    -          // sent to Codex. Plain turns keep the optimistic browser write; the
    -          // Rust writer then converges on it through messageId idempotency.
    -          if (!attachments?.length) {
    -            await directInvoke(
    -              'append_local_conversation_message',
    -              {
    -                projectPath: directProjectPath,
    -                agentId: null,
    -                messageId: directUserMessageId,
    -                message: {
    -                  role: 'user',
    -                  content: prompt,
    -                  agentId: null,
    -                },
    -              },
    -            );
    -          }
               const directTurnInput: {
                 projectPath: string;
                 prompt: string;
    @@ -5609,28 +5575,11 @@ export function App({
                 'chat_with_game_creator_direct_codex',
                 directTurnInput,
               );
    -          try {
    -            await persistDirectAssistantMessage(reply);
    -          } catch (error) {
    -            if (localProjectPathRef.current === directProjectPath) {
    -              setProjectSupervisorRuntimeError(
    -                `陶泥儿回复保存失败:${
    -                  error instanceof Error ? error.message : String(error)
    -                }`,
    -              );
    -            }
    -          }
    -          // Rust persists a successful Direct reply before returning Ok. The
    -          // browser append is redundant, so the in-memory turn claim can be
    -          // released without reopening the Provider side effect. Invalidate
    -          // any history snapshot captured before Rust committed the terminal
    -          // reply first.
    +          // Rust already persisted the complete raw response items. Invalidate
    +          // any history snapshot captured before the turn completed.
               if (localProjectPathRef.current === directProjectPath) {
                 projectSupervisorHistoryLoadVersionRef.current += 1;
               }
    -          recoveredDirectCodexTurnClaimsRef.current.delete(
    -            recoveredDirectCodexTurnClaimKey,
    -          );
               if (localProjectPathRef.current === directProjectPath) {
                 clearDirectCodexTransientReply(directProjectPath, clientTurnId);
                 setMessages((current) =>
    @@ -5643,9 +5592,6 @@ export function App({
               }
             } catch (error) {
               if (isDirectCodexTurnAlreadyRunningError(error)) {
    -            recoveredDirectCodexTurnClaimsRef.current.delete(
    -              recoveredDirectCodexTurnClaimKey,
    -            );
                 if (localProjectPathRef.current === directProjectPath) {
                   clearDirectCodexTransientReply(directProjectPath, clientTurnId);
                   setProjectSupervisorRuntimeError(
    @@ -5665,46 +5611,7 @@ export function App({
                 '陶泥儿智能创作',
                 true,
               );
    -          const partial = directCodexTransientReplyRef.current.trim();
    -          if (partial) {
    -            const partialMessageId =
    -              globalThis.crypto?.randomUUID?.() ||
    -              `direct-partial-${Date.now().toString(36)}`;
    -            directCodexInterruptedPartialRef.current = {
    -              projectPath: directProjectPath,
    -              text: partial,
    -              messageId: partialMessageId,
    -            };
    -            try {
    -              await persistDirectPartialMessage(partialMessageId, partial);
    -            } catch {
    -              // The next user send retries this idempotent append before
    -              // constructing the replay prompt.
    -            }
    -          }
    -          try {
    -            await persistDirectAssistantMessage(visibleMessage);
    -            if (localProjectPathRef.current === directProjectPath) {
    -              projectSupervisorHistoryLoadVersionRef.current += 1;
    -            }
    -            recoveredDirectCodexTurnClaimsRef.current.delete(
    -              recoveredDirectCodexTurnClaimKey,
    -            );
    -          } catch {
    -            if (localProjectPathRef.current === directProjectPath) {
    -              // Do not release the claim while the React conversation writer
    -              // can still persist this terminal record. That writer releases
    -              // the claim only after its exact append resolves or rejects.
    -              directCodexClaimReleaseOnConversationWriteFailureRef.current.set(
    -                directAssistantMessageId,
    -                recoveredDirectCodexTurnClaimKey,
    -              );
    -            } else {
    -              recoveredDirectCodexTurnClaimsRef.current.delete(
    -                recoveredDirectCodexTurnClaimKey,
    -              );
    -            }
    -          }
    +          projectSupervisorHistoryLoadVersionRef.current += 1;
               if (localProjectPathRef.current === directProjectPath) {
                 clearDirectCodexTransientReply(directProjectPath, clientTurnId);
                 setDirectCodexStatus('failed');
    diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
    index e83a74396..ad1c1bb67 100644
    --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
    +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
    @@ -2122,6 +2122,7 @@ export function registerHomeProjectCreationTests() {
           'existing-project',
           '已有项目',
         );
    +    const persistedMessages: Array> = [];
         const invoke = vi.fn(
           async (command: string, args?: Record) => {
             if (command === 'get_local_game_manifest') {
    @@ -2131,14 +2132,45 @@ export function registerHomeProjectCreationTests() {
             if (command === 'append_local_permission_log') {
               return {};
             }
    -        if (command === 'append_local_conversation_message') {
    +        if (command === 'hydrate_game_creator_plan_gdd_state') {
    +          return null;
    +        }
    +        if (command === 'read_project_permission_policy') {
    +          return {
    +            path: '.agent/policy.json',
    +            policy: { deniedCommands: [], confirmCommands: [] },
    +          };
    +        }
    +        if (
    +          command === 'read_local_conversation' ||
    +          command === 'read_direct_project_conversation'
    +        ) {
               return {
                 path: `${projectPath}/.agent/conversations/project.jsonl`,
                 agentId: null,
    -            messages: [],
    +            sessionId: null,
    +            messages: [...persistedMessages],
               };
             }
    +        if (command === 'append_local_conversation_message') {
    +          throw new Error(
    +            'DirectProject must not use browser conversation writer',
    +          );
    +        }
             if (command === 'chat_with_game_creator_direct_codex') {
    +          const clientTurnId = String(args?.clientTurnId ?? '');
    +          persistedMessages.push(
    +            {
    +              role: 'user',
    +              content: String(args?.prompt ?? ''),
    +              messageId: `direct-codex:${clientTurnId}:user`,
    +            },
    +            {
    +              role: 'assistant',
    +              content: 'DIRECT_EXISTING_PROJECT_OK',
    +              messageId: `direct-codex:${clientTurnId}:assistant`,
    +            },
    +          );
               return 'DIRECT_EXISTING_PROJECT_OK';
             }
             throw new Error(`unexpected invoke ${command}`);
    @@ -2165,32 +2197,18 @@ export function registerHomeProjectCreationTests() {
             },
           );
         });
    -    const persistedTurnCall = invoke.mock.calls.findIndex(
    -      ([command, args]) =>
    -        command === 'append_local_conversation_message' &&
    -        (args as Record | undefined)?.messageId !== undefined,
    -    );
         const directTurnCall = invoke.mock.calls.findIndex(
           ([command]) => command === 'chat_with_game_creator_direct_codex',
         );
    -    expect(persistedTurnCall).toBeGreaterThanOrEqual(0);
    -    expect(persistedTurnCall).toBeLessThan(directTurnCall);
    -    const persistedTurnArgs = invoke.mock.calls[persistedTurnCall]?.[1] as
    -      | Record
    -      | undefined;
         const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as
           | Record
           | undefined;
    -    expect(persistedTurnArgs).toEqual({
    -      projectPath,
    -      agentId: null,
    -      messageId: `direct-codex:${String(directTurnArgs?.clientTurnId ?? '')}:user`,
    -      message: {
    -        role: 'user',
    -        content: '继续修改已有项目',
    -        agentId: null,
    -      },
    -    });
    +    expect(directTurnCall).toBeGreaterThanOrEqual(0);
    +    expect(invoke).not.toHaveBeenCalledWith(
    +      'append_local_conversation_message',
    +      expect.anything(),
    +    );
    +    expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String));
         expect(invoke).not.toHaveBeenCalledWith(
           'create_automatic_local_game_project',
         );
    @@ -2214,7 +2232,10 @@ export function registerHomeProjectCreationTests() {
               expect(args).toEqual({ projectPath });
               return manifest;
             }
    -        if (command === 'read_local_conversation') {
    +        if (
    +          command === 'read_local_conversation' ||
    +          command === 'read_direct_project_conversation'
    +        ) {
               return {
                 path: `${projectPath}/.agent/conversations/project.jsonl`,
                 agentId: null,
    @@ -2257,6 +2278,23 @@ export function registerHomeProjectCreationTests() {
               };
             }
             if (command === 'chat_with_game_creator_direct_codex') {
    +          const clientTurnId = String(args?.clientTurnId ?? '');
    +          persistedMessages.push(
    +            {
    +              schemaVersion: 'game-creator-conversation.v1',
    +              role: 'user',
    +              content: String(args?.prompt ?? ''),
    +              messageId: `direct-codex:${clientTurnId}:user`,
    +              updatedAt: 1,
    +            },
    +            {
    +              schemaVersion: 'game-creator-conversation.v1',
    +              role: 'assistant',
    +              content: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
    +              messageId: `direct-codex:${clientTurnId}:assistant`,
    +              updatedAt: 2,
    +            },
    +          );
               throw new Error('codex-app-server-error:unauthorized');
             }
             throw new Error(`unexpected invoke ${command}`);
    diff --git a/docs/README.md b/docs/README.md
    index 7453573fe..95647a6f4 100644
    --- a/docs/README.md
    +++ b/docs/README.md
    @@ -22,6 +22,7 @@
     - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。
     
     - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
    +- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。
     - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
     - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
     - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
    diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
    index 8dcc07aa3..00092043a 100644
    --- a/docs/project-memory/shared-memory/decision-log.md
    +++ b/docs/project-memory/shared-memory/decision-log.md
    @@ -7961,6 +7961,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
     - 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。
     - 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。
     
    +## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显
    +
    +- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。
    +- Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。
    +- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。
    +
     ## 2026-08-31 AGC 错误报告与诊断上传
     
     - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。
    diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md
    index 9d754cb5d..ffd3726a7 100644
    --- a/docs/project-memory/shared-memory/document-map.md
    +++ b/docs/project-memory/shared-memory/document-map.md
    @@ -22,14 +22,15 @@
     AI 游戏创作 / DirectProject / UI workflow:
     
     1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
    -2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
    -3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
    -4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
    -5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
    -6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
    -7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
    -8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
    -9. UI 编辑器、宿主壳和当前测试专题文档
    +2. `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`
    +3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
    +4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
    +5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
    +6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
    +7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
    +8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
    +9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
    +10. UI 编辑器、宿主壳和当前测试专题文档
     
     图片画布 / 媒体生成:
     
    diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    index 79a527af8..2f63f00e6 100644
    --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    @@ -238,7 +238,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
     
     - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。
     - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。
    -- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
    +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;AGC 是 DirectProject 用户消息的唯一持久化来源:每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。Codex 回显的 `userMessage` / `role=user` item 只用于事件观察和关联校验,不得再次追加到项目 JSONL,避免把服务端 echo 当作第二条用户消息;本地 user item 写入与 Codex echo 过滤必须区分来源,不能用同一个“忽略 user item”入口阻断 AGC 自己的预写。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
     - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。
     - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。
     - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。
    diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
    new file mode 100644
    index 000000000..de0c1b0a2
    --- /dev/null
    +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md	
    @@ -0,0 +1,58 @@
    +# DirectProject Codex 原始历史与异常恢复
    +
    +更新时间:`2026-09-07`
    +
    +## 目标
    +
    +DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。
    +
    +本方案只适用于 DirectProject,不改变 DirectHome、Agent session 历史或 `runtime/direct-codex/turns` 审计账本。
    +
    +## 文件格式
    +
    +每行采用 Codex CLI rollout 的最小事件外壳,不保存 AGC 自有的顺序号或运行环境字段:
    +
    +```json
    +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}}
    +```
    +
    +`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
    +
    +DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。
    +
    +## 正常回合
    +
    +1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
    +2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。
    +3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
    +4. 正常 `turn/completed: completed` 不生成额外记录。
    +
    +## 异常回合收尾
    +
    +AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。
    +
    +`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item:
    +
    +```json
    +{"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}}
    +```
    +
    +合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。
    +
    +`rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。
    +
    +Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。过滤同时识别 role=user 的完整上下文标签包裹文本,即使该 item 没有 `internal_chat_message_metadata_passthrough` 元数据,也不能把它当成用户回合。
    +
    +## 恢复
    +
    +创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。新 thread 已进入连接池但历史读取或注入失败时,必须先从池中淘汰并取消订阅该 thread,重试只能创建新 thread 并重新注入。
    +
    +`clientUserMessageId` 仅作为 Codex 用户消息的稳定标识随 `turn/start` 发送,不等价于 turn 级 exactly-once 幂等。断线后的重试仍须由项目侧持久化 turn ledger 或服务端去重合同决定,不能仅凭该字段再次执行。
    +
    +聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。
    +
    +DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器;Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
    +
    +## 写入与损坏边界
    +
    +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。
    
    From a0e6a83691a4b47075b30c9e24127c4aae3fe8dc Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?=E8=91=A3=E7=BE=BD=E7=A7=A6?= 
    Date: Tue, 8 Sep 2026 22:05:15 +0800
    Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=A8=A1?=
     =?UTF-8?q?=E5=9E=8B=E9=80=89=E6=8B=A9=E5=85=A5=E5=8F=A3=E3=80=81=E4=BA=A4?=
     =?UTF-8?q?=E4=BA=92=E4=B8=8E=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B=E5=9B=9E?=
     =?UTF-8?q?=E9=80=80=20(#299)?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    ### 背景
    
    AGC 后台已支持维护 LLM 模型目录(`agc_model_catalog`),客户端此前只在项目右侧对话浮出模型选择,且存在几个体验问题:
    
    1. 首页聊天框架缺少模型选择入口;
    2. 首次进入项目时,右侧模型选择器「点不动」(被 disabled);
    3. 模型选择下拉失去焦点不自动收起;
    4. 默认模型缺少可视标识;
    5. 已保存的选择被后台停用/移除后,客户端显示「请选择模型」,未自动回退默认模型;首页因懒加载在打开前也一直显示「选择模型」。
    
    ### 改动内容(完整变更集)
    
    - **首页聊天框架新增模型选择入口**,复用项目右侧对话的 `ConversationModelSelect`;上传按钮在左,模型选择器与发送按钮在右(同一组,模型选择器紧挨发送按钮)。
    - **修复首次进入项目时右侧模型选择器点不动**:选择器不再因目录加载(`busy`)或对话进行中(`controlBusy`)被禁用;切换模型只写回客户端配置并作用于后续轮次,发送仍由 `controlBusy` / `modelReady` 把关。
    - **模型下拉支持点击外部 / Esc 自动收起**:给组件容器挂 ref,监听 `document` 的 `mousedown` 与 `keydown`,点击容器外或按 Esc 即收起(仅在展开时挂监听)。
    - **默认模型增加「默认」标识**:组件记录 `defaultModelId`,在下拉选项中给默认模型显示小标签;亮暗主题自适应。
    - **模型选择始终回退默认模型**:已保存选择仍可用时沿用;被停用/移除或从未选择时回退到后台默认模型并写回配置;只有默认模型本身不可用才走错误分流。首页由「懒加载」改为「挂载即加载」,触发按钮直接落在默认模型上,不再出现「选择模型」空态。
    - **清理**:移除组件中已无调用方的 `lazy` 加载路径。
    - **文档**:同步更新 `【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`(首页入口、对话进行中可切换模型、失焦收起等行为)。
    
    ### 关键交互/行为说明
    
    - 触发按钮永远显示模型名(默认或当前选择),不再出现「选择模型」占位,除非默认模型不可用。
    - 模型下拉的加载中、刷新、选中态沿用原有交互;默认模型有「默认」标签,便于识别。
    - 首页模型选择器不改动「开启创作」门控:目录不可用时仍可创建项目并走后台默认项。
    
    ### 验证
    
    - `npm run test -- apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx` -> 7/7 通过
    - `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts` -> 386/386 通过
    - `npm run ai-game-creator-shell:typecheck` 通过
    - `npm run check:encoding`(4309 文件)通过
    - `git diff --check` 通过
    
    ### 影响范围与注意事项
    
    - 变更集中在 AGC 客户端(`apps/ai-game-creator-shell`),不涉及 SpacetimeDB schema / 后端接口 / 契约。
    - `GET /api/llm/models` 语义不变(仍只返回启用项 + 默认项),本次不改后端。
    - 模型下拉在窄屏的换行/遮挡,以及「默认」标签对比度,建议在真机确认一下观感(单测只覆盖逻辑)。
    
    ### 关联
    
    - 无关联 Issue;本 PR 独立提交。
    
    ---------
    
    Co-authored-by: 段舒康 
    Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/299
    Co-authored-by: 董羽秦 
    Co-committed-by: 董羽秦 
    ---
     .../ConversationModelSelect.tsx               |  77 +++++++++++---
     .../ProjectSupervisorView.tsx                 |   4 +-
     apps/ai-game-creator-shell/src/styles.css     |  36 ++++++-
     .../src/view/home/index.tsx                   |  47 ++++----
     .../tests/appSurface/home.suite.ts            |  59 ++++++++++-
     .../appSurface/project-development.suite.ts   |  44 ++++++++
     .../appSurface/runtime-settings.suite.ts      |  12 ++-
     .../tests/conversationModelSelect.test.tsx    | 100 ++++++++++++++++--
     ...方案】AGC后台模型别名与对话选择-2026-09-05.md |   2 +
     9 files changed, 337 insertions(+), 44 deletions(-)
    
    diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
    index 92d10d2a2..d6e81735a 100644
    --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
    +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
    @@ -1,5 +1,5 @@
     import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
    -import { useCallback, useEffect, useState } from 'react';
    +import { useCallback, useEffect, useRef, useState } from 'react';
     
     import { resolveTauriInvoke } from '../../app/tauri';
     import type { GameCreatorAppConfigView } from '../../app/types';
    @@ -9,21 +9,25 @@ import {
     } from '../../services/clientApi';
     
     export function ConversationModelSelect({
    +  className,
       disabled,
       onReady,
     }: {
    +  className?: string;
       disabled: boolean;
    -  onReady: (ready: boolean) => void;
    +  onReady?: (ready: boolean) => void;
     }) {
       const [models, setModels] = useState([]);
       const [selected, setSelected] = useState('');
    +  const [defaultModelId, setDefaultModelId] = useState('');
       const [busy, setBusy] = useState(true);
       const [error, setError] = useState('');
       const [open, setOpen] = useState(false);
    +  const containerRef = useRef(null);
       const refresh = useCallback(async () => {
         setBusy(true);
         setError('');
    -    onReady(false);
    +    onReady?.(false);
         try {
           const invoke = resolveTauriInvoke();
           if (!invoke) throw new Error('Native host unavailable');
    @@ -32,18 +36,28 @@ export function ConversationModelSelect({
             invoke('read_game_creator_app_config'),
           ]);
           setModels(catalog.models);
    -      const id = config.config.selectedModelId || catalog.defaultModelId;
    +      setDefaultModelId(catalog.defaultModelId);
    +      const savedSelection = config.config.selectedModelId;
    +      // 优先沿用用户已选且仍可用的模型;已选模型被停用/移除时回退到默认模型,
    +      // 避免下拉出现「请选择模型」的空态。默认模型由后台强制配置,缺失时才走错误提示。
    +      const savedAvailable = savedSelection
    +        ? catalog.models.some((model) => model.id === savedSelection)
    +        : false;
    +      const id =
    +        savedAvailable && savedSelection
    +          ? savedSelection
    +          : catalog.defaultModelId;
           setSelected(id);
           const available = catalog.models.some((model) => model.id === id);
    -      if (available && !config.config.selectedModelId) {
    +      if (available && savedSelection !== id) {
             const saved = await invoke(
               'select_game_creator_model',
               { modelId: id },
             );
             if (saved.config.selectedModelId !== id)
    -          throw new Error('Default selection was not saved');
    +          throw new Error('Model selection was not saved');
           }
    -      onReady(available);
    +      onReady?.(available);
           if (!available) setError('请选择可用模型');
         } catch {
           setModels([]);
    @@ -57,8 +71,29 @@ export function ConversationModelSelect({
         void refresh();
       }, [refresh]);
     
    +  useEffect(() => {
    +    if (!open) return;
    +    function handleOutsidePointerDown(event: MouseEvent) {
    +      const target = event.target as Node | null;
    +      if (containerRef.current && !containerRef.current.contains(target)) {
    +        setOpen(false);
    +      }
    +    }
    +    function handleEscape(event: KeyboardEvent) {
    +      if (event.key === 'Escape') {
    +        setOpen(false);
    +      }
    +    }
    +    document.addEventListener('mousedown', handleOutsidePointerDown);
    +    document.addEventListener('keydown', handleEscape);
    +    return () => {
    +      document.removeEventListener('mousedown', handleOutsidePointerDown);
    +      document.removeEventListener('keydown', handleEscape);
    +    };
    +  }, [open]);
    +
       async function select(id: string) {
    -    onReady(false);
    +    onReady?.(false);
         setBusy(true);
         setError('');
         try {
    @@ -71,7 +106,7 @@ export function ConversationModelSelect({
           if (result.config.selectedModelId !== id)
             throw new Error('Selection was not saved');
           setSelected(id);
    -      onReady(true);
    +      onReady?.(true);
         } catch {
           setError('模型选择保存失败');
         } finally {
    @@ -80,7 +115,14 @@ export function ConversationModelSelect({
       }
     
       return (
    -    
    +
    {error ? {error} : null} +
    + +
    +
    + + +
    diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index ad1c1bb67..5feeec759 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -62,6 +62,46 @@ function ApprovedGddStartHarness() { } export function registerClientHomeTests() { + it('adds a model selector to the home composer and shows the default model', async () => { + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + const modelTrigger = await screen.findByRole('button', { + name: '对话模型', + }); + await waitFor(() => expect(modelTrigger.textContent).toContain('高质量')); + const createButton = screen.getByRole('button', { name: '开启创作' }); + expect(createButton).toHaveProperty('disabled', false); + + fireEvent.click(modelTrigger); + await waitFor(() => + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(), + ); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'fast', + }), + ); + expect(modelTrigger.textContent).toContain('快速'); + expect(createButton).toHaveProperty('disabled', false); + }); + it('anchors the empty home input placeholder to the editor while the page scrolls', () => { renderLauncherAt('/?launcher'); @@ -1220,7 +1260,17 @@ export function registerHomeProjectCreationTests() { }); it('keeps only open and create project actions without exposing a Linux fallback', () => { - const invoke = vi.fn(); + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); window.__TAURI__ = { core: { invoke } }; renderLauncherProjectsAt('/?launcher'); @@ -1242,7 +1292,12 @@ export function registerHomeProjectCreationTests() { screen.queryByRole('button', { name: '在文件管理器中显示' }), ).toBeNull(); expect(screen.queryByRole('button', { name: /Godot 项目/ })).toBeNull(); - expect(invoke).not.toHaveBeenCalled(); + // 首页模型选择器会在挂载时读取模型目录(read_game_creator_app_config), + // 这里只校验没有打开工作区窗口或其它项目操作被触发。 + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); }); it('opens the directory selected by the native picker', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 79815398b..64373c01d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4695,6 +4695,50 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('allows selecting the model on the first direct-project entry', async () => { + const projectPath = '/tmp/first-entry-model-select'; + const manifest = createGameCreationAppManifest( + 'first-entry-model-select', + '首次进入模型选择', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + + const surface = await screen.findByLabelText('陶泥儿项目对话'); + const trigger = within(surface).getByRole('button', { name: '对话模型' }); + await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false)); + fireEvent.click(trigger); + await waitFor(() => + expect( + within(surface).getByRole('option', { name: '快速' }), + ).not.toBeNull(), + ); + fireEvent.click(within(surface).getByRole('option', { name: '快速' })); + await waitFor(() => expect(trigger.textContent).toContain('快速')); + }); + it('runs a top workbench play request without a second confirmation', async () => { const projectPath = '/tmp/top-play-request'; const supervisorHarness = createProjectSupervisorRuntimeHarness({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 90bdc35b3..3ed93a19a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -692,9 +692,16 @@ export function registerRuntimeSettingsTests() { }) => void) | undefined; let readCount = 0; + let modelReadResolved = false; const invoke = vi.fn((command: string) => { if (command === 'read_game_creator_app_config') { readCount += 1; + // 首页模型选择器会在挂载时读取一次配置;让首次读取立即完成, + // 以免一直处于 pending 干扰运行时配置对话框的读取计数。 + if (!modelReadResolved) { + modelReadResolved = true; + return Promise.resolve({ config: { selectedModelId: 'quality' } }); + } return new Promise((resolve) => { resolveRead = resolve as typeof resolveRead; }); @@ -723,10 +730,13 @@ export function registerRuntimeSettingsTests() { true, ); + // 首页模型选择器挂载时会读取一次配置(上面已让首次读取立即完成), + // 这里只校验:对话框处于「正在读取」时点击读取/保存不会新增读取请求。 + const readsWhileReading = readCount; fireEvent.click(screen.getByRole('button', { name: '读取' })); fireEvent.click(screen.getByRole('button', { name: '保存' })); - expect(readCount).toBe(1); + expect(readCount).toBe(readsWhileReading); await act(async () => { resolveRead?.({ path: '/home/test/AppData/game-creator.config.json', diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index fd7e49b36..1f160b63b 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -5,6 +5,7 @@ import { render, screen, waitFor, + within, } from '@testing-library/react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; @@ -53,15 +54,25 @@ test('only displays aliases and persists selection through the native command', ).toContain('快速'); }); -test('does not mark a removed selection ready or expose the old identifier', async () => { - invoke.mockResolvedValue({ - config: { selectedModelId: 'private-old-model' }, - }); +test('falls back to the default model when the saved selection was removed', async () => { + invoke.mockImplementation(async (command, input) => ({ + config: { + selectedModelId: + command === 'select_game_creator_model' + ? (input as { modelId: string }).modelId + : 'private-old-model', + }, + })); const onReady = vi.fn(); render(); - await screen.findByText('请选择可用模型'); - expect(onReady).toHaveBeenLastCalledWith(false); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('private-old-model')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'quality', + }); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('高质量'); }); test('failed catalog can be refreshed without enabling submission', async () => { @@ -88,3 +99,80 @@ test('a failed save keeps submission unavailable', async () => { await screen.findByText('模型选择保存失败'); expect(onReady).toHaveBeenLastCalledWith(false); }); + +test('closes the menu when clicking outside', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.mouseDown(document.body); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('closes the menu on Escape', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('marks the default model in the menu', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const qualityOption = screen.getByRole('option', { name: /高质量/ }); + expect(within(qualityOption).getByText('默认')).not.toBeNull(); + const fastOption = screen.getByRole('option', { name: '快速' }); + expect(within(fastOption).queryByText('默认')).toBeNull(); +}); + +test('keeps model options disabled while a selection save is in flight', async () => { + let resolveSave: ((value: unknown) => void) | undefined; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + return new Promise((resolve) => { + resolveSave = resolve; + }); + } + return { config: { selectedModelId: 'quality' } }; + }); + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await screen.findByRole('option', { name: '快速' }); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + + // 保存期间重新打开菜单:可以查看,但选项应禁用,避免并发选择。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('option', { name: /高质量/ })).toHaveProperty( + 'disabled', + true, + ); + expect(onReady).toHaveBeenLastCalledWith(false); + + resolveSave?.({ config: { selectedModelId: 'fast' } }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + false, + ); +}); diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index d70a5d096..43b17d928 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -8,6 +8,8 @@ - `GET /api/llm/models` 返回启用项的 `id/displayName` 和 `defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。 - 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。 +- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。 +- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。 - 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。 ## 验收