From 584eabd1fd6850e78310aa1b281e2eb39e0075ab 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 19:52:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20AGC=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E4=B8=BA=20Rust=20=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Rust 内存错误队列、脱敏、通知聚合和 Tauri commands 前端改用 bridge,拆分通知与报告弹窗 UI 补充 Vitest、Rust 单测及错误报告技术方案 --- .../src-tauri/Cargo.lock | 1 + .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/commands.rs | 28 ++- .../src-tauri/src/error_report/commands.rs | 38 +++ .../src-tauri/src/error_report/mod.rs | 10 + .../src/error_report/notifications.rs | 58 +++++ .../src-tauri/src/error_report/queue.rs | 219 ++++++++++++++++++ .../src-tauri/src/error_report/sanitize.rs | 82 +++++++ .../src-tauri/src/main.rs | 11 +- .../src/app/AuthenticatedClient.tsx | 4 +- .../error-report/ErrorReportDialog.tsx | 81 +------ .../error-report/ErrorReportNotice.tsx | 58 +++++ .../src/services/errorReporting.ts | 114 +++------ .../src/services/errorReportingBridge.ts | 44 ++++ .../tests/errorReporting.test.ts | 79 ++++++- ...术方案】AGC错误报告与诊断上传-2026-08-31.md | 3 +- 16 files changed, 651 insertions(+), 180 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/error_report/commands.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs create mode 100644 apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx create mode 100644 apps/ai-game-creator-shell/src/services/errorReportingBridge.ts diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index a6936c44b..0bf4cb125 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1725,6 +1725,7 @@ dependencies = [ "platform-agent", "platform-llm", "portable-pty", + "regex", "reqwest 0.12.28", "schemars 1.2.1", "serde", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 318951ed9..7df34a0a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ platform-llm = { path = "../../../server-rs/crates/platform-llm" } platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } +regex = "1" shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 73221b283..2fd648f43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1671,7 +1671,7 @@ pub(crate) fn decide_game_creator_plan_gdd( enforce_project_permission_policy(&root, "conversation.write")?; enforce_project_permission_policy(&root, "agent.run_status")?; enforce_project_permission_policy(&root, "agent.resume")?; - let mut result = decide_plan_gdd_at( + let mut result = match decide_plan_gdd_at( &root, &DecidePlanGddInputV1 { gdd_id, @@ -1683,10 +1683,30 @@ pub(crate) fn decide_game_creator_plan_gdd( action, comment, }, - ) - .map_err(|error| error.to_string())?; + ) { + Ok(result) => result, + Err(error) => { + let detail = error.to_string(); + crate::error_report::report_diagnostic_error( + "tauri", + &detail, + None, + Some("decide_game_creator_plan_gdd"), + None, + ); + return Err(detail); + } + }; if !result.recovery_pending { - if wake_pending_game_creator_agent_background_tasks_at(&root).is_err() { + if let Err(error) = wake_pending_game_creator_agent_background_tasks_at(&root) { + let detail = error.to_string(); + crate::error_report::report_diagnostic_error( + "agent", + &detail, + None, + Some("wake_pending_game_creator_agent_background_tasks"), + None, + ); // The receipt is already the user-decision linearization point; // surface a recoverable projection state instead of turning a // durable approval into a false command failure. diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/commands.rs new file mode 100644 index 000000000..3658a6080 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/commands.rs @@ -0,0 +1,38 @@ +use tauri::command; + +use super::queue::{ack, mark_notified, report_diagnostic_error, snapshot, ErrorReportEvent}; + +#[command] +pub fn report_client_error( + source: String, + message: String, + stack: Option, + action: Option, + page: Option, +) -> Result { + report_diagnostic_error( + &source, + &message, + stack.as_deref(), + action.as_deref(), + page.as_deref(), + ) + .ok_or_else(|| "错误消息不能为空".to_string()) +} + +#[command] +pub fn get_pending_error_reports() -> Vec { + snapshot() +} + +#[command] +pub fn mark_error_reports_notified(event_ids: Vec) -> Result<(), String> { + mark_notified(&event_ids); + Ok(()) +} + +#[command] +pub fn ack_error_reports(event_ids: Vec) -> Result<(), String> { + ack(&event_ids); + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs new file mode 100644 index 000000000..36b0de727 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs @@ -0,0 +1,10 @@ +mod commands; +mod notifications; +mod queue; +mod sanitize; + +pub use commands::{ + ack_error_reports, get_pending_error_reports, mark_error_reports_notified, report_client_error, +}; +pub use notifications::initialize_notifications; +pub use queue::report_diagnostic_error; diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs new file mode 100644 index 000000000..1095f0ab5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs @@ -0,0 +1,58 @@ +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use tauri::{AppHandle, Emitter}; + +use super::queue::{mark_notified, snapshot, unnotified_count}; + +static APP_HANDLE: OnceLock = OnceLock::new(); +static TIMER_ACTIVE: OnceLock> = OnceLock::new(); + +pub fn initialize_notifications(app: &AppHandle) { + let _ = APP_HANDLE.set(app.clone()); + let _ = TIMER_ACTIVE.set(Mutex::new(false)); +} + +pub(crate) fn schedule_notification() { + let Some(handle) = APP_HANDLE.get().cloned() else { + return; + }; + let active = TIMER_ACTIVE.get_or_init(|| Mutex::new(false)); + { + let mut value = active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if *value { + return; + } + *value = true; + } + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + let events = snapshot(); + let ids: Vec = events + .iter() + .filter(|event| !event.notified) + .map(|event| event.event_id.clone()) + .collect(); + if !ids.is_empty() { + mark_notified(&ids); + let _ = handle.emit( + "error-report-updated", + serde_json::json!({ + "generation": events.len(), + "newCount": ids.len(), + "eventIds": ids, + }), + ); + } + if let Some(active) = TIMER_ACTIVE.get() { + *active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = false; + } + if unnotified_count() > 0 { + schedule_notification(); + } + }); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs new file mode 100644 index 000000000..7045f0fd9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs @@ -0,0 +1,219 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use super::notifications::schedule_notification; +use super::sanitize::{fingerprint, sanitize}; + +const MAX_EVENTS: usize = 100; +const MAX_SOURCE_CHARS: usize = 128; +const MAX_MESSAGE_CHARS: usize = 512; +const MAX_STACK_CHARS: usize = 8_000; +const MAX_ACTION_CHARS: usize = 128; +const MAX_PAGE_CHARS: usize = 128; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ErrorReportEvent { + pub event_id: String, + pub fingerprint: String, + pub source: String, + pub message: String, + pub stack: Option, + pub occurred_at: String, + pub last_occurred_at: String, + pub count: u32, + pub notified: bool, +} + +#[derive(Default)] +struct Queue { + events: HashMap, + order: VecDeque, + sequence: u64, +} + +static QUEUE: OnceLock> = OnceLock::new(); + +fn queue() -> &'static Mutex { + QUEUE.get_or_init(|| Mutex::new(Queue::default())) +} + +fn now() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{seconds}") +} + +pub fn report_diagnostic_error( + source: &str, + message: &str, + stack: Option<&str>, + action: Option<&str>, + page: Option<&str>, +) -> Option { + let raw_call_site = stack.and_then(|value| { + value.lines().find_map(|line| { + let trimmed = line.trim(); + trimmed.strip_prefix("at ").map(|site| { + site.trim_end_matches(|character: char| { + character.is_ascii_digit() + || character == ':' + || character == ')' + || character == '(' + }) + .trim() + .to_string() + }) + }) + }); + let source = sanitize(source, MAX_SOURCE_CHARS); + let message = sanitize(message, MAX_MESSAGE_CHARS); + let stack = stack.map(|value| sanitize(value, MAX_STACK_CHARS)); + let action = action.map(|value| sanitize(value, MAX_ACTION_CHARS)); + let page = page.map(|value| sanitize(value, MAX_PAGE_CHARS)); + if message.trim().is_empty() { + return None; + } + let fingerprint = fingerprint(&[ + &source, + action.as_deref().unwrap_or_default(), + page.as_deref().unwrap_or_default(), + &message, + raw_call_site.as_deref().unwrap_or_default(), + ]); + let timestamp = now(); + let (should_schedule, event) = { + let mut state = queue() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = state.events.get_mut(&fingerprint) { + existing.count = existing.count.saturating_add(1); + existing.last_occurred_at = timestamp; + (false, existing.clone()) + } else { + state.sequence = state.sequence.saturating_add(1); + let event_id = format!("rust-error-{}", state.sequence); + if state.order.len() >= MAX_EVENTS { + if let Some(oldest) = state.order.pop_front() { + state.events.remove(&oldest); + } + } + state.order.push_back(fingerprint.clone()); + let event = ErrorReportEvent { + event_id, + fingerprint, + source, + message, + stack, + occurred_at: timestamp.clone(), + last_occurred_at: timestamp, + count: 1, + notified: false, + }; + state + .events + .insert(event.fingerprint.clone(), event.clone()); + (true, event) + } + }; + if should_schedule { + schedule_notification(); + } + Some(event) +} + +pub(crate) fn snapshot() -> Vec { + let state = queue() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state + .order + .iter() + .filter_map(|fingerprint| state.events.get(fingerprint).cloned()) + .collect() +} + +pub(crate) fn mark_notified(event_ids: &[String]) { + let mut state = queue() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for event in state.events.values_mut() { + if event_ids.iter().any(|id| id == &event.event_id) { + event.notified = true; + } + } +} + +pub(crate) fn ack(event_ids: &[String]) { + let mut state = queue() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for event_id in event_ids { + let Some(fingerprint) = state.events.iter().find_map(|(fingerprint, event)| { + (event.event_id == *event_id).then_some(fingerprint.clone()) + }) else { + continue; + }; + state.events.remove(&fingerprint); + state.order.retain(|item| item != &fingerprint); + } +} + +pub(crate) fn unnotified_count() -> usize { + let state = queue() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state + .events + .values() + .filter(|event| !event.notified) + .count() +} + +#[cfg(test)] +pub(crate) fn reset_for_tests() { + let mut state = queue() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *state = Queue::default(); +} + +#[cfg(test)] +mod tests { + use super::{report_diagnostic_error, reset_for_tests, snapshot}; + + #[test] + fn merges_events_by_call_site_without_storing_second_queue() { + reset_for_tests(); + let first = report_diagnostic_error( + "test", + "boom", + Some("Error: boom\n at app.ts:10:2"), + None, + None, + ) + .expect("first event"); + let second = report_diagnostic_error( + "test", + "boom", + Some("Error: boom\n at app.ts:99:7"), + None, + None, + ) + .expect("merged event"); + assert_eq!(first.event_id, second.event_id); + assert_eq!(snapshot()[0].count, 2); + } + + #[test] + fn rejects_empty_messages() { + reset_for_tests(); + assert!(report_diagnostic_error("test", "\n", None, None, None).is_none()); + assert!(snapshot().is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs new file mode 100644 index 000000000..30def22d9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs @@ -0,0 +1,82 @@ +use sha2::{Digest, Sha256}; + +fn replace_pattern(value: String, pattern: &str, replacement: &str) -> String { + regex::Regex::new(pattern) + .expect("valid diagnostic sanitization pattern") + .replace_all(&value, replacement) + .into_owned() +} + +pub(crate) fn sanitize(value: &str, max_chars: usize) -> String { + let normalized = value + .to_string() + .pipe(|value| { + replace_pattern( + value, + r"(?i)authorization\s*:\s*(?:bearer\s+)?\S+", + "authorization: [REDACTED]", + ) + }) + .pipe(|value| replace_pattern(value, r"(?i)bearer\s+\S+", "Bearer [REDACTED]")) + .pipe(|value| { + replace_pattern( + value, + r"(?i)(?:api[_-]?key|token)\s*[=:]\s*\S+", + "[REDACTED]", + ) + }) + .pipe(|value| replace_pattern(value, r"(?i)https?://\S+", "")) + .pipe(|value| { + replace_pattern( + value, + r"(?i)[A-Z]:\\[^\s]+|/(?:Users|home|private|tmp)/[^\s]+", + "", + ) + }) + .pipe(|value| replace_pattern(value, r"(?i)\b[0-9a-f]{8,}\b", "")); + normalized + .replace(['\r', '\n'], " ") + .chars() + .filter(|character| !character.is_control() || *character == '\t') + .collect::() + .chars() + .take(max_chars) + .collect() +} + +trait Pipe: Sized { + fn pipe(self, function: impl FnOnce(Self) -> T) -> T { + function(self) + } +} + +impl Pipe for T {} + +pub(crate) fn fingerprint(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part.as_bytes()); + hasher.update([0]); + } + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::sanitize; + + #[test] + fn redacts_credentials_urls_paths_and_ids() { + let value = "authorization: Bearer secret token=abc https://example.test/a /home/alice/project deadbeef12"; + let sanitized = sanitize(value, 512); + assert!(!sanitized.contains("secret")); + assert!(!sanitized.contains("example.test")); + assert!(!sanitized.contains("/home/alice")); + assert!(!sanitized.contains("deadbeef12")); + } + + #[test] + fn removes_newlines_and_bounds_length() { + assert_eq!(sanitize("a\nb\u{0000}c", 3), "a b"); + } +} 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 bad186bfb..1af786697 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -21,6 +21,7 @@ use platform_llm::{ }; use reqwest::header; use serde::{Deserialize, Serialize}; +use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, validate_game_iteration_versions, GameCreationAgentArtifactTrace, @@ -39,7 +40,6 @@ 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::ErrorReportLogInput; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; @@ -73,6 +73,7 @@ mod context_menu; #[cfg(all(debug_assertions, not(test)))] mod debug; mod delegation; +pub mod error_report; mod git_inspect; mod goal; mod image_inspect; @@ -107,6 +108,7 @@ use commands::*; use config::*; use context_compaction::*; use delegation::*; +use error_report::*; use git_inspect::*; use goal::*; use image_inspect::*; @@ -2105,6 +2107,7 @@ fn main() { .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) .setup(move |app| { + error_report::initialize_notifications(app.handle()); if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.setup.begin"); let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin"); @@ -2356,7 +2359,11 @@ fn main() { get_local_game_project_revision, get_local_game_manifest, append_application_log, - read_diagnostic_logs + read_diagnostic_logs, + report_client_error, + get_pending_error_reports, + mark_error_reports_notified, + ack_error_reports ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 843ef706e..bbd074bc9 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -9,7 +9,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; -import { ErrorReportCenter } from '../components/error-report/ErrorReportDialog'; +import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; import { clearStoredAuthAccessToken, getClientAuthErrorMessage, @@ -570,7 +570,7 @@ export function AuthenticatedClient({ {children({ user: authUser, logout })} - + ); } diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx index a53bc22e4..fbaf8d3a5 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { type ClientErrorEvent, type DiagnosticLogFile, - getPendingClientErrorEvents, markClientErrorEventsSubmitted, readApplicationDiagnosticLogs, submitErrorReportBatch, - subscribeClientErrorEvents, } from '../../services/errorReporting'; import { ThemedModal } from '../modal/ThemedModal'; @@ -67,7 +65,7 @@ export function ErrorReportDialog({ logs: includeLogs ? logs : [], userDescription: description, }); - markClientErrorEventsSubmitted(selectedEvents); + await markClientErrorEventsSubmitted(selectedEvents); setStatus('已提交,感谢你的反馈'); window.setTimeout(onClose, 700); } catch (error) { @@ -185,78 +183,3 @@ export function ErrorReportDialog({ ); } - -export function ErrorReportCenter() { - const [pendingEvents, setPendingEvents] = useState([]); - const [notificationEvents, setNotificationEvents] = useState< - ClientErrorEvent[] - >([]); - const [reportEvents, setReportEvents] = useState([]); - const notifiedFingerprints = useRef(new Set()); - const aggregationTimer = useRef(null); - - const sync = useCallback(() => { - setPendingEvents(getPendingClientErrorEvents()); - }, []); - - useEffect(() => { - sync(); - return subscribeClientErrorEvents(sync); - }, [sync]); - - useEffect(() => { - const candidates = pendingEvents.filter( - (event) => !notifiedFingerprints.current.has(event.fingerprint), - ); - if ( - !candidates.length || - notificationEvents.length || - aggregationTimer.current !== null - ) - return; - aggregationTimer.current = window.setTimeout(() => { - aggregationTimer.current = null; - const next = getPendingClientErrorEvents(); - const newlyNotified = next.filter( - (event) => !notifiedFingerprints.current.has(event.fingerprint), - ); - newlyNotified.forEach((event) => - notifiedFingerprints.current.add(event.fingerprint), - ); - if (newlyNotified.length) setNotificationEvents(next); - }, 5000); - return () => { - if (aggregationTimer.current !== null) { - window.clearTimeout(aggregationTimer.current); - aggregationTimer.current = null; - } - }; - }, [notificationEvents.length, pendingEvents]); - - const ignore = () => setNotificationEvents([]); - const openReport = () => { - setReportEvents(notificationEvents); - setNotificationEvents([]); - }; - - return ( - <> - {notificationEvents.length ? ( - - ) : null} - 0} - events={reportEvents} - onClose={() => setReportEvents([])} - /> - - ); -} diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx new file mode 100644 index 000000000..0c83afdb2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + type ClientErrorEvent, + getPendingClientErrorEvents, + subscribeClientErrorEvents, +} from '../../services/errorReporting'; +import { ErrorReportDialog } from './ErrorReportDialog'; + +export function ErrorReportNotice() { + const [notificationEvents, setNotificationEvents] = useState< + ClientErrorEvent[] + >([]); + const [reportEvents, setReportEvents] = useState([]); + + const loadEvents = useCallback(async (eventIds?: string[]) => { + try { + const events = await getPendingClientErrorEvents(); + if (!eventIds?.length) return; + const ids = new Set(eventIds); + setNotificationEvents(events.filter((event) => ids.has(event.eventId))); + } catch { + // 通知失败不应打断 AGC 主流程。 + } + }, []); + + useEffect(() => subscribeClientErrorEvents(loadEvents), [loadEvents]); + + const openReport = () => { + setReportEvents(notificationEvents); + setNotificationEvents([]); + }; + + return ( + <> + {notificationEvents.length ? ( + + ) : null} + 0} + events={reportEvents} + onClose={() => setReportEvents([])} + /> + + ); +} diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 3592c9fd9..89e778c3b 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -2,6 +2,12 @@ import { invoke } from '@tauri-apps/api/core'; import { getStoredAuthAccessToken } from './clientAuth'; import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp'; +import { + ackErrorReports, + getPendingErrorReports, + reportClientError, + subscribeErrorReportUpdates, +} from './errorReportingBridge'; export type ClientErrorEvent = { eventId: string; @@ -17,10 +23,6 @@ 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; - export function shouldCaptureClientError(error: unknown) { if (!error || typeof error !== 'object') return true; const candidate = error as { status?: unknown; networkError?: unknown }; @@ -44,21 +46,6 @@ export async function invokeDiagnostic( } } -function notify() { - listeners.forEach((listener) => listener()); -} - -function normalizeMessage(value: string) { - return ( - normalizeDiagnosticText(value) - .replace(/[\r\n]+/gu, ' ') - // 保留 HTTP 状态码、短计数等有诊断价值的小数字;仅折叠较长的可识别数字。 - .replace(/\d{5,}/gu, '') - .trim() - .slice(0, 512) - ); -} - function normalizeDiagnosticText(value: string) { return value .replace( @@ -72,73 +59,20 @@ function normalizeDiagnosticText(value: string) { .replace(/\b[0-9a-f]{8,}\b/giu, ''); } -async function sha256(value: string) { - if (globalThis.crypto?.subtle) { - const digest = await globalThis.crypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(value), - ); - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); - } - return normalizeMessage(value); -} - export async function captureClientError( error: unknown, context: { source?: string; action?: string; page?: string } = {}, ) { const errorValue = error instanceof Error ? error : new Error(String(error)); - const message = normalizeMessage(errorValue.message || '未知客户端错误'); - const stack = errorValue.stack - ? normalizeDiagnosticText(errorValue.stack).slice(0, 8_000) - : undefined; - const callSite = stack - ?.split('\n') - .find((line) => /^\s*at\s+/u.test(line)) - ?.replace(/:\d+(?::\d+)?\)?$/u, '') - .trim(); - const fingerprintInput = [ - context.source ?? 'client', - context.action, - context.page, - message, - callSite, - ] - .filter(Boolean) - .join('|'); - let fingerprint: string; - try { - fingerprint = await sha256(fingerprintInput); - } catch { - // 错误采集绝不能反过来制造 unhandledrejection;降级为稳定的可读键。 - fingerprint = fingerprintInput; - } - const existing = pending.get(fingerprint); - const now = new Date().toISOString(); - if (existing) { - existing.count += 1; - existing.occurredAt = now; - notify(); - return existing; - } - const event: ClientErrorEvent = { - eventId: `client-error-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`, - fingerprint, + const message = errorValue.message || '未知客户端错误'; + const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined; + return reportClientError({ source: context.source ?? 'client', message, stack, - occurredAt: now, - count: 1, - }; - if (pending.size >= MAX_EVENTS) { - const oldest = pending.keys().next().value; - if (oldest) pending.delete(oldest); - } - pending.set(fingerprint, event); - notify(); - return event; + action: context.action, + page: context.page, + }).catch(() => undefined); } function formatConsoleArgument(value: unknown) { @@ -193,13 +127,23 @@ export function installWebviewLogBridge() { } export function getPendingClientErrorEvents() { - return Array.from(pending.values()); + return getPendingErrorReports(); } -export function subscribeClientErrorEvents(listener: () => void) { - listeners.add(listener); +export function subscribeClientErrorEvents( + listener: (eventIds: string[]) => void, +) { + let disposed = false; + let unlisten: (() => void) | undefined; + void subscribeErrorReportUpdates((update) => { + if (!disposed) listener(update.eventIds ?? []); + }).then((stop) => { + if (disposed) stop(); + else unlisten = stop; + }); return () => { - listeners.delete(listener); + disposed = true; + unlisten?.(); }; } @@ -252,12 +196,10 @@ export async function submitErrorReportBatch( } export function markClientErrorEventsSubmitted(events: ClientErrorEvent[]) { - for (const event of events) pending.delete(event.fingerprint); - notify(); + return ackErrorReports(events.map((event) => event.eventId)); } /** 仅供单元测试隔离进程内错误池;生产流程不调用。 */ export function resetClientErrorEventsForTests() { - pending.clear(); - notify(); + // Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。 } diff --git a/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts b/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts new file mode 100644 index 000000000..a57c510e7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts @@ -0,0 +1,44 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import type { ClientErrorEvent } from './errorReporting'; + +type RustErrorReportEvent = ClientErrorEvent & { + lastOccurredAt?: string; + notified?: boolean; +}; + +export type ErrorReportUpdate = { + generation: number; + newCount: number; + eventIds?: string[]; +}; + +export function reportClientError(input: { + source: string; + message: string; + stack?: string; + action?: string; + page?: string; +}) { + return invoke('report_client_error', input); +} +export function getPendingErrorReports() { + return invoke('get_pending_error_reports'); +} + +export function markErrorReportsNotified(eventIds: string[]) { + return invoke('mark_error_reports_notified', { eventIds }); +} + +export function ackErrorReports(eventIds: string[]) { + return invoke('ack_error_reports', { eventIds }); +} + +export function subscribeErrorReportUpdates( + listener: (update: ErrorReportUpdate) => void, +): Promise { + return listen('error-report-updated', (event) => { + listener(event.payload); + }); +} diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 671fde789..1bd754d70 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -2,8 +2,74 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +type FakeErrorEvent = { + eventId: string; + fingerprint: string; + source: string; + message: string; + stack?: string; + occurredAt: string; + count: number; + notified: boolean; +}; + +const fakeRustQueue = vi.hoisted(() => { + const events = new Map(); + let sequence = 0; + return { + events, + get sequence() { + return sequence; + }, + next() { + sequence += 1; + return sequence; + }, + reset() { + events.clear(); + sequence = 0; + }, + }; +}); + vi.mock('@tauri-apps/api/core', () => ({ - invoke: vi.fn().mockResolvedValue(undefined), + invoke: vi.fn(async (command: string, args?: Record) => { + if (command === 'report_client_error') { + const fingerprint = `${args?.source ?? 'client'}|${args?.action ?? ''}|${args?.page ?? ''}|${args?.message ?? ''}`; + const existing = fakeRustQueue.events.get(fingerprint); + if (existing) { + existing.count += 1; + return existing; + } + const event = { + eventId: `rust-error-${fakeRustQueue.next()}`, + fingerprint, + source: String(args?.source ?? 'client'), + message: String(args?.message ?? ''), + stack: typeof args?.stack === 'string' ? args.stack : undefined, + occurredAt: '1', + count: 1, + notified: false, + }; + if (fakeRustQueue.events.size >= 100) { + const oldest = fakeRustQueue.events.keys().next().value; + if (oldest) fakeRustQueue.events.delete(oldest); + } + fakeRustQueue.events.set(fingerprint, event); + return event; + } + if (command === 'get_pending_error_reports') + return [...fakeRustQueue.events.values()]; + if (command === 'ack_error_reports') { + for (const event of fakeRustQueue.events.values()) { + const eventIds = Array.isArray(args?.eventIds) ? args.eventIds : []; + if (eventIds.includes(event.eventId)) + fakeRustQueue.events.delete(event.fingerprint); + } + return undefined; + } + return undefined; + }), })); vi.mock('../src/services/clientAuth', () => ({ getStoredAuthAccessToken: vi.fn(() => 'test-token'), @@ -28,6 +94,7 @@ import { describe('客户端错误报告池', () => { afterEach(() => { + fakeRustQueue.reset(); resetClientErrorEventsForTests(); vi.clearAllMocks(); }); @@ -41,15 +108,15 @@ describe('客户端错误报告池', () => { }); expect(second.eventId).toBe(first.eventId); - expect(getPendingClientErrorEvents()).toHaveLength(1); - expect(getPendingClientErrorEvents()[0]?.count).toBe(2); + expect(await getPendingClientErrorEvents()).toHaveLength(1); + expect((await getPendingClientErrorEvents())[0]?.count).toBe(2); }); it('限制当前进程错误池最多保留 100 条', async () => { for (let index = 0; index < 101; index += 1) { await captureClientError(new Error(`错误 ${index}`), { source: 'test' }); } - expect(getPendingClientErrorEvents()).toHaveLength(100); + expect(await getPendingClientErrorEvents()).toHaveLength(100); }); it('提交成功后只清除本次提交的事件', async () => { @@ -66,9 +133,9 @@ describe('客户端错误报告池', () => { ); await submitErrorReportBatch({ events: [first], logs: [] }); - markClientErrorEventsSubmitted([first]); + await markClientErrorEventsSubmitted([first]); - expect(getPendingClientErrorEvents()).toEqual([second]); + expect(await getPendingClientErrorEvents()).toEqual([second]); }); it('未登录时拒绝提交且不发请求', async () => { diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index bd85e77cb..eb140e85c 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -12,7 +12,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - 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 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 -- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只显示一条非阻塞通知;通知支持“查看并报告”和“忽略”,同一 fingerprint 在本次运行中只提醒一次。通知不直接打开阻塞式报告面板。 +- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 notified/ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Rust emit 只携带 generation、newCount 和 eventIds,不携带错误正文。 +- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只显示一条非阻塞通知;通知支持“查看并报告”和“忽略”,同一 fingerprint 在本次运行中只提醒一次。通知不直接打开阻塞式报告面板。忽略只标记 notified,不删除事件;提交成功后由 bridge ack/delete 选中事件。 - 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。 ## HTTP 与存储