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 ad1499c0b..114d1c189 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -39,6 +39,7 @@ 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 tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; @@ -1563,6 +1564,49 @@ static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); static STARTUP_PANIC_LOG_PATH: OnceLock = OnceLock::new(); static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); +#[tauri::command] +fn append_diagnostic_event(event: ErrorReportEventInput) -> Result<(), String> { + 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()) +} + +#[tauri::command] +fn read_diagnostic_logs() -> Result, String> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(Vec::new()); + }; + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let directory = config_dir.join("diagnostics"); + let mut files = Vec::new(); + for name in ["application.log", "application.previous.log", "startup.log"] { + let path = directory.join(name); + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + files.push(ErrorReportLogInput { + name: name.to_string(), + content, + }); + } + Ok(files) +} + fn diagnostic_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2059,6 +2103,11 @@ fn main() { show_startup_error_dialog(path); } })?; + let startup_log = game_creator_runtime_config_dir() + .map(|directory| directory.join("diagnostics/startup.log")); + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); + } if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); } @@ -2292,7 +2341,9 @@ fn main() { commit_local_project_asset, commit_local_project_asset_canvas_candidate, get_local_game_project_revision, - get_local_game_manifest + get_local_game_manifest, + append_diagnostic_event, + read_diagnostic_logs ]) .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 290a06cc1..06d541e6b 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -9,6 +9,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { ErrorReportDialog } from '../components/error-report/ErrorReportDialog'; import { clearStoredAuthAccessToken, getClientAuthErrorMessage, @@ -29,6 +30,7 @@ import { normalizeClientServerBaseUrl, setClientServerSelection, } from '../services/clientHttp'; +import { captureClientError } from '../services/errorReporting'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -43,6 +45,7 @@ import { type ClientRuntimeErrorBoundaryProps = { children: ReactNode; onLogout: () => void; + onReport?: () => void; }; type ClientRuntimeErrorBoundaryState = { @@ -66,6 +69,7 @@ export class ClientRuntimeErrorBoundary extends Component< componentDidCatch(error: unknown, errorInfo: ErrorInfo) { console.error('AGC authenticated client render failed', error, errorInfo); + void captureClientError(error, { source: 'react-render' }); } render() { @@ -86,6 +90,11 @@ export class ClientRuntimeErrorBoundary extends Component< + {this.props.onReport ? ( + + ) : null} ); @@ -109,6 +118,7 @@ export function AuthenticatedClient({ const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); + const [errorReportOpen, setErrorReportOpen] = useState(false); const initialServerSelection = getClientServerSelection(); const [serverSelection, setServerSelection] = useState( initialServerSelection, @@ -117,6 +127,35 @@ export function AuthenticatedClient({ initialServerSelection.customBaseUrl, ); + useEffect(() => { + const handleError = (event: ErrorEvent) => { + void captureClientError(event.error ?? event.message, { + source: 'window.onerror', + }); + }; + const handleRejection = (event: PromiseRejectionEvent) => { + void captureClientError(event.reason, { source: 'unhandledrejection' }); + }; + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleRejection); + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleRejection); + }; + }, []); + + useEffect(() => { + const openReport = () => { + void captureClientError(new Error('用户主动报告问题'), { + source: 'manual', + }); + setErrorReportOpen(true); + }; + window.addEventListener('agc-open-error-report', openReport); + return () => + window.removeEventListener('agc-open-error-report', openReport); + }, []); + function persistServerSelection() { try { const next = setClientServerSelection({ @@ -126,6 +165,10 @@ export function AuthenticatedClient({ setServerSelection(next); return next; } catch (error) { + void captureClientError(error, { + source: 'auth-hydrate', + action: 'restore-session', + }); setLoginStatus(error instanceof Error ? error.message : String(error)); return null; } @@ -304,6 +347,10 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { + void captureClientError(error, { + source: 'auth', + action: 'send-login-code', + }); setLoginStatus(error instanceof Error ? error.message : String(error)); } finally { setCodeBusy(false); @@ -362,6 +409,7 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { + void captureClientError(error, { source: 'auth', action: 'login' }); setLoginStatus(error instanceof Error ? error.message : String(error)); } finally { setLoginBusy(false); @@ -530,8 +578,22 @@ export function AuthenticatedClient({ } return ( - - {children({ user: authUser, logout })} - + <> + { + void captureClientError(new Error('用户主动报告问题'), { + source: 'manual', + }); + setErrorReportOpen(true); + }} + > + {children({ user: authUser, logout })} + + setErrorReportOpen(false)} + /> + ); } 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 new file mode 100644 index 000000000..f96c667cc --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + type ClientErrorEvent, + type DiagnosticLogFile, + getPendingClientErrorEvents, + markClientErrorEventsSubmitted, + readApplicationDiagnosticLogs, + submitErrorReportBatch, + subscribeClientErrorEvents, +} from '../../services/errorReporting'; +import { ThemedModal } from '../modal/ThemedModal'; + +type ErrorReportDialogProps = { + open: boolean; + onClose: () => void; +}; + +export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) { + const [events, setEvents] = useState([]); + const [logs, setLogs] = useState([]); + const [includeLogs, setIncludeLogs] = useState(true); + const [description, setDescription] = useState(''); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + + useEffect( + () => + subscribeClientErrorEvents(() => + setEvents(getPendingClientErrorEvents()), + ), + [], + ); + + useEffect(() => { + if (!open) return; + setEvents(getPendingClientErrorEvents()); + setStatus(''); + void readApplicationDiagnosticLogs().then(setLogs); + }, [open]); + + const submit = useCallback(async () => { + if (!events.length || busy) return; + setBusy(true); + setStatus('正在提交…'); + try { + await submitErrorReportBatch({ + events, + logs: includeLogs ? logs : [], + userDescription: description, + }); + markClientErrorEventsSubmitted(events); + setStatus('已提交,感谢你的反馈'); + window.setTimeout(onClose, 700); + } catch (error) { + setStatus( + error instanceof Error ? error.message : '提交失败,请稍后再试', + ); + } finally { + setBusy(false); + } + }, [busy, description, events, includeLogs, logs, onClose]); + + return ( + +
+
+

报告问题

+
+ +
+
+
+ 错误事件({events.length}) + {events.length ? ( +
    + {events.map((event) => ( +
  • + {event.message} + + {event.source} · {event.count} 次 + +
  • + ))} +
+ ) : ( +

当前没有待报告的错误。

+ )} +
+