将 AGC 诊断日志改为持久化原始日志

Rust app_log 输出同时保留 stderr 并写入 AppData application.log。

将 WebView console 输出镜像到同一普通文本日志并保留滚动备份。

移除结构化错误事件写盘路径,事件仅在内存中合并并于提交时生成 events.jsonl。

清理旧 Tauri command、共享类型和同步更新诊断上传文档。
This commit is contained in:
2026-09-01 11:16:26 +08:00
parent c9585fffb5
commit 734ae175b0
16 changed files with 183 additions and 106 deletions
@@ -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::<Vec<_>>())
.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()));
}
@@ -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,
@@ -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 进度面板,便于在界面上看到重试(无需盯命令行)。
@@ -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;
@@ -39,7 +39,7 @@ fn repo_root() -> Option<PathBuf> {
// 便于排查 “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());
}
@@ -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<PathBuf> = 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 <AppData 绝对路径>"
);
std::process::exit(1);
}
};
let Some(config_dir) = runtime_config_dir else {
eprintln!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
app_log!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
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);
}
};
@@ -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(),
@@ -400,27 +400,27 @@ pub(crate) async fn merge_ui_impl_with_provider(
provider_identity: Option<(&str, &str)>,
) -> Result<MergeDTO, String> {
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::<llm_contract::MergeResponse>(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 })
@@ -593,11 +593,11 @@ pub(crate) async fn recognize_ui_impl_with_provider(
provider_identity: Option<(&str, &str)>,
) -> Result<RecognitionDTO, String> {
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::<RecognitionResponse>(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()
);
@@ -135,11 +135,11 @@ pub(crate) async fn suggest_ui_design_semantic_impl(
state: State,
) -> Result<Vec<UIDesignSuggestionTreeNode>, 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::<UIDesignSuggestion>(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)
@@ -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);
};
@@ -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<string, ClientErrorEvent>();
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<WebviewLogLevel, (...args: unknown[]) => 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());
}
@@ -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();
});
});
@@ -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`。
@@ -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`、处理备注和受控下载。
@@ -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 {