refactor(new merge) (#369)

Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-09 20:18:25 +08:00
committed by GitHub
Unverified
parent e28eba01e1
commit 3d664c017a
18 changed files with 514 additions and 390 deletions

View File

@@ -1,13 +1,11 @@
import { trackUiEvent } from './telemetry';
export type AppErrorCode =
| 'TIMEOUT'
| 'RATE_LIMIT'
| 'PERMISSION'
| 'NETWORK'
| 'CONFIG'
| 'GATEWAY'
| 'UNKNOWN';
import {
AppError,
type AppErrorCode,
mapBackendErrorCode,
normalizeAppError,
} from './error-model';
export { AppError } from './error-model';
export type TransportKind = 'ipc' | 'ws' | 'http';
export type GatewayTransportPreference = 'ws-first';
@@ -181,64 +179,8 @@ class TransportUnsupportedError extends Error {
}
}
export class AppError extends Error {
code: AppErrorCode;
cause?: unknown;
details?: Record<string, unknown>;
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
super(message);
this.code = code;
this.cause = cause;
this.details = details;
}
}
function mapUnifiedErrorCode(code?: string): AppErrorCode {
switch (code) {
case 'TIMEOUT':
return 'TIMEOUT';
case 'PERMISSION':
return 'PERMISSION';
case 'GATEWAY':
return 'GATEWAY';
case 'VALIDATION':
return 'CONFIG';
case 'UNSUPPORTED':
return 'UNKNOWN';
default:
return 'UNKNOWN';
}
}
function normalizeError(err: unknown, details?: Record<string, unknown>): AppError {
if (err instanceof AppError) {
return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) });
}
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
if (lower.includes('timeout')) {
return new AppError('TIMEOUT', message, err, details);
}
if (lower.includes('rate limit')) {
return new AppError('RATE_LIMIT', message, err, details);
}
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) {
return new AppError('PERMISSION', message, err, details);
}
if (lower.includes('network') || lower.includes('fetch')) {
return new AppError('NETWORK', message, err, details);
}
if (lower.includes('gateway')) {
return new AppError('GATEWAY', message, err, details);
}
if (lower.includes('config') || lower.includes('invalid')) {
return new AppError('CONFIG', message, err, details);
}
return new AppError('UNKNOWN', message, err, details);
return mapBackendErrorCode(code);
}
function shouldLogApiRequests(): boolean {
@@ -389,7 +331,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) {
// Fallback to legacy channel handlers.
} else {
throw normalizeError(err, { transport: 'ipc', channel, source: 'app:request' });
throw normalizeAppError(err, { transport: 'ipc', channel, source: 'app:request' });
}
}
}
@@ -397,7 +339,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
try {
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
} catch (err) {
throw normalizeError(err, { transport: 'ipc', channel, source: 'legacy-ipc' });
throw normalizeAppError(err, { transport: 'ipc', channel, source: 'legacy-ipc' });
}
}
@@ -956,15 +898,19 @@ export function initializeDefaultTransports(): void {
}
export function toUserMessage(error: unknown): string {
const appError = error instanceof AppError ? error : normalizeError(error);
const appError = error instanceof AppError ? error : normalizeAppError(error);
switch (appError.code) {
case 'AUTH_INVALID':
return 'Authentication failed. Check API key or login session and retry.';
case 'TIMEOUT':
return 'Request timed out. Please retry.';
case 'RATE_LIMIT':
return 'Too many requests. Please wait and try again.';
case 'PERMISSION':
return 'Permission denied. Check your configuration and retry.';
case 'CHANNEL_UNAVAILABLE':
return 'Service channel unavailable. Retry after restarting the app or gateway.';
case 'NETWORK':
return 'Network error. Please verify connectivity and retry.';
case 'CONFIG':
@@ -1052,7 +998,7 @@ export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise
});
continue;
}
throw normalizeError(err, {
throw normalizeAppError(err, {
requestId,
channel,
transport: kind,
@@ -1069,7 +1015,7 @@ export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise
message: lastError instanceof Error ? lastError.message : String(lastError),
});
throw normalizeError(lastError, {
throw normalizeAppError(lastError, {
requestId,
channel,
transport: 'ipc',
@@ -1100,5 +1046,5 @@ export async function invokeIpcWithRetry<T>(
}
}
throw normalizeError(lastError);
throw normalizeAppError(lastError);
}

102
src/lib/error-model.ts Normal file
View File

@@ -0,0 +1,102 @@
export type AppErrorCode =
| 'AUTH_INVALID'
| 'TIMEOUT'
| 'RATE_LIMIT'
| 'PERMISSION'
| 'CHANNEL_UNAVAILABLE'
| 'NETWORK'
| 'CONFIG'
| 'GATEWAY'
| 'UNKNOWN';
export class AppError extends Error {
code: AppErrorCode;
cause?: unknown;
details?: Record<string, unknown>;
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
super(message);
this.code = code;
this.cause = cause;
this.details = details;
}
}
export function mapBackendErrorCode(code?: string): AppErrorCode {
switch (code) {
case 'TIMEOUT':
return 'TIMEOUT';
case 'PERMISSION':
return 'PERMISSION';
case 'GATEWAY':
return 'GATEWAY';
case 'VALIDATION':
return 'CONFIG';
case 'UNSUPPORTED':
return 'CHANNEL_UNAVAILABLE';
default:
return 'UNKNOWN';
}
}
function classifyMessage(message: string): AppErrorCode {
const lower = message.toLowerCase();
if (
lower.includes('invalid ipc channel')
|| lower.includes('no handler registered')
|| lower.includes('window is not defined')
|| lower.includes('unsupported')
) {
return 'CHANNEL_UNAVAILABLE';
}
if (
lower.includes('invalid authentication')
|| lower.includes('unauthorized')
|| lower.includes('auth failed')
|| lower.includes('401')
) {
return 'AUTH_INVALID';
}
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('abort')) {
return 'TIMEOUT';
}
if (lower.includes('rate limit') || lower.includes('429')) {
return 'RATE_LIMIT';
}
if (
lower.includes('permission')
|| lower.includes('forbidden')
|| lower.includes('denied')
|| lower.includes('403')
) {
return 'PERMISSION';
}
if (
lower.includes('network')
|| lower.includes('fetch')
|| lower.includes('econnrefused')
|| lower.includes('econnreset')
|| lower.includes('enotfound')
) {
return 'NETWORK';
}
if (lower.includes('gateway')) {
return 'GATEWAY';
}
if (lower.includes('config') || lower.includes('invalid') || lower.includes('validation') || lower.includes('400')) {
return 'CONFIG';
}
return 'UNKNOWN';
}
export function normalizeAppError(err: unknown, details?: Record<string, unknown>): AppError {
if (err instanceof AppError) {
return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) });
}
const message = err instanceof Error ? err.message : String(err);
return new AppError(classifyMessage(message), message, err, details);
}

