Files
makelore/src/lib/api-client.ts
2026-07-29 17:22:35 +08:00

287 lines
7.3 KiB
TypeScript

import {
AppError,
mapBackendErrorCode,
normalizeAppError,
type AppErrorCode,
} from './error-model';
export { AppError } from './error-model';
type UnifiedRequest = {
id: string;
module: string;
action: string;
payload?: unknown;
};
type UnifiedResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
const UNIFIED_CHANNELS = new Set<string>([
'app:version',
'app:name',
'app:platform',
'opencode:status',
'settings:getAll',
'settings:get',
'settings:set',
'settings:setMany',
'settings:reset',
'provider:list',
'provider:get',
'provider:getDefault',
'provider:hasApiKey',
'provider:getApiKey',
'provider:validateKey',
'provider:save',
'provider:delete',
'provider:setApiKey',
'provider:updateWithKey',
'provider:deleteApiKey',
'provider:setDefault',
'update:status',
'update:version',
'update:check',
'update:download',
'update:install',
'update:setChannel',
'update:setAutoDownload',
'update:cancelAutoInstall',
]);
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
const splitIndex = channel.indexOf(':');
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
module: channel.slice(0, splitIndex),
action: channel.slice(splitIndex + 1),
payload: args.length <= 1 ? args[0] : args,
};
}
function mapUnifiedError(response: UnifiedResponse): AppError {
const message = response.error?.message || 'Unified IPC request failed';
return new AppError(mapBackendErrorCode(response.error?.code), message, response.error?.details);
}
async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
if (channel !== 'app:request' && UNIFIED_CHANNELS.has(channel)) {
const request = toUnifiedRequest(channel, args);
try {
const response = await window.electron.ipcRenderer.invoke('app:request', request) as UnifiedResponse;
if (!response?.ok) {
const message = response?.error?.message || 'Unified IPC request failed';
if (message.includes('APP_REQUEST_UNSUPPORTED:')) {
throw new Error(message);
}
throw mapUnifiedError(response);
}
return response.data as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
!message.includes('APP_REQUEST_UNSUPPORTED:')
&& !message.includes('Invalid IPC channel: app:request')
) {
throw normalizeAppError(error, { transport: 'ipc', channel, source: 'app:request' });
}
}
}
try {
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
} catch (error) {
throw normalizeAppError(error, { transport: 'ipc', channel, source: 'legacy-ipc' });
}
}
export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise<T> {
return invokeViaIpc<T>(channel, args);
}
export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<T> {
return invokeApi<T>(channel, ...args);
}
export async function invokeIpcWithRetry<T>(
channel: string,
args: unknown[] = [],
retries = 1,
retryable: AppErrorCode[] = ['TIMEOUT', 'NETWORK'],
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await invokeApi<T>(channel, ...args);
} catch (error) {
lastError = error;
if (!(error instanceof AppError) || !retryable.includes(error.code) || attempt === retries) {
throw error;
}
}
}
throw normalizeAppError(lastError);
}
export function initializeDefaultTransports(): void {
// IPC is the only renderer-to-main transport in the opencode-based app.
}
export function toUserMessage(error: unknown): string {
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 Makelore.';
case 'NETWORK':
return 'Network error. Please verify connectivity and retry.';
case 'CONFIG':
return 'Configuration is invalid. Please review settings.';
case 'RUNTIME':
return 'Runtime is unavailable. Start or restart it and retry.';
default:
return appError.message || 'Unexpected error occurred.';
}
}
export type FilePreviewError =
| 'outsideSandbox'
| 'readOnlyRoot'
| 'tooLarge'
| 'binary'
| 'notFound'
| 'notDirectory'
| 'invalidContent'
| string;
export interface ReadTextFileResult {
ok: boolean;
content?: string;
mimeType?: string;
size?: number;
readOnly?: boolean;
error?: FilePreviewError;
}
export interface ReadBinaryFileResult {
ok: boolean;
data?: Uint8Array;
mimeType?: string;
size?: number;
readOnly?: boolean;
error?: FilePreviewError;
}
export interface ReadBinaryFileOptions {
maxBytes?: number;
}
export interface WriteTextFileResult {
ok: boolean;
error?: FilePreviewError;
}
export interface StatFileResult {
ok: boolean;
size?: number;
mtime?: number;
isFile?: boolean;
isDir?: boolean;
readOnly?: boolean;
error?: FilePreviewError;
}
export interface ListDirEntry {
name: string;
path: string;
isDir: boolean;
size: number;
}
export interface ListDirResult {
ok: boolean;
entries?: ListDirEntry[];
error?: FilePreviewError;
}
export interface TreeNode {
name: string;
relPath: string;
absPath: string;
isDir: boolean;
size?: number;
mtime?: number;
children?: TreeNode[];
}
export interface ListTreeOptions {
maxDepth?: number;
maxNodes?: number;
includeHidden?: boolean;
}
export interface ListTreeResult {
ok: boolean;
root?: TreeNode;
truncated?: boolean;
error?: FilePreviewError;
}
export const readTextFile = (path: string): Promise<ReadTextFileResult> =>
invokeIpc<ReadTextFileResult>('file:readText', path);
export const readBinaryFile = (
path: string,
opts?: ReadBinaryFileOptions,
): Promise<ReadBinaryFileResult> =>
invokeIpc<ReadBinaryFileResult>('file:readBinary', path, opts);
export const writeTextFile = (path: string, content: string): Promise<WriteTextFileResult> =>
invokeIpc<WriteTextFileResult>('file:writeText', path, content);
interface SaveTranscriptResult {
status: 'saved' | 'cancelled';
}
export async function saveMarkdownTranscript(
defaultPath: string,
markdown: string,
): Promise<'saved' | 'cancelled'> {
const result = await invokeIpc<SaveTranscriptResult>('transcript:save', {
defaultPath,
markdown,
});
if (result.status !== 'saved' && result.status !== 'cancelled') {
throw new Error('Invalid transcript save result');
}
return result.status;
}
export const statFile = (path: string): Promise<StatFileResult> =>
invokeIpc<StatFileResult>('file:stat', path);
export const listDir = (path: string): Promise<ListDirResult> =>
invokeIpc<ListDirResult>('file:listDir', path);
export const listTree = (path: string, opts?: ListTreeOptions): Promise<ListTreeResult> =>
invokeIpc<ListTreeResult>('file:listTree', path, opts);