committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
239
src/lib/gateway-client.ts
Normal file
239
src/lib/gateway-client.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { hostApiFetch } from './host-api';
|
||||
|
||||
type GatewayInfo = {
|
||||
wsUrl: string;
|
||||
token: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type GatewayEventHandler = (payload: unknown) => void;
|
||||
|
||||
class GatewayBrowserClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private gatewayInfo: GatewayInfo | null = null;
|
||||
private pendingRequests = new Map<string, PendingRequest>();
|
||||
private eventHandlers = new Map<string, Set<GatewayEventHandler>>();
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
await this.connectPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectPromise = this.openSocket();
|
||||
try {
|
||||
await this.connectPromise;
|
||||
} finally {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
for (const [, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Gateway connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
|
||||
await this.connect();
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Gateway socket is not connected');
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const request = {
|
||||
type: 'req',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Gateway RPC timeout: ${method}`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
this.ws!.send(JSON.stringify(request));
|
||||
});
|
||||
}
|
||||
|
||||
on(eventName: string, handler: GatewayEventHandler): () => void {
|
||||
const handlers = this.eventHandlers.get(eventName) || new Set<GatewayEventHandler>();
|
||||
handlers.add(handler);
|
||||
this.eventHandlers.set(eventName, handlers);
|
||||
|
||||
return () => {
|
||||
const current = this.eventHandlers.get(eventName);
|
||||
current?.delete(handler);
|
||||
if (current && current.size === 0) {
|
||||
this.eventHandlers.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async openSocket(): Promise<void> {
|
||||
this.gatewayInfo = await hostApiFetch<GatewayInfo>('/api/app/gateway-info');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(this.gatewayInfo!.wsUrl);
|
||||
let resolved = false;
|
||||
let challengeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
challengeTimer = setTimeout(() => {
|
||||
rejectOnce(new Error('Gateway connect challenge timeout'));
|
||||
ws.close();
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
||||
if (message.type === 'event' && message.event === 'connect.challenge') {
|
||||
const nonce = (message.payload as { nonce?: string } | undefined)?.nonce;
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
const connectFrame = {
|
||||
type: 'req',
|
||||
id: `connect-${Date.now()}`,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'gateway-client',
|
||||
displayName: 'ClawX',
|
||||
version: '0.1.0',
|
||||
platform: navigator.platform,
|
||||
mode: 'ui',
|
||||
},
|
||||
auth: {
|
||||
token: this.gatewayInfo?.token,
|
||||
},
|
||||
caps: [],
|
||||
role: 'operator',
|
||||
scopes: ['operator.admin'],
|
||||
},
|
||||
};
|
||||
ws.send(JSON.stringify(connectFrame));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'res' && typeof message.id === 'string') {
|
||||
if (String(message.id).startsWith('connect-')) {
|
||||
this.ws = ws;
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(message.id);
|
||||
if (message.ok === false || message.error) {
|
||||
const errorMessage = typeof message.error === 'object' && message.error !== null
|
||||
? String((message.error as { message?: string }).message || JSON.stringify(message.error))
|
||||
: String(message.error || 'Gateway request failed');
|
||||
pending.reject(new Error(errorMessage));
|
||||
} else {
|
||||
pending.resolve(message.payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'event' && typeof message.event === 'string') {
|
||||
this.emitEvent(message.event, message.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message.method === 'string') {
|
||||
this.emitEvent(message.method, message.params);
|
||||
}
|
||||
} catch (error) {
|
||||
rejectOnce(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
rejectOnce(new Error('Gateway WebSocket error'));
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
if (!resolved) {
|
||||
rejectOnce(new Error('Gateway WebSocket closed before connect'));
|
||||
return;
|
||||
}
|
||||
for (const [, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Gateway connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
this.emitEvent('__close__', null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private emitEvent(eventName: string, payload: unknown): void {
|
||||
const handlers = this.eventHandlers.get(eventName);
|
||||
if (!handlers) return;
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch {
|
||||
// ignore handler failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gatewayClient = new GatewayBrowserClient();
|
||||
42
src/lib/host-api.ts
Normal file
42
src/lib/host-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const HOST_API_PORT = 3210;
|
||||
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = await response.json() as { error?: string };
|
||||
if (payload?.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore body parse failure
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export function createHostEventSource(path = '/api/events'): EventSource {
|
||||
return new EventSource(`${HOST_API_BASE}${path}`);
|
||||
}
|
||||
|
||||
export function getHostApiBase(): string {
|
||||
return HOST_API_BASE;
|
||||
}
|
||||
25
src/lib/host-events.ts
Normal file
25
src/lib/host-events.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createHostEventSource } from './host-api';
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
function getEventSource(): EventSource {
|
||||
if (!eventSource) {
|
||||
eventSource = createHostEventSource();
|
||||
}
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
export function subscribeHostEvent<T = unknown>(
|
||||
eventName: string,
|
||||
handler: (payload: T) => void,
|
||||
): () => void {
|
||||
const source = getEventSource();
|
||||
const listener = (event: Event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as T;
|
||||
handler(payload);
|
||||
};
|
||||
source.addEventListener(eventName, listener);
|
||||
return () => {
|
||||
source.removeEventListener(eventName, listener);
|
||||
};
|
||||
}
|
||||
122
src/lib/provider-accounts.ts
Normal file
122
src/lib/provider-accounts.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderType,
|
||||
ProviderVendorInfo,
|
||||
ProviderWithKeyInfo,
|
||||
} from '@/lib/providers';
|
||||
|
||||
export interface ProviderSnapshot {
|
||||
accounts: ProviderAccount[];
|
||||
statuses: ProviderWithKeyInfo[];
|
||||
vendors: ProviderVendorInfo[];
|
||||
defaultAccountId: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderListItem {
|
||||
account: ProviderAccount;
|
||||
vendor?: ProviderVendorInfo;
|
||||
status?: ProviderWithKeyInfo;
|
||||
}
|
||||
|
||||
export async function fetchProviderSnapshot(): Promise<ProviderSnapshot> {
|
||||
const [accounts, statuses, vendors, defaultInfo] = await Promise.all([
|
||||
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'),
|
||||
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'),
|
||||
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'),
|
||||
hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
statuses,
|
||||
vendors,
|
||||
defaultAccountId: defaultInfo.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasConfiguredCredentials(
|
||||
account: ProviderAccount,
|
||||
status?: ProviderWithKeyInfo,
|
||||
): boolean {
|
||||
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
|
||||
return true;
|
||||
}
|
||||
return status?.hasKey ?? false;
|
||||
}
|
||||
|
||||
export function pickPreferredAccount(
|
||||
accounts: ProviderAccount[],
|
||||
defaultAccountId: string | null,
|
||||
vendorId: ProviderType | string,
|
||||
statusMap: Map<string, ProviderWithKeyInfo>,
|
||||
): ProviderAccount | null {
|
||||
const sameVendor = accounts.filter((account) => account.vendorId === vendorId);
|
||||
if (sameVendor.length === 0) return null;
|
||||
|
||||
return (
|
||||
(defaultAccountId ? sameVendor.find((account) => account.id === defaultAccountId) : undefined)
|
||||
|| sameVendor.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id)))
|
||||
|| sameVendor[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderAccountId(
|
||||
vendorId: ProviderType,
|
||||
existingAccountId: string | null,
|
||||
vendors: ProviderVendorInfo[],
|
||||
): string {
|
||||
if (existingAccountId) {
|
||||
return existingAccountId;
|
||||
}
|
||||
|
||||
const vendor = vendors.find((candidate) => candidate.id === vendorId);
|
||||
return vendor?.supportsMultipleAccounts ? `${vendorId}-${crypto.randomUUID()}` : vendorId;
|
||||
}
|
||||
|
||||
export function legacyProviderToAccount(provider: ProviderWithKeyInfo): ProviderAccount {
|
||||
return {
|
||||
id: provider.id,
|
||||
vendorId: provider.type,
|
||||
label: provider.name,
|
||||
authMode: provider.type === 'ollama' ? 'local' : 'api_key',
|
||||
baseUrl: provider.baseUrl,
|
||||
model: provider.model,
|
||||
fallbackModels: provider.fallbackModels,
|
||||
fallbackAccountIds: provider.fallbackProviderIds,
|
||||
enabled: provider.enabled,
|
||||
isDefault: false,
|
||||
createdAt: provider.createdAt,
|
||||
updatedAt: provider.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProviderListItems(
|
||||
accounts: ProviderAccount[],
|
||||
statuses: ProviderWithKeyInfo[],
|
||||
vendors: ProviderVendorInfo[],
|
||||
defaultAccountId: string | null,
|
||||
): ProviderListItem[] {
|
||||
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
|
||||
const statusMap = new Map(statuses.map((status) => [status.id, status]));
|
||||
|
||||
if (accounts.length > 0) {
|
||||
return accounts
|
||||
.map((account) => ({
|
||||
account,
|
||||
vendor: vendorMap.get(account.vendorId),
|
||||
status: statusMap.get(account.id),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.account.id === defaultAccountId) return -1;
|
||||
if (right.account.id === defaultAccountId) return 1;
|
||||
return right.account.updatedAt.localeCompare(left.account.updatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
return statuses.map((status) => ({
|
||||
account: legacyProviderToAccount(status),
|
||||
vendor: vendorMap.get(status.type),
|
||||
status,
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Provider Types & UI Metadata — single source of truth for the frontend.
|
||||
*
|
||||
* NOTE: When adding a new provider type, also update
|
||||
* electron/utils/provider-registry.ts (env vars, models, configs).
|
||||
* NOTE: Backend provider metadata is being refactored toward the new
|
||||
* account-based registry, but the renderer still keeps a local compatibility
|
||||
* layer so TypeScript project boundaries remain stable during the migration.
|
||||
*/
|
||||
|
||||
export const PROVIDER_TYPES = [
|
||||
@@ -21,6 +22,20 @@ export const PROVIDER_TYPES = [
|
||||
] as const;
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
|
||||
export const BUILTIN_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'ark',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'minimax-portal-cn',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
] as const;
|
||||
|
||||
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
|
||||
|
||||
export interface ProviderConfig {
|
||||
@@ -46,37 +61,80 @@ export interface ProviderTypeInfo {
|
||||
name: string;
|
||||
icon: string;
|
||||
placeholder: string;
|
||||
/** Model brand name for display (e.g. "Claude", "GPT") */
|
||||
model?: string;
|
||||
requiresApiKey: boolean;
|
||||
/** Pre-filled base URL (for proxy/compatible providers like SiliconFlow) */
|
||||
defaultBaseUrl?: string;
|
||||
/** Whether the user can edit the base URL in setup */
|
||||
showBaseUrl?: boolean;
|
||||
/** Whether to show a Model ID input field (for providers where user picks the model) */
|
||||
showModelId?: boolean;
|
||||
/** Whether the Model ID input should only be shown in developer mode */
|
||||
showModelIdInDevModeOnly?: boolean;
|
||||
/** Default / example model ID placeholder */
|
||||
modelIdPlaceholder?: string;
|
||||
/** Default model ID to pre-fill */
|
||||
defaultModelId?: string;
|
||||
/** Whether this provider uses OAuth device flow instead of an API key */
|
||||
isOAuth?: boolean;
|
||||
/** Whether this provider also accepts a direct API key (in addition to OAuth) */
|
||||
supportsApiKey?: boolean;
|
||||
/** URL where users can apply for the API Key */
|
||||
apiKeyUrl?: string;
|
||||
}
|
||||
|
||||
export type ProviderAuthMode =
|
||||
| 'api_key'
|
||||
| 'oauth_device'
|
||||
| 'oauth_browser'
|
||||
| 'local';
|
||||
|
||||
export type ProviderVendorCategory =
|
||||
| 'official'
|
||||
| 'compatible'
|
||||
| 'local'
|
||||
| 'custom';
|
||||
|
||||
export interface ProviderVendorInfo extends ProviderTypeInfo {
|
||||
category: ProviderVendorCategory;
|
||||
envVar?: string;
|
||||
supportedAuthModes: ProviderAuthMode[];
|
||||
defaultAuthMode: ProviderAuthMode;
|
||||
supportsMultipleAccounts: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string;
|
||||
vendorId: ProviderType;
|
||||
label: string;
|
||||
authMode: ProviderAuthMode;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
metadata?: {
|
||||
region?: string;
|
||||
email?: string;
|
||||
resourceUrl?: string;
|
||||
customModels?: string[];
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
import { providerIcons } from '@/assets/providers';
|
||||
|
||||
/** All supported provider types with UI metadata */
|
||||
export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
||||
{ id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true },
|
||||
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
|
||||
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true },
|
||||
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' },
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: '🔷',
|
||||
placeholder: 'AIza...',
|
||||
model: 'Gemini',
|
||||
requiresApiKey: true,
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
defaultModelId: 'gemini-3.1-pro-preview',
|
||||
apiKeyUrl: 'https://aistudio.google.com/app/apikey',
|
||||
},
|
||||
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' },
|
||||
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' },
|
||||
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
|
||||
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3' },
|
||||
|
||||
Reference in New Issue
Block a user