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; fingerprint: string; source: string; message: string; stack?: string; occurredAt: string; count: number; }; export type DiagnosticLogFile = { name: string; content: string }; type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'; let fallbackSubmissionSequence = 0; function createSubmissionId() { return ( globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}-${++fallbackSubmissionSequence}` ); } export function shouldCaptureClientError(error: unknown) { if (!error || typeof error !== 'object') return true; const candidate = error as { status?: unknown; networkError?: unknown }; if (candidate.networkError === true) return true; if (typeof candidate.status === 'number') { return candidate.status === 408 || candidate.status >= 500; } return true; } export async function invokeDiagnostic( invokeFn: (command: string, args?: Record) => Promise, command: string, args?: Record, ) { try { return await invokeFn(command, args); } catch (error) { void captureClientError(error, { source: 'tauri', action: command }); throw error; } } function normalizeDiagnosticText(value: string) { return value .replace( /authorization\s*:\s*(?:bearer\s+)?\S+/giu, 'authorization: [REDACTED]', ) .replace(/bearer\s+\S+/giu, 'Bearer [REDACTED]') .replace(/(?:api[_-]?key|token)\s*[=:]\s*\S+/giu, '[REDACTED]') .replace(/https?:\/\/\S+/giu, '') .replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '') .replace(/\b[0-9a-f]{8,}\b/giu, ''); } 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 = errorValue.message || '未知客户端错误'; const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined; return reportClientError({ source: context.source ?? 'client', message, stack, action: context.action, page: context.page, }).catch(() => undefined); } export function captureAgentRuntimeError(error: unknown, agentId: string) { return captureClientError(error, { source: 'agent-runtime', action: 'agent-runtime', page: agentId, }); } 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 = normalizeDiagnosticText( 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 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 getPendingErrorReports(); } export function subscribeClientErrorEvents(listener: () => void) { let disposed = false; let unlisten: (() => void) | undefined; void subscribeErrorReportUpdates(() => { if (!disposed) listener(); }) .then((stop) => { if (disposed) stop(); else unlisten = stop; }) .catch(() => { // Browser previews and partially initialized native webviews do not have // an event bridge; snapshots and focus/visibility refreshes remain the // source of truth in those environments. }); return () => { disposed = true; unlisten?.(); }; } export async function readApplicationDiagnosticLogs(): Promise< DiagnosticLogFile[] > { return invoke('read_diagnostic_logs'); } export async function submitErrorReportBatch( payload: { events: ClientErrorEvent[]; userDescription?: string; logs: DiagnosticLogFile[]; submissionId?: string; }, apiBaseUrl = getClientServerBaseUrl(), ) { const token = getStoredAuthAccessToken(); if (!token) throw new Error('请先登录后再提交错误报告'); const response = await fetchClientHttp( '/api/error-reports', { method: 'POST', credentials: 'same-origin', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ schemaVersion: 1, submissionId: payload.submissionId ?? createSubmissionId(), events: payload.events, userDescription: payload.userDescription?.trim() || null, logs: payload.logs, }), }, { serverBaseUrl: apiBaseUrl }, ); if (!response.ok) { throw new Error(`错误报告提交失败(${response.status})`); } const result = (await response.json()) as { data?: unknown }; return result.data ?? result; } const stableSubmissionIds = new Map(); function errorEventKey(events: ClientErrorEvent[]) { return events .map((event) => event.eventId) .sort() .join('\u0000'); } export function getStableErrorReportSubmissionId(events: ClientErrorEvent[]) { const key = errorEventKey(events); const existing = stableSubmissionIds.get(key); if (existing) return existing; const submissionId = createSubmissionId(); stableSubmissionIds.set(key, submissionId); return submissionId; } export async function markClientErrorEventsSubmitted( events: ClientErrorEvent[], ) { await ackErrorReports(events.map((event) => event.eventId)); stableSubmissionIds.delete(errorEventKey(events)); } export async function ackClientErrorEventsWithRetry( events: ClientErrorEvent[], ) { for (const [attempt, delay] of [0, 500, 1_000, 2_000].entries()) { if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); try { await markClientErrorEventsSubmitted(events); return; } catch (error) { console.warn( `[error-report] 本地 ack 第 ${attempt + 1} 次失败,将继续重试`, error, ); } } console.warn('[error-report] 本地 ack 重试耗尽,事件仍保留在本地队列'); } /** 仅供单元测试隔离进程内错误池;生产流程不调用。 */ export function resetClientErrorEventsForTests() { // Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。 stableSubmissionIds.clear(); }