Files
Genarrative/apps/ai-game-creator-shell/src/services/errorReporting.ts
T
kdletters 8701f43f75 AGC 渲染层网络与状态下沉 Rust,并修复画布交互与失败文案
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。
- 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。
- 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。
- 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
2026-09-28 14:15:02 +08:00

237 lines
7.1 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
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';
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<T>(
invokeFn: (command: string, args?: Record<string, unknown>) => Promise<T>,
command: string,
args?: Record<string, unknown>,
) {
try {
return await invokeFn(command, args);
} catch (error) {
void captureClientError(error, { source: 'tauri', action: command });
throw error;
}
}
/**
* 诊断文本脱敏:抹掉授权头、Bearer、api key / token、URL 与本地绝对路径、长十六进制 id。
*
* 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 ——
* 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。
*/
function redactDiagnosticUrl(value: string) {
return value.replace(/https?:\/\/[^\s"'<>]+/giu, (rawUrl) => {
let parsed: URL;
try {
parsed = new URL(rawUrl.replace(/[),.;!?]+$/u, ''));
} catch {
return '<url>';
}
const pathname = parsed.pathname || '/';
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/v1/') ||
pathname.startsWith('/admin/')
) {
return `<origin>${pathname}`;
}
return '<url>';
});
}
export function normalizeDiagnosticText(value: string) {
return redactDiagnosticUrl(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(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
}
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<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 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<DiagnosticLogFile[]>('read_diagnostic_logs');
}
/**
* 提交一批脱敏错误报告。
*
* 平台会话、origin、稳定 submissionId、响应 envelope 与错误分类都由 Rust typed
* command 持有;渲染层只交出脱敏事件、可选说明和诊断日志,不接触 access token。
*/
export async function submitErrorReportBatch(payload: {
events: ClientErrorEvent[];
userDescription?: string;
logs: DiagnosticLogFile[];
}) {
return invoke<unknown>('submit_error_report', {
events: payload.events,
logs: payload.logs,
userDescription: payload.userDescription?.trim() || null,
});
}
export async function markClientErrorEventsSubmitted(
events: ClientErrorEvent[],
) {
await ackErrorReports(events.map((event) => event.eventId));
}
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 重建进程内状态。
}