Makelore 2.0 initial clean snapshot
This commit is contained in:
286
src/lib/api-client.ts
Normal file
286
src/lib/api-client.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user