From 734ae175b0f3706762a59436c73ee6438c706922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 11:16:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=20AGC=20=E8=AF=8A=E6=96=AD=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=94=B9=E4=B8=BA=E6=8C=81=E4=B9=85=E5=8C=96=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust app_log 输出同时保留 stderr 并写入 AppData application.log。 将 WebView console 输出镜像到同一普通文本日志并保留滚动备份。 移除结构化错误事件写盘路径,事件仅在内存中合并并于提交时生成 events.jsonl。 清理旧 Tauri command、共享类型和同步更新诊断上传文档。 --- .../src-tauri/src/agent/codex_app_server.rs | 18 ++--- .../src/agent/codex_provider_proxy.rs | 4 +- .../agent/generation/loop_orchestration.rs | 2 +- .../agent/runtime_driver/provider_recovery.rs | 2 +- .../src-tauri/src/debug/debug_drafts.rs | 16 ++-- .../src-tauri/src/main.rs | 76 +++++++++++-------- .../src/ui_editor/commands/binding.rs | 2 +- .../src-tauri/src/ui_editor/commands/merge.rs | 28 +++---- .../src/ui_editor/commands/recognition.rs | 28 +++---- .../commands/ui_design_suggestion.rs | 26 +++---- .../src/app/AuthenticatedClient.tsx | 7 +- .../src/services/errorReporting.ts | 56 ++++++++++++-- .../tests/errorReporting.test.ts | 17 +++++ .../shared-memory/decision-log.md | 1 + ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 4 +- .../shared-contracts/src/error_reports.rs | 2 - 16 files changed, 183 insertions(+), 106 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 5a1d30e9f..636e9c990 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 @@ -1545,7 +1545,7 @@ impl CodexAppServerConnection { None }; if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() { - eprintln!( + app_log!( "agent.direct_codex.provider_proxy configured={}", provider_proxy.is_some() ); @@ -1662,7 +1662,7 @@ impl CodexAppServerConnection { .await .map_err(platform_llm::LlmError::Transport)?; if let Some(reason) = remote_control_disable_reason { - eprintln!("agent.codex_app_server.remote_control disabled reason={reason}"); + app_log!("agent.codex_app_server.remote_control disabled reason={reason}"); } if let Some(skill_root) = connection.inner._skill_root.as_ref() { connection @@ -2366,7 +2366,7 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(serde_json::Value::as_object) .map(|params| params.keys().cloned().collect::>()) .unwrap_or_default(); - eprintln!( + app_log!( "agent.direct_codex.event method={} paramsKeys={:?}", method, params_keys ); @@ -2376,7 +2376,7 @@ async fn read_game_creator_codex_app_server_stdout( .map(serde_json::Value::to_string) .map(|value| value.len()) .unwrap_or_default(); - eprintln!( + app_log!( "agent.direct_codex.event.safeDetails method={} detailBytes={}", method, detail_bytes ); @@ -2396,7 +2396,7 @@ async fn read_game_creator_codex_app_server_stdout( .pointer("/turn/status") .and_then(serde_json::Value::as_str) .unwrap_or(""); - eprintln!( + app_log!( "agent.direct_codex.event notification={} status={}", event_name, status ); @@ -2415,7 +2415,7 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(serde_json::Value::as_array) .map(Vec::len) .unwrap_or_default(); - eprintln!( + app_log!( "agent.direct_codex.item event={} type={} commandPresent={} changeCount={}", event_name, item_type, @@ -2441,7 +2441,7 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(|params| params.get("grantRoot")) .and_then(serde_json::Value::as_str); if direct_workspace && direct_debug { - eprintln!( + app_log!( "agent.direct_codex.server_request method={method} grantRootPresent={}", requested_grant_root.is_some() ); @@ -2632,7 +2632,7 @@ async fn read_game_creator_codex_app_server_stderr( if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() && upgraded.workspace_mode.uses_direct_conversation() { - eprintln!("agent.direct_codex.stderr bytes={count}"); + app_log!("agent.direct_codex.stderr bytes={count}"); } let oversized_record = upgraded .stderr_summary @@ -2747,7 +2747,7 @@ async fn fail_game_creator_codex_app_server_connection( .unwrap_or_else(|| "unknown".to_string()); let stderr = inner.stderr_summary.lock().await.diagnostic(); let diagnostic = format!("{error};exitStatus={exit_status};{stderr}"); - eprintln!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); + app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); for (_, pending) in inner.pending.lock().await.drain() { let _ = pending.sender.send(Err(diagnostic.clone())); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index f4d82651c..d046cdcb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -111,7 +111,7 @@ async fn proxy_codex_provider_request( } let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some(); if direct_debug { - eprintln!( + app_log!( "agent.direct_codex.provider_proxy.request method={} path={}", request.method(), request.uri().path(), @@ -174,7 +174,7 @@ async fn proxy_codex_provider_request( } } if direct_debug { - eprintln!( + app_log!( "agent.direct_codex.provider_proxy.response status={} strippedCodexHeaders={}", status.as_u16(), stripped_limit_headers, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index 43a6aa0ed..e955f53a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -462,7 +462,7 @@ pub(crate) async fn request_generator_game_draft_with_client( Ok(response) => break response, Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { empty_retries += 1; - eprintln!( + app_log!( "llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)" ); // 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 8e3368d3e..260d4f5be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -141,7 +141,7 @@ pub(crate) fn schedule_waiting_autonomous_manifest_parent_wake_after_lane_releas "error": error, }), ); - eprintln!("项目任务图自动唤醒状态持久化失败:{error}"); + app_log!("项目任务图自动唤醒状态持久化失败:{error}"); } if !singleflight.finish_pass() { return; diff --git a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs index 65a9b9c69..ebd0ab68a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs @@ -39,7 +39,7 @@ fn repo_root() -> Option { // 便于排查 “EOF while parsing a string” 这类输出截断问题。尽力而为,不阻断主流程。 pub(crate) fn persist_snapshot(raw_content: &str) { let Some(repo_root) = repo_root() else { - eprintln!("llm.draft.snapshot.skip: 未能定位仓库根目录"); + app_log!("llm.draft.snapshot.skip: 未能定位仓库根目录"); return; }; let dir = repo_root @@ -47,12 +47,12 @@ pub(crate) fn persist_snapshot(raw_content: &str) { .join("ai-game-creator-shell") .join(".llm-drafts"); if let Err(error) = fs::create_dir_all(&dir) { - eprintln!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display()); + app_log!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display()); return; } let path = dir.join(format!("draft-{}.txt", unix_millis())); if let Err(error) = fs::write(&path, raw_content) { - eprintln!( + app_log!( "llm.draft.snapshot.write.failed: {}: {error}", path.display() ); @@ -61,14 +61,14 @@ pub(crate) fn persist_snapshot(raw_content: &str) { // 同步更新 latest.txt,方便直接打开最近一次草案。 let latest = dir.join("latest.txt"); let _ = fs::write(&latest, raw_content); - eprintln!("llm.draft.snapshot.saved: {}", path.display()); + app_log!("llm.draft.snapshot.saved: {}", path.display()); } // LLM 调用失败(例如返回内容为空)时,把本次发送给模型的输入(system + 完整 user prompt) // 连同错误信息一起落盘到 .llm-drafts/,便于按同样输入复现与定位。尽力而为,不阻断主流程。 pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: &str) { let Some(repo_root) = repo_root() else { - eprintln!("llm.draft.error-input.skip: 未能定位仓库根目录"); + app_log!("llm.draft.error-input.skip: 未能定位仓库根目录"); return; }; let dir = repo_root @@ -76,7 +76,7 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: .join("ai-game-creator-shell") .join(".llm-drafts"); if let Err(io_error) = fs::create_dir_all(&dir) { - eprintln!( + app_log!( "llm.draft.error-input.dir.failed: {}: {io_error}", dir.display() ); @@ -87,7 +87,7 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: ); let path = dir.join(format!("error-input-{}.txt", unix_millis())); if let Err(io_error) = fs::write(&path, &body) { - eprintln!( + app_log!( "llm.draft.error-input.write.failed: {}: {io_error}", path.display() ); @@ -96,5 +96,5 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: // 同步更新 latest-error-input.txt,方便直接打开最近一次失败输入。 let latest = dir.join("latest-error-input.txt"); let _ = fs::write(&latest, &body); - eprintln!("llm.draft.error-input.saved: {}", path.display()); + app_log!("llm.draft.error-input.saved: {}", path.display()); } 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 114d1c189..38a75ccc2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -39,11 +39,21 @@ use shared_contracts::game_creation_app::{ GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, }; -use shared_contracts::error_reports::{ErrorReportEventInput, ErrorReportLogInput}; +use shared_contracts::error_reports::ErrorReportLogInput; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; +/// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。 +/// 诊断包只在用户主动提交时读取这些 raw log;结构化错误事件仍只留在内存。 +macro_rules! app_log { + ($($arg:tt)*) => {{ + let message = format!($($arg)*); + let _ = $crate::append_application_log_line(&format!("RUST {}: {}", module_path!(), message)); + std::eprintln!("{}", message); + }}; +} + // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; @@ -1565,22 +1575,14 @@ static STARTUP_PANIC_LOG_PATH: OnceLock = OnceLock::new(); static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); #[tauri::command] -fn append_diagnostic_event(event: ErrorReportEventInput) -> Result<(), String> { +fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { let config_dir = game_creator_runtime_config_dir() .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; - let path = config_dir.join("diagnostics/application.log"); - let mut event = event; - event.event_id = sanitize_diagnostic_message(&event.event_id, Some(&config_dir)); - event.fingerprint = sanitize_diagnostic_message(&event.fingerprint, Some(&config_dir)); - event.source = sanitize_diagnostic_message(&event.source, Some(&config_dir)); - event.message = sanitize_diagnostic_message(&event.message, Some(&config_dir)); - event.stack = event - .stack - .take() - .map(|value| sanitize_diagnostic_message(&value, Some(&config_dir))); - event.occurred_at = sanitize_diagnostic_message(&event.occurred_at, Some(&config_dir)); - let line = serde_json::to_string(&event).map_err(|error| error.to_string())?; - append_bounded_diagnostic_line(&path, &line).map_err(|error| error.to_string()) + let level = sanitize_diagnostic_message(&level, Some(&config_dir)); + let source = sanitize_diagnostic_message(&source, Some(&config_dir)); + let message = sanitize_diagnostic_message(&message, Some(&config_dir)); + let line = format!("WEBVIEW {level} {source}: {message}"); + append_application_log_line(&line).map_err(|error| error.to_string()) } #[tauri::command] @@ -1712,6 +1714,14 @@ pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io append_bounded_diagnostic_line_with_limit(path, line, DIAGNOSTIC_LOG_MAX_BYTES) } +pub(crate) fn append_application_log_line(line: &str) -> std::io::Result<()> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(()); + }; + let sanitized = sanitize_diagnostic_message(line, Some(&config_dir)); + append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) +} + fn redact_windows_absolute_paths(value: &str) -> String { let bytes = value.as_bytes(); let mut output = String::with_capacity(value.len()); @@ -1800,7 +1810,7 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat } fn show_startup_error_dialog(log_path: &Path) { - eprintln!("Genarrative startup failed; see {}", log_path.display()); + app_log!("Genarrative startup failed; see {}", log_path.display()); } #[derive(Clone, Debug)] @@ -1890,16 +1900,16 @@ where fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { if matches!(event, tauri::RunEvent::Exit) { if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { - eprintln!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); + app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); } } match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { - eprintln!("agent.runner.gui_exit.shutdown_requested") + app_log!("agent.runner.gui_exit.shutdown_requested") } GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { - eprintln!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) + app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) } } } @@ -1926,7 +1936,7 @@ fn install_agent_runtime_async_runtime_with_deep_stack() { let runtime = match build_agent_runtime_async_runtime() { Ok(runtime) => runtime, Err(error) => { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } }; @@ -1996,7 +2006,7 @@ fn main() { let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { Ok(config_dir) => config_dir, Err(error) => { - eprintln!("{error}"); + app_log!("{error}"); std::process::exit(1); } }; @@ -2005,23 +2015,23 @@ fn main() { [_] => false, [_, option] if option == "--gui-owner-required" => true, _ => { - eprintln!( + app_log!( "用法:--agent-runner [--gui-owner-required] --config-dir " ); std::process::exit(1); } }; let Some(config_dir) = runtime_config_dir else { - eprintln!("Agent Runner 必须显式传入 --config-dir "); + app_log!("Agent Runner 必须显式传入 --config-dir "); std::process::exit(1); }; if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } set_game_creator_runtime_config_dir(config_dir.clone()); if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } return; @@ -2032,13 +2042,13 @@ fn main() { match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { Ok(config_dir) => config_dir, Err(error) => { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } }; if let Some(config_dir) = config_dir { if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } if command.requires_external_agent_runner() { @@ -2048,7 +2058,7 @@ fn main() { configure_external_agent_runner(&config_dir) }; if let Err(error) = configured { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } } @@ -2056,19 +2066,19 @@ fn main() { } if command.requires_started_external_agent_runner() { if let Err(error) = ensure_external_agent_runner_started() { - eprintln!("agent.runner.failed: {error}"); + app_log!("agent.runner.failed: {error}"); std::process::exit(1); } } if let Err(error) = run_cli_command(command) { - eprintln!("agent.run.failed: {error}"); + app_log!("agent.run.failed: {error}"); std::process::exit(1); } return; } Ok(None) => {} Err(error) => { - eprintln!("{error}"); + app_log!("{error}"); std::process::exit(1); } } @@ -2342,7 +2352,7 @@ fn main() { commit_local_project_asset_canvas_candidate, get_local_game_project_revision, get_local_game_manifest, - append_diagnostic_event, + append_application_log, read_diagnostic_logs ]) .build(tauri_context); @@ -2362,7 +2372,7 @@ fn main() { ); show_startup_error_dialog(path); } - eprintln!("failed to build Genarrative AI Game Creator shell: {error}"); + app_log!("failed to build Genarrative AI Game Creator shell: {error}"); std::process::exit(1); } }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index 29ba2ab80..50ca55efb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -392,7 +392,7 @@ pub(crate) async fn bind_components_impl_with_provider( &known_sprite_ids, &known_font_ids, )?; - eprintln!( + app_log!( "ui_binding.completed ui_images={} sprites={} editable_nodes={} changes={}", state.ui_design_images.len(), sprite_ids.len(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index aeaadfd1e..fc841affe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -400,27 +400,27 @@ pub(crate) async fn merge_ui_impl_with_provider( provider_identity: Option<(&str, &str)>, ) -> Result { if state.ui_trees.is_empty() { - eprintln!("ui_merge.error stage=validate reason=no_trees"); + app_log!("ui_merge.error stage=validate reason=no_trees"); return Err("请先完成 UI 结构识别".to_string()); } validate_merge_input_state(&state).map_err(|error| { - eprintln!("ui_merge.error stage=validate_input error={error}"); + app_log!("ui_merge.error stage=validate_input error={error}"); error })?; let priorities = priority::for_state(&state).map_err(|error| { - eprintln!("ui_merge.error stage=build_priority error={error}"); + app_log!("ui_merge.error stage=build_priority error={error}"); error })?; let trees = llm_contract::input_trees(&state, &priorities).map_err(|error| { - eprintln!("ui_merge.error stage=build_input error={error}"); + app_log!("ui_merge.error stage=build_input error={error}"); error })?; let records_json = serde_json::to_string(&trees).map_err(|error| { - eprintln!("ui_merge.error stage=serialize_input error={error}"); + app_log!("ui_merge.error stage=serialize_input error={error}"); format!("序列化 UI 合并输入失败:{error}") })?; if records_json.len() > MAX_MERGE_INPUT_BYTES { - eprintln!( + app_log!( "ui_merge.error stage=serialize_input reason=too_large bytes={} limit={MAX_MERGE_INPUT_BYTES}", records_json.len() ); @@ -429,7 +429,7 @@ pub(crate) async fn merge_ui_impl_with_provider( let client = if provider_identity.is_none() { Some( build_game_creator_llm_client_from_config().map_err(|error| { - eprintln!("ui_merge.error stage=build_client error={error}"); + app_log!("ui_merge.error stage=build_client error={error}"); error })?, ) @@ -437,7 +437,7 @@ pub(crate) async fn merge_ui_impl_with_provider( None }; let schema = llm_contract::schema().map_err(|error| { - eprintln!("ui_merge.error stage=build_schema error={error}"); + app_log!("ui_merge.error stage=build_schema error={error}"); error })?; let tool = LlmFunctionTool::new( @@ -470,7 +470,7 @@ pub(crate) async fn merge_ui_impl_with_provider( .await } .map_err(|error| { - eprintln!("ui_merge.error stage=llm_request error={error}"); + app_log!("ui_merge.error stage=llm_request error={error}"); format!("UI 树合并失败:{error}") })?; let call = response @@ -478,24 +478,24 @@ pub(crate) async fn merge_ui_impl_with_provider( .iter() .find(|call| call.name == MERGE_TOOL_NAME) .ok_or_else(|| { - eprintln!("ui_merge.error stage=parse_tool_call reason=missing_tool_call"); + app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call"); format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用") })?; let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| { - eprintln!("ui_merge.error stage=parse_arguments error={error}"); + app_log!("ui_merge.error stage=parse_arguments error={error}"); format!("UI 合并工具参数无效:{error}") })?; validate_merge_plan_shape(&arguments).map_err(|error| { - eprintln!("ui_merge.error stage=validate_arguments error={error}"); + app_log!("ui_merge.error stage=validate_arguments error={error}"); format!("UI 合并工具参数无效:{error}") })?; let parsed = serde_json::from_value::(arguments).map_err(|error| { - eprintln!("ui_merge.error stage=parse_arguments error={error}"); + app_log!("ui_merge.error stage=parse_arguments error={error}"); format!("UI 合并工具参数无效:{error}") })?; let ui_tree = materialize::plan(parsed.root, &state, &priorities).map_err(|error| { - eprintln!("ui_merge.error stage=materialize error={error}"); + app_log!("ui_merge.error stage=materialize error={error}"); error })?; Ok(MergeDTO { ui_tree }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index ce6b8bdab..cf5bf5b79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -593,11 +593,11 @@ pub(crate) async fn recognize_ui_impl_with_provider( provider_identity: Option<(&str, &str)>, ) -> Result { if state.ui_design_images.is_empty() { - eprintln!("ui_recognition.error stage=validate reason=no_images"); + app_log!("ui_recognition.error stage=validate reason=no_images"); return Err("请先导入界面图".to_string()); } if state.ui_design_images.len() > MAX_REFERENCES { - eprintln!( + app_log!( "ui_recognition.error stage=validate reason=too_many_images count={}", state.ui_design_images.len() ); @@ -605,13 +605,13 @@ pub(crate) async fn recognize_ui_impl_with_provider( } let root_ids = recognition_root_image_ids(&state); if root_ids.is_empty() { - eprintln!("ui_recognition.error stage=validate reason=no_root_image"); + app_log!("ui_recognition.error stage=validate reason=no_root_image"); return Err("至少需要一张可作为识别上下文根的界面图".to_string()); } let client = if provider_identity.is_none() { Some( build_game_creator_llm_client_from_config().map_err(|error| { - eprintln!("ui_recognition.error stage=build_client error={error}"); + app_log!("ui_recognition.error stage=build_client error={error}"); error })?, ) @@ -619,7 +619,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( None }; let schema = recognition_json_schema().map_err(|error| { - eprintln!("ui_recognition.error stage=build_schema error={error}"); + app_log!("ui_recognition.error stage=build_schema error={error}"); error })?; let root = Path::new(project_path.trim()); @@ -637,7 +637,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( .ok_or_else(|| "缺少识别上下文界面图资源".to_string())?; let absolute = crate::project::resolve_local_project_path(root, &image.path).map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=resolve_image root={} image={} error={error}", root_id.as_str(), context_id.as_str() @@ -647,7 +647,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( let image_url = read_ui_reference_image_data_url(absolute) .await .map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=read_image root={} image={} error={error}", root_id.as_str(), context_id.as_str() @@ -694,13 +694,13 @@ pub(crate) async fn recognize_ui_impl_with_provider( .await } .map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=llm_request root={} error={error}", root_id.as_str() ); format!("UI 结构识别失败(根界面图 {}):{error}", root_id.as_str()) })?; - eprintln!( + app_log!( "ui_recognition.llm_output root={} text_present={} tool_call_count={}", root_id.as_str(), !response.text.trim().is_empty(), @@ -711,7 +711,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( .iter() .find(|call| call.name == "recognize_ui_structure") .ok_or_else(|| { - eprintln!( + app_log!( "ui_recognition.error stage=parse_tool_call root={} reason=missing_tool_call", root_id.as_str() ); @@ -721,28 +721,28 @@ pub(crate) async fn recognize_ui_impl_with_provider( ) })?; let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=parse_arguments root={} error={error}", root_id.as_str() ); format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str()) })?; validate_recognition_response_shape(&arguments).map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=validate_arguments root={} error={error}", root_id.as_str() ); format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str()) })?; let parsed = serde_json::from_value::(arguments).map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=parse_arguments root={} error={error}", root_id.as_str() ); format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str()) })?; validate_tree_image_ids(&parsed.trees, &context_ids).map_err(|error| { - eprintln!( + app_log!( "ui_recognition.error stage=validate_trees root={} error={error}", root_id.as_str() ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs index 296835e62..975d25edd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs @@ -135,11 +135,11 @@ pub(crate) async fn suggest_ui_design_semantic_impl( state: State, ) -> Result, String> { if state.ui_design_images.is_empty() { - eprintln!("ui_design_suggestion.error stage=validate reason=no_images"); + app_log!("ui_design_suggestion.error stage=validate reason=no_images"); return Err("请先导入界面图".to_string()); } if state.ui_design_images.len() > MAX_REFERENCES { - eprintln!( + app_log!( "ui_design_suggestion.error stage=validate reason=too_many_images count={}", state.ui_design_images.len() ); @@ -152,7 +152,7 @@ pub(crate) async fn suggest_ui_design_semantic_impl( ids.insert(id.clone()); let absolute = crate::project::resolve_local_project_path(root, &image.path).map_err(|error| { - eprintln!( + app_log!( "ui_design_suggestion.error stage=resolve_image id={} error={error}", id.as_str() ); @@ -161,7 +161,7 @@ pub(crate) async fn suggest_ui_design_semantic_impl( let image_url = read_ui_reference_image_data_url(absolute) .await .map_err(|error| { - eprintln!( + app_log!( "ui_design_suggestion.error stage=read_image id={} error={error}", id.as_str() ); @@ -173,11 +173,11 @@ pub(crate) async fn suggest_ui_design_semantic_impl( parts.push(LlmMessageContentPart::InputImage { image_url }); } let client = build_game_creator_llm_client_from_config().map_err(|error| { - eprintln!("ui_design_suggestion.error stage=build_client error={error}"); + app_log!("ui_design_suggestion.error stage=build_client error={error}"); error })?; let schema = ui_design_suggestion_json_schema().map_err(|error| { - eprintln!("ui_design_suggestion.error stage=build_schema error={error}"); + app_log!("ui_design_suggestion.error stage=build_schema error={error}"); error })?; let tool = LlmFunctionTool::new( @@ -197,10 +197,10 @@ pub(crate) async fn suggest_ui_design_semantic_impl( ) .await .map_err(|error| { - eprintln!("ui_design_suggestion.error stage=llm_request error={error}"); + app_log!("ui_design_suggestion.error stage=llm_request error={error}"); format!("UI 参考图语义识别失败:{error}") })?; - eprintln!( + app_log!( "ui_design_suggestion.llm_output text_present={} tool_call_count={}", !response.text.trim().is_empty(), response.tool_calls.len() @@ -210,25 +210,25 @@ pub(crate) async fn suggest_ui_design_semantic_impl( .iter() .find(|call| call.name == "suggest_ui_design_semantics") .ok_or_else(|| { - eprintln!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call"); + app_log!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call"); "LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string() })?; let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| { - eprintln!("ui_design_suggestion.error stage=parse_arguments error={error}"); + app_log!("ui_design_suggestion.error stage=parse_arguments error={error}"); format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error})") })?; validate_suggestion_response_shape(&arguments).map_err(|error| { - eprintln!("ui_design_suggestion.error stage=validate_arguments error={error}"); + app_log!("ui_design_suggestion.error stage=validate_arguments error={error}"); format!("LLM 响应无效(详情:{error})") })?; let suggestions = serde_json::from_value::(arguments) .map_err(|error| { - eprintln!("ui_design_suggestion.error stage=parse_arguments error={error}"); + app_log!("ui_design_suggestion.error stage=parse_arguments error={error}"); format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error})") })? .ui_designs; validate_suggestions(&suggestions, &ids).map_err(|error| { - eprintln!("ui_design_suggestion.error stage=validate_result error={error}"); + app_log!("ui_design_suggestion.error stage=validate_result error={error}"); format!("LLM 响应无效(详情:{error})") })?; Ok(suggestions) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 06d541e6b..3997f5540 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -30,7 +30,10 @@ import { normalizeClientServerBaseUrl, setClientServerSelection, } from '../services/clientHttp'; -import { captureClientError } from '../services/errorReporting'; +import { + captureClientError, + installWebviewLogBridge, +} from '../services/errorReporting'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -128,6 +131,7 @@ export function AuthenticatedClient({ ); useEffect(() => { + const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { void captureClientError(event.error ?? event.message, { source: 'window.onerror', @@ -139,6 +143,7 @@ export function AuthenticatedClient({ window.addEventListener('error', handleError); window.addEventListener('unhandledrejection', handleRejection); return () => { + uninstallWebviewLogBridge(); window.removeEventListener('error', handleError); window.removeEventListener('unhandledrejection', handleRejection); }; diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index c610efa95..70a8cf281 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -15,6 +15,8 @@ export type ClientErrorEvent = { export type DiagnosticLogFile = { name: string; content: string }; +type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'; + const pending = new Map(); const listeners = new Set<() => void>(); const MAX_EVENTS = 100; @@ -81,15 +83,59 @@ export async function captureClientError( if (oldest) pending.delete(oldest); } pending.set(fingerprint, event); - try { - await invoke('append_diagnostic_event', { event }); - } catch { - // 浏览器预览和未初始化 Tauri 时仍保留内存事件;不得阻断主流程。 - } notify(); return event; } +function formatConsoleArgument(value: unknown) { + if (value instanceof Error) return value.stack || value.message; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function appendWebviewLog(level: WebviewLogLevel, values: unknown[]) { + const message = values.map(formatConsoleArgument).join(' ').slice(0, 8_000); + void invoke('append_application_log', { + level, + source: 'webview', + message, + }).catch(() => { + // 浏览器预览或 Tauri 尚未初始化时不阻断主流程。 + }); +} + +/** 将 WebView console 输出镜像到 Rust 的普通文本 application.log。 */ +export function installWebviewLogBridge() { + const marker = '__agcWebviewLogBridgeInstalled'; + const target = globalThis as typeof globalThis & { [marker]?: boolean }; + const targetConsole = globalThis.console; + if (target[marker] || !targetConsole) return () => {}; + target[marker] = true; + const levels: WebviewLogLevel[] = ['debug', 'info', 'warn', 'error', 'log']; + const originals = new Map void>(); + for (const level of levels) { + const original = targetConsole[level].bind(targetConsole) as ( + ...args: unknown[] + ) => void; + originals.set(level, original); + targetConsole[level] = (...args: unknown[]) => { + original(...args); + appendWebviewLog(level, args); + }; + } + return () => { + for (const level of levels) { + const original = originals.get(level); + if (original) targetConsole[level] = original; + } + delete target[marker]; + }; +} + export function getPendingClientErrorEvents() { return Array.from(pending.values()); } diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index ecff07e7b..ccb91162d 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -13,10 +13,13 @@ vi.mock('../src/services/clientHttp', () => ({ getClientServerBaseUrl: vi.fn(() => 'https://example.test'), })); +import { invoke } from '@tauri-apps/api/core'; + import { fetchClientHttp } from '../src/services/clientHttp'; import { captureClientError, getPendingClientErrorEvents, + installWebviewLogBridge, markClientErrorEventsSubmitted, resetClientErrorEventsForTests, submitErrorReportBatch, @@ -76,4 +79,18 @@ describe('客户端错误报告池', () => { ).rejects.toThrow('请先登录'); expect(fetchClientHttp).not.toHaveBeenCalled(); }); + + it('将 WebView console 输出写入普通文本日志 command', async () => { + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}); + const uninstall = installWebviewLogBridge(); + console.info('hello', { count: 1 }); + await Promise.resolve(); + expect(invoke).toHaveBeenCalledWith('append_application_log', { + level: 'info', + source: 'webview', + message: 'hello {"count":1}', + }); + uninstall(); + consoleInfo.mockRestore(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ac167e0e7..4217ab900 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7863,3 +7863,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 上传接口为登录态 `/api/error-reports`,后台新增 error-reports Tab、专用文件化诊断包、状态与受控下载;管理员查看/下载进入审计链路。 - `/bug-report` 仅作为打开该面板的快捷入口,追加简短提示,不再生成包含项目、run 或截图口径的缺陷模板。 - 2026-08-31 追加:事件 DTO 精简为 `eventId/fingerprint/source/message/stack/occurredAt/count`,提交请求携带 `submissionId` 做幂等。归档固定为 `events.jsonl`,服务端使用 `agc/error-reports/v1/{yyyy}/{mm}/{dd}/{batchId}.zip` 私有 OSS key;元数据只保留 batch、用户、状态、大小、SHA-256 和 OSS key,事件正文/说明/日志从归档读取。OSS 不可用时状态为 `failed`,不自动重试。 +- 2026-09-01 追加:`application.log` 不再写结构化错误事件;Rust `app_log!` 和 WebView console 都写入普通文本 raw log,结构化事件仅保留在当前进程内,提交时才生成 ZIP 内的 `events.jsonl`。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 861d2e930..822d5eea1 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,14 +8,14 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 捕获 React render error、`window.onerror`、`unhandledrejection` 以及显式标记的 Tauri/API/Agent 错误。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 -- Tauri `append_diagnostic_event` 写入 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;`read_diagnostic_logs` 只读取应用级日志。 +- Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log;`read_diagnostic_logs` 只读取应用级日志。 - 报告面板允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。 ## HTTP 与存储 - 登录态客户端使用 `POST /api/error-reports`,请求 DTO 位于 `shared-contracts::error_reports`。 -- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与基础脱敏,归档使用 `events.jsonl`(每行一个事件)。submissionId 提供重放幂等。 +- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与基础脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等。 - 归档对象使用固定私有 OSS key:`agc/error-reports/v1/{yyyy}/{mm}/{dd}/{batchId}.zip`;api-server 先写 `uploading` 元数据,上传成功后记录 `ossObjectKey`、SHA-256、大小和 `ready` 状态。完整事件、说明和日志不进入元数据记录。 - 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}`、`GET /admin/api/error-reports` 和受保护的 `/download`。 - admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、详情、状态 `new/in-progress/resolved`、处理备注和受控下载。 diff --git a/server-rs/crates/shared-contracts/src/error_reports.rs b/server-rs/crates/shared-contracts/src/error_reports.rs index fb5adcabb..033b4f993 100644 --- a/server-rs/crates/shared-contracts/src/error_reports.rs +++ b/server-rs/crates/shared-contracts/src/error_reports.rs @@ -12,8 +12,6 @@ pub struct Event { pub count: u32, } -pub type ErrorReportEventInput = Event; - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ErrorReportLogInput {