From beae1dfe5fd268233c63bc2285b34e4d0527d5f8 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 19:44:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=87=E7=A8=8B=E5=8D=A1=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=96=87=E6=A1=88=E6=94=B9=E4=B8=BA=E7=94=A8=E6=88=B7=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP 工具按写入、浏览、读取素材、导入、生成图片等用户动作显示文案 - 写入工具与文件变更统一显示正在写入文件和项目相对路径 - 命令显示具体命令,验证类命令显示正在验证游戏 - 未知工具只显示正在调用工具,不暴露内部工具名和未审核参数 - 工具详情限制路径长度并拒绝绝对路径与上跳路径 - stream=false 时继续保留新的具体工具执行文案 - 补充工具映射、安全边界、命令分类与过程卡心跳回归 - 决策记录同步工具语义文案规则 --- .../src-tauri/src/agent/codex_app_server.rs | 265 ++++++++++++++++-- .../src-tauri/src/agent/direct_runtime.rs | 28 +- apps/ai-game-creator-shell/src/App.tsx | 21 +- .../appSurface/project-development.suite.ts | 31 +- .../shared-memory/decision-log.md | 4 +- 5 files changed, 308 insertions(+), 41 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 00066ccfd..c9372b5ef 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 @@ -544,15 +544,10 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat .and_then(serde_json::Value::as_str) .unwrap_or_default(); if item_type == "commandExecution" { - let command = item + if item .get("command") .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_ascii_lowercase(); - if command.contains("game.static_smoke") - || command.contains("preview.validate") - || command.contains("verify") - || command.contains("test") + .is_some_and(direct_codex_command_is_game_verification) { return "game-verify"; } @@ -580,6 +575,108 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } +fn direct_codex_command_is_game_verification(command: &str) -> bool { + let command = command.to_ascii_lowercase(); + command.contains("game.static_smoke") + || command.contains("preview.validate") + || command.contains("verify") + || command.contains("test") +} + +fn direct_codex_bounded_detail(value: &str, max_chars: usize) -> Option { + let value = value.split_whitespace().collect::>().join(" "); + if value.is_empty() { + return None; + } + Some(value.chars().take(max_chars).collect()) +} + +fn direct_codex_project_path_detail(item: &serde_json::Value, pointer: &str) -> Option { + let value = item.pointer(pointer)?.as_str()?; + if value.is_empty() { + return None; + } + let path = std::path::Path::new(value); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return None; + } + direct_codex_bounded_detail(value, 120) +} + +fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String { + let arguments = item + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Null); + let optional_path = |pointer: &str| { + direct_codex_project_path_detail( + &serde_json::json!({ "arguments": arguments.clone(), "tool": "" }), + pointer, + ) + }; + let optional_text = |field: &str, max_chars: usize| { + arguments + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|value| direct_codex_bounded_detail(value, max_chars)) + }; + match item.get("tool").and_then(serde_json::Value::as_str) { + Some("taonier_prepare_game_art") => "正在准备美术素材".to_string(), + Some("agc_generate_image") => match direct_codex_project_path_detail( + &serde_json::json!({ "arguments": arguments.clone() }), + "/arguments/outputPath", + ) { + Some(path) => format!("正在生成图片:{path}"), + None => "正在生成图片".to_string(), + }, + Some("agc_edit_image") => match optional_text("assetName", 80) { + Some(name) => format!("正在编辑图片:{name}"), + None => "正在编辑图片".to_string(), + }, + Some("agc_list_registered_assets") => match optional_text("query", 80) { + Some(query) => format!("正在读取素材库:{query}"), + None => "正在读取素材库".to_string(), + }, + Some("agc_list_project_files") => { + match optional_path("/arguments/path").or_else(|| optional_text("query", 80)) { + Some(detail) => format!("正在浏览项目文件:{detail}"), + None => "正在浏览项目文件".to_string(), + } + } + Some("agc_write_file") => match optional_path("/arguments/path") { + Some(path) => format!("正在写入文件:{path}"), + None => "正在写入文件".to_string(), + }, + Some("agc_list_account_assets") => match optional_text("query", 80) { + Some(query) => format!("正在读取账户素材:{query}"), + None => "正在读取账户素材".to_string(), + }, + Some("agc_import_account_assets") => { + let local_path_count = arguments + .get("localPaths") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .filter(|count| *count > 0); + match local_path_count { + Some(count) => format!("正在导入素材:{count} 项"), + None => "正在导入素材".to_string(), + } + } + Some("agc_create_or_derive_resource") => "正在创建素材资源".to_string(), + Some("agc_remove_background") => "正在去除图片背景".to_string(), + Some("agc_browser_playtest") => "正在试玩游戏".to_string(), + Some("agc_web_search") => match optional_text("query", 80) { + Some(query) => format!("正在搜索资料:{query}"), + None => "正在搜索资料".to_string(), + }, + _ => "正在调用工具".to_string(), + } +} + /// Project a started Codex item into a short user-visible progress line. /// Codex app-server 0.147/0.149 only pushes structural item/started events /// (with the concrete command/tool/path) while tools run; it does not push @@ -592,28 +689,22 @@ fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option { - let tool = item - .get("tool") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("工具"); - match item - .pointer("/arguments/path") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - { - Some(path) => format!("正在调用 {tool}:{path}"), - None => format!("正在调用 {tool}"), - } - } + "mcpToolCall" => direct_codex_mcp_tool_intermediate_text(item), "commandExecution" => { let command = item .get("command") .and_then(serde_json::Value::as_str) .filter(|value| !value.trim().is_empty()); match command { - Some(command) => format!("正在执行:{command}"), + Some(command) => { + let command = direct_codex_bounded_detail(command, 120) + .unwrap_or_else(|| "命令".to_string()); + if direct_codex_command_is_game_verification(&command) { + format!("正在验证游戏:{command}") + } else { + format!("正在执行命令:{command}") + } + } None => "正在执行命令".to_string(), } } @@ -624,11 +715,11 @@ fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option format!("正在修改:{path}"), - None => "正在修改项目文件".to_string(), + Some(path) => format!("正在写入文件:{path}"), + None => "正在写入文件".to_string(), } } - "webSearch" => "正在联网搜索".to_string(), + "webSearch" => "正在搜索资料".to_string(), "contextCompaction" => "正在整理上下文".to_string(), _ => return None, }; @@ -3584,7 +3675,7 @@ mod tests { }); assert_eq!( direct_codex_item_intermediate_text(&mcp).as_deref(), - Some("正在调用 agc_write_file:game/index.html") + Some("正在写入文件:game/index.html") ); let command = serde_json::json!({ @@ -3593,7 +3684,7 @@ mod tests { }); assert_eq!( direct_codex_item_intermediate_text(&command).as_deref(), - Some("正在执行:npm run build") + Some("正在执行命令:npm run build") ); assert_eq!( direct_codex_safe_activity_for_item_value(&serde_json::json!({ @@ -3602,6 +3693,14 @@ mod tests { })), "game-verify" ); + assert_eq!( + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "commandExecution", + "command": "preview.validate" + })) + .as_deref(), + Some("正在验证游戏:preview.validate") + ); let file_change = serde_json::json!({ "type": "fileChange", @@ -3609,13 +3708,121 @@ mod tests { }); assert_eq!( direct_codex_item_intermediate_text(&file_change).as_deref(), - Some("正在修改:game/player.gd") + Some("正在写入文件:game/player.gd") ); let reasoning = serde_json::json!({ "type": "reasoning" }); assert_eq!(direct_codex_item_intermediate_text(&reasoning), None); } + #[test] + fn direct_mcp_tool_details_use_user_facing_work_labels() { + let detail = |tool: &str, arguments: serde_json::Value| { + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": tool, + "arguments": arguments + })) + .expect("mcp tool detail") + }; + + assert_eq!( + detail("taonier_prepare_game_art", serde_json::json!({})), + "正在准备美术素材" + ); + assert_eq!( + detail( + "agc_generate_image", + serde_json::json!({ "outputPath": "assets/hero.png" }) + ), + "正在生成图片:assets/hero.png" + ); + assert_eq!( + detail( + "agc_edit_image", + serde_json::json!({ "assetName": "主角头像" }) + ), + "正在编辑图片:主角头像" + ); + assert_eq!( + detail( + "agc_list_registered_assets", + serde_json::json!({ "query": "hero" }) + ), + "正在读取素材库:hero" + ); + assert_eq!( + detail( + "agc_list_project_files", + serde_json::json!({ "path": "assets" }) + ), + "正在浏览项目文件:assets" + ); + assert_eq!( + detail("agc_list_account_assets", serde_json::json!({})), + "正在读取账户素材" + ); + assert_eq!( + detail( + "agc_import_account_assets", + serde_json::json!({ "localPaths": ["assets/a.png", "assets/b.png"] }) + ), + "正在导入素材:2 项" + ); + assert_eq!( + detail("agc_create_or_derive_resource", serde_json::json!({})), + "正在创建素材资源" + ); + assert_eq!( + detail("agc_remove_background", serde_json::json!({})), + "正在去除图片背景" + ); + assert_eq!( + detail("agc_browser_playtest", serde_json::json!({})), + "正在试玩游戏" + ); + assert_eq!( + detail( + "agc_web_search", + serde_json::json!({ "query": "tauri webview" }) + ), + "正在搜索资料:tauri webview" + ); + } + + #[test] + fn direct_mcp_tool_details_do_not_expose_unreviewed_names_or_unsafe_paths() { + let unknown = direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": "SECRET_TOOL_/private/project", + "arguments": { "prompt": "Bearer secret-token /private/project" } + })) + .expect("safe unknown-tool detail"); + assert_eq!(unknown, "正在调用工具"); + assert!(!unknown.contains("SECRET_TOOL")); + assert!(!unknown.contains("/private/project")); + assert!(!unknown.contains("secret-token")); + + assert_eq!( + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { "path": "C:/Users/private/secret.txt" } + })) + .as_deref(), + Some("正在写入文件") + ); + assert_eq!( + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { "path": "../outside.txt" } + })) + .as_deref(), + Some("正在写入文件") + ); + } + #[test] fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); 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 c8f3f2063..7915b3a98 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 @@ -3697,12 +3697,23 @@ fn project_direct_codex_accumulated_text( } fn is_direct_codex_item_started_work_detail(value: &str) -> bool { - const PREFIXES: [&str; 5] = [ - "正在执行:", - "正在调用", - "正在修改:", - "正在联网搜索", + const PREFIXES: [&str; 16] = [ + "正在写入文件:", + "正在浏览项目文件", + "正在读取素材库", + "正在读取账户素材", + "正在导入素材", + "正在生成图片", + "正在编辑图片", + "正在准备美术素材", + "正在创建素材资源", + "正在去除图片背景", + "正在试玩游戏", + "正在搜索资料:", + "正在执行命令:", + "正在验证游戏:", "正在整理上下文", + "正在调用工具", ]; PREFIXES.iter().any(|prefix| value.starts_with(prefix)) } @@ -4696,14 +4707,15 @@ mod tests { #[test] fn direct_item_started_work_detail_survives_stream_disabled() { assert!(is_direct_codex_item_started_work_detail( - "正在执行:npm run build" + "正在执行命令:npm run build" )); assert!(is_direct_codex_item_started_work_detail( - "正在调用 agc_read_file:game/index.html" + "正在浏览项目文件:game/index.html" )); assert!(is_direct_codex_item_started_work_detail( - "正在修改:game/player.gd" + "正在写入文件:game/player.gd" )); + assert!(is_direct_codex_item_started_work_detail("正在调用工具")); assert!(!is_direct_codex_item_started_work_detail("阶段性回复")); assert!(!is_direct_codex_item_started_work_detail( "hidden reasoning must not leak" diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 04f33762a..2030ac659 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -319,6 +319,23 @@ function directCodexActivityDetail( } } +const DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES = [ + '正在写入文件:', + '正在浏览项目文件', + '正在读取素材库', + '正在读取账户素材', + '正在导入素材', + '正在生成图片', + '正在编辑图片', + '正在准备美术素材', + '正在创建素材资源', + '正在去除图片背景', + '正在试玩游戏', + '正在搜索资料:', + '正在执行命令:', + '正在验证游戏:', +] as const; + function directCodexProcessDetail({ accumulatedText, activity, @@ -348,7 +365,9 @@ function directCodexProcessDetail({ } function isDirectCodexSpecificWorkDetail(text: string) { - return /^(?:正在执行:|正在调用|正在修改:)/u.test(text); + return DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES.some((prefix) => + text.startsWith(prefix), + ); } function directCodexTransientReplyText({ 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 f890d531b..79815398b 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 @@ -5755,7 +5755,7 @@ export function registerProjectSupervisorSurfaceTests() { sequence: 5, status: 'running', activity: 'command-exec', - accumulatedText: `正在执行:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, + accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, updatedAt: 5000, }, }); @@ -5774,6 +5774,35 @@ export function registerProjectSupervisorSurfaceTests() { within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), ).not.toBeNull(); expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 7, + status: 'running', + activity: 'controlled-tool', + accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, + updatedAt: 5200, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 8, + status: 'running', + activity: 'controlled-tool', + updatedAt: 5300, + }, + }); + }); + expect( + within(streamingProcessCard).getByText( + /正在写入文件:game\/index\.html/u, + ), + ).not.toBeNull(); + expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); expect( within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 15be79fd2..d3fe15e0f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,9 +18,9 @@ ## 2026-09-02 Direct 过程卡按回合阶段状态驱动 - 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。 -- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节(例如“正在执行:<命令>”)与回复流解耦,`stream=false` 时仍展示在过程卡;同一 command-exec 后续无正文的活动心跳不得用通用“正在执行命令”覆盖已展示的具体命令。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 +- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节与回复流解耦,`stream=false` 时仍展示在过程卡;MCP 工具按用户语义显示(例如 `agc_write_file` 为“正在写入文件:<项目相对路径>”、图片/素材/搜索/试玩分别显示生成、导入、搜索、试玩等动作),未知工具只显示“正在调用工具”不暴露内部工具名;命令显示“正在执行命令:<命令>”,验证类命令显示“正在验证游戏:<命令>”。同一活动后续无正文的心跳不得用通用文案覆盖已展示的具体工作。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs` 的 DirectProject observer、`apps/ai-game-creator-shell/src/App.tsx` 的事件投影、`ProjectSupervisorView` 过程卡渲染与对应 AppSurface 回归。 -- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 心跳不覆盖具体命令、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 +- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留,并覆盖全部 AGC MCP 工具语义、未知工具不泄漏、绝对路径 / 上跳路径不展示;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 与写文件心跳不覆盖具体工作、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 ---