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 index 3658a6080..06e308cf2 100644 --- 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 @@ -1,6 +1,6 @@ use tauri::command; -use super::queue::{ack, mark_notified, report_diagnostic_error, snapshot, ErrorReportEvent}; +use super::queue::{ack, report_diagnostic_error, snapshot, ErrorReportEvent}; #[command] pub fn report_client_error( @@ -25,12 +25,6 @@ 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); 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 index 36b0de727..845ab6ede 100644 --- 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 @@ -3,8 +3,6 @@ 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 commands::{ack_error_reports, get_pending_error_reports, 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 index 1095f0ab5..fd1b5ee7a 100644 --- 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 @@ -1,12 +1,16 @@ -use std::sync::{Mutex, OnceLock}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, OnceLock, +}; use std::time::Duration; use tauri::{AppHandle, Emitter}; -use super::queue::{mark_notified, snapshot, unnotified_count}; +use super::queue::{generation, snapshot}; static APP_HANDLE: OnceLock = OnceLock::new(); static TIMER_ACTIVE: OnceLock> = OnceLock::new(); +static LAST_EMITTED_GENERATION: AtomicU64 = AtomicU64::new(0); pub fn initialize_notifications(app: &AppHandle) { let _ = APP_HANDLE.set(app.clone()); @@ -30,19 +34,13 @@ pub(crate) fn schedule_notification() { 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 current_generation = generation(); + LAST_EMITTED_GENERATION.store(current_generation, Ordering::Release); + if !events.is_empty() { let _ = handle.emit( "error-report-updated", serde_json::json!({ - "generation": events.len(), - "newCount": ids.len(), - "eventIds": ids, + "generation": current_generation, }), ); } @@ -51,7 +49,7 @@ pub(crate) fn schedule_notification() { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = false; } - if unnotified_count() > 0 { + if generation() > LAST_EMITTED_GENERATION.load(Ordering::Acquire) { 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 index 7045f0fd9..42c28093c 100644 --- 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 @@ -25,7 +25,6 @@ pub struct ErrorReportEvent { pub occurred_at: String, pub last_occurred_at: String, pub count: u32, - pub notified: bool, } #[derive(Default)] @@ -113,7 +112,6 @@ pub fn report_diagnostic_error( occurred_at: timestamp.clone(), last_occurred_at: timestamp, count: 1, - notified: false, }; state .events @@ -138,17 +136,6 @@ pub(crate) fn snapshot() -> Vec { .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() @@ -164,15 +151,11 @@ pub(crate) fn ack(event_ids: &[String]) { } } -pub(crate) fn unnotified_count() -> usize { +pub(crate) fn generation() -> u64 { let state = queue() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - state - .events - .values() - .filter(|event| !event.notified) - .count() + state.sequence } #[cfg(test)] 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 1af786697..b640364a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2362,7 +2362,6 @@ fn main() { read_diagnostic_logs, report_client_error, get_pending_error_reports, - mark_error_reports_notified, ack_error_reports ]) .build(tauri_context); 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 index c9bb8283f..3d692098f 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { type ClientErrorEvent, @@ -13,25 +13,54 @@ export function ErrorReportNotice() { >([]); const [reportEvents, setReportEvents] = useState([]); - const loadEvents = useCallback(async (eventIds?: string[]) => { - try { - const events = await getPendingClientErrorEvents(); - if (eventIds?.length) { - const ids = new Set(eventIds); - setNotificationEvents(events.filter((event) => ids.has(event.eventId))); - } else { - setNotificationEvents( - events.filter((event) => event.notified === false), - ); - } - } catch { - // 通知失败不应打断 AGC 主流程。 + const requestId = useRef(0); + const retryTimer = useRef(null); + const loadEvents = useCallback(() => { + if (retryTimer.current !== null) { + window.clearTimeout(retryTimer.current); + retryTimer.current = null; } + const currentRequest = ++requestId.current; + const retryDelays = [500, 1_000, 2_000]; + const run = async (attempt: number): Promise => { + try { + const events = await getPendingClientErrorEvents(); + if (currentRequest !== requestId.current) return; + setNotificationEvents(events); + } catch { + if ( + currentRequest !== requestId.current || + attempt >= retryDelays.length + ) + return; + retryTimer.current = window.setTimeout(() => { + retryTimer.current = null; + void run(attempt + 1); + }, retryDelays[attempt]); + } + }; + void run(0); }, []); useEffect(() => { - void loadEvents(); - return subscribeClientErrorEvents(loadEvents); + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') loadEvents(); + }; + const handleFocus = () => loadEvents(); + loadEvents(); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); + const unsubscribe = subscribeClientErrorEvents(loadEvents); + return () => { + unsubscribe(); + requestId.current += 1; + if (retryTimer.current !== null) { + window.clearTimeout(retryTimer.current); + retryTimer.current = null; + } + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); + }; }, [loadEvents]); const openReport = () => { diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index bc305b4f9..93944b478 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -17,7 +17,6 @@ export type ClientErrorEvent = { stack?: string; occurredAt: string; count: number; - notified?: boolean; }; export type DiagnosticLogFile = { name: string; content: string }; @@ -131,13 +130,11 @@ export function getPendingClientErrorEvents() { return getPendingErrorReports(); } -export function subscribeClientErrorEvents( - listener: (eventIds: string[]) => void, -) { +export function subscribeClientErrorEvents(listener: () => void) { let disposed = false; let unlisten: (() => void) | undefined; - void subscribeErrorReportUpdates((update) => { - if (!disposed) listener(update.eventIds ?? []); + void subscribeErrorReportUpdates(() => { + if (!disposed) listener(); }).then((stop) => { if (disposed) stop(); else unlisten = stop; @@ -182,9 +179,7 @@ export async function submitErrorReportBatch( schemaVersion: 1, submissionId: globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}`, - events: payload.events.map( - ({ notified: _notified, ...event }) => event, - ), + events: payload.events, userDescription: payload.userDescription?.trim() || null, logs: payload.logs, }), diff --git a/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts b/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts index a57c510e7..afbb503d3 100644 --- a/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts +++ b/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts @@ -5,13 +5,10 @@ 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: { @@ -27,18 +24,15 @@ 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, + listener: () => void, ): Promise { return listen('error-report-updated', (event) => { - listener(event.payload); + void event.payload; + listener(); }); } diff --git a/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx b/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx new file mode 100644 index 000000000..37e80b642 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx @@ -0,0 +1,67 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockState = vi.hoisted(() => ({ + getPending: vi.fn(), + listeners: [] as Array<() => void>, +})); + +vi.mock('../src/services/errorReporting', () => ({ + getPendingClientErrorEvents: mockState.getPending, + subscribeClientErrorEvents: (listener: () => void) => { + mockState.listeners.push(listener); + return () => { + mockState.listeners = mockState.listeners.filter( + (item) => item !== listener, + ); + }; + }, +})); + +vi.mock('../src/components/error-report/ErrorReportDialog', () => ({ + ErrorReportDialog: () => null, +})); + +import { ErrorReportNotice } from '../src/components/error-report/ErrorReportNotice'; + +const event = { + eventId: 'rust-error-1', + fingerprint: 'fingerprint-1', + source: 'test', + message: '错过 emit 的错误', + occurredAt: '1', + count: 1, +}; + +describe('ErrorReportNotice', () => { + afterEach(() => { + cleanup(); + mockState.getPending.mockReset(); + mockState.listeners = []; + }); + + it('挂载时查询快照,即使错过唤醒事件也能显示通知', async () => { + mockState.getPending.mockResolvedValue([event]); + + render(); + + expect(await screen.findByText('发现 1 个问题')).toBeTruthy(); + expect(mockState.getPending).toHaveBeenCalledTimes(1); + }); + + it('收到唤醒后重新查询权威快照', async () => { + mockState.getPending + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([event]); + + render(); + await waitFor(() => expect(mockState.getPending).toHaveBeenCalledTimes(1)); + + mockState.listeners[0]?.(); + + expect(await screen.findByText('发现 1 个问题')).toBeTruthy(); + expect(mockState.getPending).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 1bd754d70..6211c57b7 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -10,7 +10,6 @@ type FakeErrorEvent = { stack?: string; occurredAt: string; count: number; - notified: boolean; }; const fakeRustQueue = vi.hoisted(() => { @@ -49,7 +48,6 @@ vi.mock('@tauri-apps/api/core', () => ({ 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; diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index eb140e85c..23213fb77 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -12,8 +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 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 -- 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 选中事件。 +- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Rust emit 只作为无状态唤醒,携带单调递增的 generation,不携带错误正文或事件 ID。 +- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。 - 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。 ## HTTP 与存储