将 AGC 诊断日志改为持久化原始日志
Rust app_log 输出同时保留 stderr 并写入 AppData application.log。 将 WebView console 输出镜像到同一普通文本日志并保留滚动备份。 移除结构化错误事件写盘路径,事件仅在内存中合并并于提交时生成 events.jsonl。 清理旧 Tauri command、共享类型和同步更新诊断上传文档。
This commit is contained in:
@@ -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()
|
||||
);
|
||||
|
||||
+13
-13
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user