View File

@@ -1,5 +1,6 @@
import { invokeIpc } from '@/lib/api-client';
import { trackUiEvent } from './telemetry';
import { normalizeAppError } from './error-model';
const HOST_API_PORT = 3210;
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
@@ -45,7 +46,10 @@ async function parseResponse<T>(response: Response): Promise<T> {
} catch {
// ignore body parse failure
}
throw new Error(message);
throw normalizeAppError(new Error(message), {
source: 'browser-fallback',
status: response.status,
});
}
if (response.status === 204) {
@@ -117,8 +121,12 @@ function parseLegacyProxyResponse<T>(
}
function shouldFallbackToBrowser(message: string): boolean {
return message.includes('Invalid IPC channel: hostapi:fetch')
|| message.includes('window is not defined');
const normalized = message.toLowerCase();
return normalized.includes('invalid ipc channel: hostapi:fetch')
|| normalized.includes("no handler registered for 'hostapi:fetch'")
|| normalized.includes('no handler registered for "hostapi:fetch"')
|| normalized.includes('no handler registered for hostapi:fetch')
|| normalized.includes('window is not defined');
}
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -139,16 +147,18 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
return parseLegacyProxyResponse<T>(response, path, method, startedAt);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method });
const message = normalized.message;
trackUiEvent('hostapi.fetch_error', {
path,
method,
source: 'ipc-proxy',
durationMs: Date.now() - startedAt,
message,
code: normalized.code,
});
if (!shouldFallbackToBrowser(message)) {
throw error;
throw normalized;
}
}
@@ -167,7 +177,11 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
durationMs: Date.now() - startedAt,
status: response.status,
});
return parseResponse<T>(response);
try {
return await parseResponse<T>(response);
} catch (error) {
throw normalizeAppError(error, { source: 'browser-fallback', path, method });
}
}
export function createHostEventSource(path = '/api/events'): EventSource {

View File

@@ -61,9 +61,13 @@ export function Dashboard() {
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
const [usagePage, setUsagePage] = useState(1);
// Fetch data only when gateway is running
// Track page view on mount only.
useEffect(() => {
trackUiEvent('dashboard.page_viewed');
}, []);
// Fetch data only when gateway is running.
useEffect(() => {
if (isGatewayRunning) {
fetchChannels();
fetchSkills();

View File

@@ -4,6 +4,7 @@
*/
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { AppError, normalizeAppError } from '@/lib/error-model';
import { useGatewayStore } from './gateway';
import type { Skill, MarketplaceSkill } from '../types/skill';
@@ -30,6 +31,27 @@ type ClawHubListResult = {
version?: string;
};
function mapErrorCodeToSkillErrorKey(
code: AppError['code'],
operation: 'fetch' | 'search' | 'install',
): string {
if (code === 'TIMEOUT') {
return operation === 'search'
? 'searchTimeoutError'
: operation === 'install'
? 'installTimeoutError'
: 'fetchTimeoutError';
}
if (code === 'RATE_LIMIT') {
return operation === 'search'
? 'searchRateLimitError'
: operation === 'install'
? 'installRateLimitError'
: 'fetchRateLimitError';
}
return 'rateLimitError';
}
interface SkillsState {
skills: Skill[];
searchResults: MarketplaceSkill[];
@@ -131,13 +153,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
set({ skills: combinedSkills, loading: false });
} catch (error) {
console.error('Failed to fetch skills:', error);
let errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg.includes('Timeout')) {
errorMsg = 'timeoutError';
} else if (errorMsg.toLowerCase().includes('rate limit')) {
errorMsg = 'rateLimitError';
}
set({ loading: false, error: errorMsg });
const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' });
set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') });
}
},
@@ -151,16 +168,14 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
if (result.success) {
set({ searchResults: result.results || [] });
} else {
if (result.error?.includes('Timeout')) {
throw new Error('searchTimeoutError');
}
if (result.error?.toLowerCase().includes('rate limit')) {
throw new Error('searchRateLimitError');
}
throw new Error(result.error || 'Search failed');
throw normalizeAppError(new Error(result.error || 'Search failed'), {
module: 'skills',
operation: 'search',
});
}
} catch (error) {
set({ searchError: String(error) });
const appError = normalizeAppError(error, { module: 'skills', operation: 'search' });
set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') });
} finally {
set({ searching: false });
}
@@ -174,13 +189,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
body: JSON.stringify({ slug, version }),
});
if (!result.success) {
if (result.error?.includes('Timeout')) {
throw new Error('installTimeoutError');
}
if (result.error?.toLowerCase().includes('rate limit')) {
throw new Error('installRateLimitError');
}
throw new Error(result.error || 'Install failed');
const appError = normalizeAppError(new Error(result.error || 'Install failed'), {
module: 'skills',
operation: 'install',
});
throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install'));
}
// Refresh skills after install
await get().fetchSkills();
@@ -258,4 +271,4 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
),
}));
},
}));
}));