Refactor clawx (#344)

Co-authored-by: ashione <skyzlxuan@gmail.com>
This commit is contained in:
paisley
2026-03-09 13:10:42 +08:00
committed by GitHub
Unverified
parent 3d804a9f5e
commit 2c5c82bb74
75 changed files with 7640 additions and 3106 deletions

11
electron/api/context.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService } from '../gateway/clawhub';
import type { HostEventBus } from './event-bus';
export interface HostApiContext {
gatewayManager: GatewayManager;
clawHubService: ClawHubService;
eventBus: HostEventBus;
mainWindow: BrowserWindow | null;
}

36
electron/api/event-bus.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { ServerResponse } from 'http';
type EventPayload = unknown;
export class HostEventBus {
private readonly clients = new Set<ServerResponse>();
addSseClient(res: ServerResponse): void {
this.clients.add(res);
res.on('close', () => {
this.clients.delete(res);
});
}
emit(eventName: string, payload: EventPayload): void {
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.clients) {
try {
client.write(message);
} catch {
this.clients.delete(client);
}
}
}
closeAll(): void {
for (const client of this.clients) {
try {
client.end();
} catch {
// Ignore individual client close failures.
}
}
this.clients.clear();
}
}

View File

@@ -0,0 +1,39 @@
import type { IncomingMessage, ServerResponse } from 'http';
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) {
return {} as T;
}
return JSON.parse(raw) as T;
}
export function setCorsHeaders(res: ServerResponse): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
setCorsHeaders(res);
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
export function sendNoContent(res: ServerResponse): void {
setCorsHeaders(res);
res.statusCode = 204;
res.end();
}
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
setCorsHeaders(res);
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(text);
}

View File

@@ -0,0 +1,32 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { setCorsHeaders, sendNoContent } from '../route-utils';
export async function handleAppRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/events' && req.method === 'GET') {
setCorsHeaders(res);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
ctx.eventBus.addSseClient(res);
// Send a current-state snapshot immediately so renderer subscribers do not
// miss lifecycle transitions that happened before the SSE connection opened.
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
return true;
}
if (req.method === 'OPTIONS') {
sendNoContent(res);
return true;
}
return false;
}

View File

@@ -0,0 +1,167 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { app } from 'electron';
import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
deleteChannelConfig,
getChannelFormValues,
listConfiguredChannels,
saveChannelConfig,
setChannelEnabled,
validateChannelConfig,
validateChannelCredentials,
} from '../../utils/channel-config';
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
if (existsSync(targetManifest)) {
return { installed: true };
}
const candidateSources = app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk'),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'),
join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'),
join(__dirname, '../../../build/openclaw-plugins/dingtalk'),
];
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
if (!sourceDir) {
return {
installed: false,
warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
}
try {
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
rmSync(targetDir, { recursive: true, force: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
if (!existsSync(targetManifest)) {
return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' };
}
return { installed: true };
} catch {
return { installed: false, warning: 'Failed to install bundled DingTalk plugin mirror' };
}
}
export async function handleChannelRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
return true;
}
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string }>(req);
sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) });
} catch (error) {
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
}
return true;
}
if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, string> }>(req);
sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) });
} catch (error) {
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
}
return true;
}
if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
await whatsAppLoginManager.start(body.accountId);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') {
try {
await whatsAppLoginManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown> }>(req);
if (body.channelType === 'dingtalk') {
const installResult = await ensureDingTalkPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
return true;
}
}
await saveChannelConfig(body.channelType, body.config);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
await setChannelEnabled(body.channelType, body.enabled);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
sendJson(res, 200, {
success: true,
values: await getChannelFormValues(channelType),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
await deleteChannelConfig(channelType);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
void ctx;
return false;
}

168
electron/api/routes/cron.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const channelType = job.delivery?.channel;
const target = channelType
? { channelType, channelId: channelType, channelName: channelType }
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs
? new Date(job.state.nextRunAtMs).toISOString()
: undefined;
return {
id: job.id,
name: job.name,
message,
schedule: job.schedule,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
};
}
export async function handleCronRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
try {
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
const data = result as { jobs?: GatewayCronJob[] };
const jobs = data?.jobs ?? [];
for (const job of jobs) {
const isIsolatedAgent =
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn';
const needsRepair =
isIsolatedAgent &&
job.delivery?.mode === 'announce' &&
!job.delivery?.channel;
if (needsRepair) {
try {
await ctx.gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
} catch {
// ignore per-job repair failure
}
}
}
sendJson(res, 200, jobs.map(transformCronJob));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,200 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { dialog, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { extname, join } from 'node:path';
import { homedir } from 'node:os';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
export async function handleFileRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ filePaths: string[] }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of body.filePaths) {
const id = crypto.randomUUID();
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const fileName = filePath.split(/[\\/]/).pop() || 'file';
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = body.mimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
sendJson(res, 200, {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const { filePath, mimeType } of body.paths) {
try {
const s = await fsP.stat(filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(filePath, mimeType)
: null;
results[filePath] = { preview, fileSize: s.size };
} catch {
results[filePath] = { preview: null, fileSize: 0 };
}
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
base64?: string;
mimeType?: string;
filePath?: string;
defaultFileName: string;
}>(req);
const ext = body.defaultFileName.includes('.')
? body.defaultFileName.split('.').pop()!
: (body.mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) {
sendJson(res, 200, { success: false });
return true;
}
const fsP = await import('node:fs/promises');
if (body.filePath) {
await fsP.copyFile(body.filePath, result.filePath);
} else if (body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
sendJson(res, 400, { success: false, error: 'No image data provided' });
return true;
}
sendJson(res, 200, { success: true, savedPath: result.filePath });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,129 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../../utils/config';
import { getSetting } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleGatewayRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
sendJson(res, 200, {
wsUrl: `ws://127.0.0.1:${port}/ws`,
token,
port,
});
return true;
}
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
sendJson(res, 200, ctx.gatewayManager.getStatus());
return true;
}
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
const health = await ctx.gatewayManager.checkHealth();
sendJson(res, 200, health);
return true;
}
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
try {
await ctx.gatewayManager.start();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
try {
await ctx.gatewayManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
try {
await ctx.gatewayManager.restart();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
try {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const urlValue = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
sendJson(res, 200, { success: true, url: urlValue, token, port });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
}>(req);
const VISION_MIME_TYPES = new Set([
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
]);
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
const fileReferences: string[] = [];
if (body.media && body.media.length > 0) {
const fsP = await import('node:fs/promises');
for (const m of body.media) {
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
if (VISION_MIME_TYPES.has(m.mimeType)) {
const fileBuffer = await fsP.readFile(m.filePath);
imageAttachments.push({
content: fileBuffer.toString('base64'),
mimeType: m.mimeType,
fileName: m.fileName,
});
}
}
}
const message = fileReferences.length > 0
? [body.message, ...fileReferences].filter(Boolean).join('\n')
: body.message;
const rpcParams: Record<string, unknown> = {
sessionKey: body.sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey: body.idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,29 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleLogRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/logs' && req.method === 'GET') {
const tailLines = Number(url.searchParams.get('tailLines') || '100');
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
return true;
}
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
sendJson(res, 200, { dir: logger.getLogDir() });
return true;
}
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
sendJson(res, 200, { files: await logger.listLogFiles() });
return true;
}
return false;
}

View File

@@ -0,0 +1,295 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
deleteApiKey,
deleteProvider,
getAllProvidersWithKeyInfo,
getApiKey,
getDefaultProvider,
getProvider,
hasApiKey,
saveProvider,
setDefaultProvider,
storeApiKey,
type ProviderConfig,
} from '../../utils/secure-storage';
import {
getProviderConfig,
} from '../../utils/provider-registry';
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import {
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from '../../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
import { getProviderService } from '../../services/providers/provider-service';
import { providerAccountToConfig } from '../../services/providers/provider-store';
import type { ProviderAccount } from '../../shared/providers/types';
export async function handleProviderRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const providerService = getProviderService();
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
sendJson(res, 200, await providerService.listVendors());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
sendJson(res, 200, await providerService.listAccounts());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
const account = await providerService.createAccount(body.account, body.apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
await providerService.setDefaultAccount(body.accountId);
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
sendJson(res, 200, await providerService.getAccount(accountId));
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
const existing = await providerService.getAccount(accountId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider account not found' });
return true;
}
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account: nextAccount });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser'
? 'google-gemini-cli'
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx.gatewayManager,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'GET') {
sendJson(res, 200, await getAllProvidersWithKeyInfo());
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
sendJson(res, 200, { providerId: await getDefaultProvider() ?? null });
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ providerId: string }>(req);
await setDefaultProvider(body.providerId);
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req);
const provider = await getProvider(body.providerId);
const providerType = provider?.type || body.providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl }));
} catch (error) {
sendJson(res, 500, { valid: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
});
} else {
await deviceOAuthManager.startFlow(body.provider, body.region, {
accountId: body.accountId,
label: body.label,
});
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
const config = body.config;
await saveProvider(config);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await storeApiKey(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
if (providerId.endsWith('/api-key')) {
const actualId = providerId.slice(0, -('/api-key'.length));
sendJson(res, 200, { apiKey: await getApiKey(actualId) });
return true;
}
if (providerId.endsWith('/has-api-key')) {
const actualId = providerId.slice(0, -('/has-api-key'.length));
sendJson(res, 200, { hasKey: await hasApiKey(actualId) });
return true;
}
sendJson(res, 200, await getProvider(providerId));
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
const existing = await getProvider(providerId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider not found' });
return true;
}
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
await saveProvider(nextConfig);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await storeApiKey(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await deleteApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
}
}
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const existing = await getProvider(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await deleteApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
sendJson(res, 200, { success: true });
return true;
}
await deleteProvider(providerId);
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,96 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSessionRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const sessionKey = body.sessionKey;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
return true;
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
} catch {
// Non-fatal; still try to update sessions.json.
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,98 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { applyProxySettings } from '../../main/proxy';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
const settings = await getAllSettings();
await applyProxySettings(settings);
if (ctx.gatewayManager.getStatus().state === 'running') {
await ctx.gatewayManager.restart();
}
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
));
}
export async function handleSettingsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/settings' && req.method === 'GET') {
sendJson(res, 200, await getAllSettings());
return true;
}
if (url.pathname === '/api/settings' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<Partial<AppSettings>>(req);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(ctx);
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
sendJson(res, 200, { value: await getSetting(key) });
} catch (error) {
sendJson(res, 404, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
await setSetting(key, body.value);
if (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
) {
await handleProxySettingsChange(ctx);
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
try {
await resetSettings();
await handleProxySettingsChange(ctx);
sendJson(res, 200, { success: true, settings: await getAllSettings() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,90 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSkillRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
sendJson(res, 200, await getAllSkillConfigs());
return true;
}
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{
skillKey: string;
apiKey?: string;
env?: Record<string, string>;
}>(req);
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
apiKey: body.apiKey,
env: body.env,
}));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
sendJson(res, 200, {
success: true,
results: await ctx.clawHubService.search(body),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.install(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.uninstall(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
try {
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug: string }>(req);
await ctx.clawHubService.openSkillReadme(body.slug);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}

View File

@@ -0,0 +1,20 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleUsageRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
const parsedLimit = Number(url.searchParams.get('limit') || '');
const limit = Number.isFinite(parsedLimit) ? Math.max(Math.floor(parsedLimit), 1) : undefined;
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
return true;
}
return false;
}

60
electron/api/server.ts Normal file
View File

@@ -0,0 +1,60 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { PORTS } from '../utils/config';
import { logger } from '../utils/logger';
import type { HostApiContext } from './context';
import { handleAppRoutes } from './routes/app';
import { handleGatewayRoutes } from './routes/gateway';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleChannelRoutes } from './routes/channels';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleSkillRoutes } from './routes/skills';
import { handleFileRoutes } from './routes/files';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { sendJson } from './route-utils';
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
const routeHandlers: RouteHandler[] = [
handleAppRoutes,
handleGatewayRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleChannelRoutes,
handleSkillRoutes,
handleFileRoutes,
handleSessionRoutes,
handleCronRoutes,
handleLogRoutes,
handleUsageRoutes,
];
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
for (const handler of routeHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
} catch (error) {
logger.error('Host API request failed:', error);
sendJson(res, 500, { success: false, error: String(error) });
}
});
server.listen(port, '127.0.0.1', () => {
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
});
return server;
}

View File

@@ -0,0 +1,182 @@
import { app } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { listConfiguredChannels } from '../utils/channel-config';
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
export interface GatewayLaunchContext {
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
openclawDir: string;
entryScript: string;
gatewayArgs: string[];
forkEnv: Record<string, string | undefined>;
mode: 'dev' | 'packaged';
binPathExists: boolean;
loadedProviderKeyCount: number;
proxySummary: string;
channelStartupSummary: string;
}
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
): Promise<void> {
await syncProxyConfigToOpenClaw(appSettings);
try {
await sanitizeOpenClawConfig();
} catch (err) {
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await syncGatewayTokenToConfig(appSettings.gatewayToken);
} catch (err) {
logger.warn('Failed to sync gateway token to openclaw.json:', err);
}
try {
await syncBrowserConfigToOpenClaw();
} catch (err) {
logger.warn('Failed to sync browser config to openclaw.json:', err);
}
}
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
const providerEnv: Record<string, string> = {};
const providerTypes = getKeyableProviderTypes();
let loadedProviderKeyCount = 0;
try {
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId) {
const defaultProvider = await getProvider(defaultProviderId);
const defaultProviderType = defaultProvider?.type;
const defaultProviderKey = await getApiKey(defaultProviderId);
if (defaultProviderType && defaultProviderKey) {
const envVar = getProviderEnvVar(defaultProviderType);
if (envVar) {
providerEnv[envVar] = defaultProviderKey;
loadedProviderKeyCount++;
}
}
}
} catch (err) {
logger.warn('Failed to load default provider key for environment injection:', err);
}
for (const providerType of providerTypes) {
try {
const key = await getApiKey(providerType);
if (key) {
const envVar = getProviderEnvVar(providerType);
if (envVar) {
providerEnv[envVar] = key;
loadedProviderKeyCount++;
}
}
} catch (err) {
logger.warn(`Failed to load API key for ${providerType}:`, err);
}
}
return { providerEnv, loadedProviderKeyCount };
}
async function resolveChannelStartupPolicy(): Promise<{
skipChannels: boolean;
channelStartupSummary: string;
}> {
try {
const configuredChannels = await listConfiguredChannels();
if (configuredChannels.length === 0) {
return {
skipChannels: true,
channelStartupSummary: 'skipped(no configured channels)',
};
}
return {
skipChannels: false,
channelStartupSummary: `enabled(${configuredChannels.join(',')})`,
};
} catch (error) {
logger.warn('Failed to determine configured channels for gateway launch:', error);
return {
skipChannels: false,
channelStartupSummary: 'enabled(unknown)',
};
}
}
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
if (!isOpenClawPresent()) {
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
const appSettings = await getAllSettings();
await syncGatewayConfigBeforeLaunch(appSettings);
if (!existsSync(entryScript)) {
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
}
const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured'];
const mode = app.isPackaged ? 'packaged' : 'dev';
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
const uvEnv = await getUvMirrorEnv();
const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings);
const proxySummary = appSettings.proxyEnabled
? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}`
: 'disabled';
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const forkEnv: Record<string, string | undefined> = {
...baseEnv,
PATH: finalPath,
...providerEnv,
...uvEnv,
...proxyEnv,
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
};
return {
appSettings,
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
};
}

View File

@@ -0,0 +1,59 @@
import { logger } from '../utils/logger';
type HealthResult = { ok: boolean; error?: string };
export class GatewayConnectionMonitor {
private pingInterval: NodeJS.Timeout | null = null;
private healthCheckInterval: NodeJS.Timeout | null = null;
startPing(sendPing: () => void, intervalMs = 30000): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
sendPing();
}, intervalMs);
}
startHealthCheck(options: {
shouldCheck: () => boolean;
checkHealth: () => Promise<HealthResult>;
onUnhealthy: (errorMessage: string) => void;
onError: (error: unknown) => void;
intervalMs?: number;
}): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
if (!options.shouldCheck()) {
return;
}
try {
const health = await options.checkHealth();
if (!health.ok) {
const errorMessage = health.error ?? 'Health check failed';
logger.warn(`Gateway health check failed: ${errorMessage}`);
options.onUnhealthy(errorMessage);
}
} catch (error) {
logger.error('Gateway health check error:', error);
options.onError(error);
}
}, options.intervalMs ?? 30000);
}
clear(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
}

View File

@@ -0,0 +1,63 @@
import { GatewayEventType, type JsonRpcNotification } from './protocol';
import { logger } from '../utils/logger';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
};
export function dispatchProtocolEvent(
emitter: GatewayEventEmitter,
event: string,
payload: unknown,
): void {
switch (event) {
case 'tick':
break;
case 'chat':
emitter.emit('chat:message', { message: payload });
break;
case 'agent': {
const p = payload as Record<string, unknown>;
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
const chatEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
if (chatEvent.state || chatEvent.message) {
emitter.emit('chat:message', { message: chatEvent });
}
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
emitter.emit('channel:status', payload as { channelId: string; status: string });
break;
default:
emitter.emit('notification', { method: event, params: payload });
}
}
export function dispatchJsonRpcNotification(
emitter: GatewayEventEmitter,
notification: JsonRpcNotification,
): void {
emitter.emit('notification', notification);
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
break;
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as { message: unknown });
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
emitter.emit('error', new Error(errorData.message || 'Gateway error'));
break;
}
default:
logger.debug(`Unknown Gateway notification: ${notification.method}`);
}
}

View File

@@ -0,0 +1,31 @@
import { logger } from '../utils/logger';
import { isLifecycleSuperseded, nextLifecycleEpoch } from './process-policy';
export class LifecycleSupersededError extends Error {
constructor(message: string) {
super(message);
this.name = 'LifecycleSupersededError';
}
}
export class GatewayLifecycleController {
private epoch = 0;
getCurrentEpoch(): number {
return this.epoch;
}
bump(reason: string): number {
this.epoch = nextLifecycleEpoch(this.epoch);
logger.debug(`Gateway lifecycle epoch advanced to ${this.epoch} (${reason})`);
return this.epoch;
}
assert(expectedEpoch: number, phase: string): void {
if (isLifecycleSuperseded(expectedEpoch, this.epoch)) {
throw new LifecycleSupersededError(
`Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.epoch})`,
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
import { app, utilityProcess } from 'electron';
import { existsSync, writeFileSync } from 'fs';
import path from 'path';
import type { GatewayLaunchContext } from './config-sync';
import type { GatewayLifecycleState } from './process-policy';
import { logger } from '../utils/logger';
import { appendNodeRequireToNodeOptions } from '../utils/paths';
const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
(function () {
var _f = globalThis.fetch;
if (typeof _f !== 'function') return;
if (globalThis.__clawxFetchPatched) return;
globalThis.__clawxFetchPatched = true;
globalThis.fetch = function clawxFetch(input, init) {
var url =
typeof input === 'string' ? input
: input && typeof input === 'object' && typeof input.url === 'string'
? input.url : '';
if (url.indexOf('openrouter.ai') !== -1) {
init = init ? Object.assign({}, init) : {};
var prev = init.headers;
var flat = {};
if (prev && typeof prev.forEach === 'function') {
prev.forEach(function (v, k) { flat[k] = v; });
} else if (prev && typeof prev === 'object') {
Object.assign(flat, prev);
}
delete flat['http-referer'];
delete flat['HTTP-Referer'];
delete flat['x-title'];
delete flat['X-Title'];
flat['HTTP-Referer'] = 'https://claw-x.com';
flat['X-Title'] = 'ClawX';
init.headers = flat;
}
return _f.call(globalThis, input, init);
};
if (process.platform === 'win32') {
try {
var cp = require('child_process');
if (!cp.__clawxPatched) {
cp.__clawxPatched = true;
['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
var original = cp[method];
if (typeof original !== 'function') return;
cp[method] = function() {
var args = Array.prototype.slice.call(arguments);
var optIdx = -1;
for (var i = 1; i < args.length; i++) {
var a = args[i];
if (a && typeof a === 'object' && !Array.isArray(a)) {
optIdx = i;
break;
}
}
if (optIdx >= 0) {
args[optIdx].windowsHide = true;
} else {
var opts = { windowsHide: true };
if (typeof args[args.length - 1] === 'function') {
args.splice(args.length - 1, 0, opts);
} else {
args.push(opts);
}
}
return original.apply(this, args);
};
});
}
} catch (e) {
// ignore
}
}
})();
`;
function ensureGatewayFetchPreload(): string {
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
try {
writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8');
} catch {
// best-effort
}
return dest;
}
export async function launchGatewayProcess(options: {
port: number;
launchContext: GatewayLaunchContext;
sanitizeSpawnArgs: (args: string[]) => string[];
getCurrentState: () => GatewayLifecycleState;
getShouldReconnect: () => boolean;
onStderrLine: (line: string) => void;
onSpawn: (pid: number | undefined) => void;
onExit: (child: Electron.UtilityProcess, code: number | null) => void;
onError: (error: Error) => void;
}): Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }> {
const {
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
} = options.launchContext;
logger.info(
`Starting Gateway process (mode=${mode}, port=${options.port}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, channels=${channelStartupSummary}, proxy=${proxySummary})`,
);
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
if (!app.isPackaged) {
try {
const preloadPath = ensureGatewayFetchPreload();
if (existsSync(preloadPath)) {
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
runtimeEnv.NODE_OPTIONS,
preloadPath,
);
}
} catch (err) {
logger.warn('Failed to set up OpenRouter headers preload:', err);
}
}
return await new Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }>((resolve, reject) => {
const child = utilityProcess.fork(entryScript, gatewayArgs, {
cwd: openclawDir,
stdio: 'pipe',
env: runtimeEnv as NodeJS.ProcessEnv,
serviceName: 'OpenClaw Gateway',
});
let settled = false;
const resolveOnce = () => {
if (settled) return;
settled = true;
resolve({ child, lastSpawnSummary });
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
child.on('error', (error) => {
logger.error('Gateway process spawn error:', error);
options.onError(error);
rejectOnce(error);
});
child.on('exit', (code: number) => {
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
const level = expectedExit ? logger.info : logger.warn;
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
options.onExit(child, code);
});
child.stderr?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
options.onStderrLine(line);
}
});
child.on('spawn', () => {
logger.info(`Gateway process started (pid=${child.pid})`);
options.onSpawn(child.pid);
resolveOnce();
});
});
}

View File

@@ -1,3 +1,15 @@
export interface ReconnectConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxAttempts: 10,
baseDelay: 1000,
maxDelay: 30000,
};
export function nextLifecycleEpoch(currentEpoch: number): number {
return currentEpoch + 1;
}
@@ -22,6 +34,53 @@ export function getReconnectSkipReason(context: ReconnectAttemptContext): string
return null;
}
export interface ReconnectScheduleContext {
shouldReconnect: boolean;
hasReconnectTimer: boolean;
reconnectAttempts: number;
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
export type ReconnectScheduleDecision =
| { action: 'skip'; reason: string }
| { action: 'already-scheduled' }
| { action: 'fail'; attempts: number; maxAttempts: number }
| { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number };
export function getReconnectScheduleDecision(
context: ReconnectScheduleContext,
): ReconnectScheduleDecision {
if (!context.shouldReconnect) {
return { action: 'skip', reason: 'auto-reconnect disabled' };
}
if (context.hasReconnectTimer) {
return { action: 'already-scheduled' };
}
if (context.reconnectAttempts >= context.maxAttempts) {
return {
action: 'fail',
attempts: context.reconnectAttempts,
maxAttempts: context.maxAttempts,
};
}
const delay = Math.min(
context.baseDelay * Math.pow(2, context.reconnectAttempts),
context.maxDelay,
);
return {
action: 'schedule',
nextAttempt: context.reconnectAttempts + 1,
maxAttempts: context.maxAttempts,
delay,
};
}
export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
export interface RestartDeferralContext {

View File

@@ -0,0 +1,42 @@
export interface PendingGatewayRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
export function clearPendingGatewayRequests(
pendingRequests: Map<string, PendingGatewayRequest>,
error: Error,
): void {
for (const [, request] of pendingRequests) {
clearTimeout(request.timeout);
request.reject(error);
}
pendingRequests.clear();
}
export function resolvePendingGatewayRequest(
pendingRequests: Map<string, PendingGatewayRequest>,
id: string,
value: unknown,
): boolean {
const request = pendingRequests.get(id);
if (!request) return false;
clearTimeout(request.timeout);
pendingRequests.delete(id);
request.resolve(value);
return true;
}
export function rejectPendingGatewayRequest(
pendingRequests: Map<string, PendingGatewayRequest>,
id: string,
error: Error,
): boolean {
const request = pendingRequests.get(id);
if (!request) return false;
clearTimeout(request.timeout);
pendingRequests.delete(id);
request.reject(error);
return true;
}

View File

@@ -0,0 +1,91 @@
import { logger } from '../utils/logger';
import {
getDeferredRestartAction,
shouldDeferRestart,
type GatewayLifecycleState,
} from './process-policy';
type RestartDeferralState = {
state: GatewayLifecycleState;
startLock: boolean;
};
type DeferredRestartContext = RestartDeferralState & {
shouldReconnect: boolean;
};
export class GatewayRestartController {
private deferredRestartPending = false;
private restartDebounceTimer: NodeJS.Timeout | null = null;
isRestartDeferred(context: RestartDeferralState): boolean {
return shouldDeferRestart(context);
}
markDeferredRestart(reason: string, context: RestartDeferralState): void {
if (!this.deferredRestartPending) {
logger.info(
`Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${context.state}, startLock=${context.startLock})`,
);
} else {
logger.debug(
`Gateway restart already deferred; keeping pending request (${reason}, state=${context.state}, startLock=${context.startLock})`,
);
}
this.deferredRestartPending = true;
}
flushDeferredRestart(
trigger: string,
context: DeferredRestartContext,
executeRestart: () => void,
): void {
const action = getDeferredRestartAction({
hasPendingRestart: this.deferredRestartPending,
state: context.state,
startLock: context.startLock,
shouldReconnect: context.shouldReconnect,
});
if (action === 'none') return;
if (action === 'wait') {
logger.debug(
`Deferred Gateway restart still waiting (${trigger}, state=${context.state}, startLock=${context.startLock})`,
);
return;
}
this.deferredRestartPending = false;
if (action === 'drop') {
logger.info(
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
);
return;
}
logger.info(`Executing deferred Gateway restart now (${trigger})`);
executeRestart();
}
debouncedRestart(delayMs: number, executeRestart: () => void): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
}
logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`);
this.restartDebounceTimer = setTimeout(() => {
this.restartDebounceTimer = null;
executeRestart();
}, delayMs);
}
clearDebounceTimer(): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
this.restartDebounceTimer = null;
}
}
resetDeferredRestart(): void {
this.deferredRestartPending = false;
}
}

View File

@@ -0,0 +1,106 @@
import { logger } from '../utils/logger';
import { LifecycleSupersededError } from './lifecycle-controller';
import { getGatewayStartupRecoveryAction } from './startup-recovery';
export interface ExistingGatewayInfo {
port: number;
externalToken?: string;
}
type StartupHooks = {
port: number;
ownedPid?: number;
shouldWaitForPortFree: boolean;
maxStartAttempts?: number;
resetStartupStderrLines: () => void;
getStartupStderrLines: () => string[];
assertLifecycle: (phase: string) => void;
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
connect: (port: number, externalToken?: string) => Promise<void>;
onConnectedToExistingGateway: () => void;
waitForPortFree: (port: number) => Promise<void>;
startProcess: () => Promise<void>;
waitForReady: (port: number) => Promise<void>;
onConnectedToManagedGateway: () => void;
runDoctorRepair: () => Promise<boolean>;
onDoctorRepairSuccess: () => void;
delay: (ms: number) => Promise<void>;
};
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
let configRepairAttempted = false;
let startAttempts = 0;
const maxStartAttempts = hooks.maxStartAttempts ?? 3;
while (true) {
startAttempts++;
hooks.assertLifecycle('start');
hooks.resetStartupStderrLines();
try {
logger.debug('Checking for existing Gateway...');
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
hooks.assertLifecycle('start/find-existing');
if (existing) {
logger.debug(`Found existing Gateway on port ${existing.port}`);
await hooks.connect(existing.port, existing.externalToken);
hooks.assertLifecycle('start/connect-existing');
hooks.onConnectedToExistingGateway();
return;
}
logger.debug('No existing Gateway found, starting new process...');
if (hooks.shouldWaitForPortFree) {
await hooks.waitForPortFree(hooks.port);
hooks.assertLifecycle('start/wait-port');
}
await hooks.startProcess();
hooks.assertLifecycle('start/start-process');
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready');
await hooks.connect(hooks.port);
hooks.assertLifecycle('start/connect');
hooks.onConnectedToManagedGateway();
return;
} catch (error) {
if (error instanceof LifecycleSupersededError) {
throw error;
}
const recoveryAction = getGatewayStartupRecoveryAction({
startupError: error,
startupStderrLines: hooks.getStartupStderrLines(),
configRepairAttempted,
attempt: startAttempts,
maxAttempts: maxStartAttempts,
});
if (recoveryAction === 'repair') {
configRepairAttempted = true;
logger.warn(
'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry',
);
const repaired = await hooks.runDoctorRepair();
if (repaired) {
logger.info('OpenClaw doctor repair completed; retrying Gateway startup');
hooks.onDoctorRepairSuccess();
continue;
}
logger.error('OpenClaw doctor repair failed; not retrying Gateway startup');
}
if (recoveryAction === 'retry') {
logger.warn(`Transient start error: ${String(error)}. Retrying... (${startAttempts}/${maxStartAttempts})`);
await hooks.delay(1000);
continue;
}
throw error;
}
}
}

View File

@@ -12,6 +12,14 @@ const INVALID_CONFIG_PATTERNS: RegExp[] = [
/\brun:\s*openclaw doctor --fix\b/i,
];
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/WebSocket closed before handshake/i,
/ECONNREFUSED/i,
/Gateway process exited before becoming ready/i,
/Timed out waiting for connect\.challenge/i,
/Connect handshake timeout/i,
];
function normalizeLogLine(value: string): string {
return value.trim();
}
@@ -58,3 +66,34 @@ export function shouldAttemptConfigAutoRepair(
return hasInvalidConfigFailureSignal(startupError, startupStderrLines);
}
export function isTransientGatewayStartError(error: unknown): boolean {
const errorText = error instanceof Error
? `${error.name}: ${error.message}`
: String(error ?? '');
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
}
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
export function getGatewayStartupRecoveryAction(options: {
startupError: unknown;
startupStderrLines: string[];
configRepairAttempted: boolean;
attempt: number;
maxAttempts: number;
}): GatewayStartupRecoveryAction {
if (shouldAttemptConfigAutoRepair(
options.startupError,
options.startupStderrLines,
options.configRepairAttempted,
)) {
return 'repair';
}
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
return 'retry';
}
return 'fail';
}

View File

@@ -0,0 +1,42 @@
export type GatewayStderrClassification = {
level: 'drop' | 'debug' | 'warn';
normalized: string;
};
const MAX_STDERR_LINES = 120;
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
const msg = message.trim();
if (!msg) {
return { level: 'drop', normalized: msg };
}
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
// Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
return { level: 'debug', normalized: msg };
}
return { level: 'warn', normalized: msg };
}
export function recordGatewayStartupStderrLine(lines: string[], line: string): void {
const normalized = line.trim();
if (!normalized) return;
lines.push(normalized);
if (lines.length > MAX_STDERR_LINES) {
lines.splice(0, lines.length - MAX_STDERR_LINES);
}
}

38
electron/gateway/state.ts Normal file
View File

@@ -0,0 +1,38 @@
import { PORTS } from '../utils/config';
import { logger } from '../utils/logger';
import type { GatewayStatus } from './manager';
type GatewayStateHooks = {
emitStatus: (status: GatewayStatus) => void;
onTransition?: (previousState: GatewayStatus['state'], nextState: GatewayStatus['state']) => void;
};
export class GatewayStateController {
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
constructor(private readonly hooks: GatewayStateHooks) {}
getStatus(): GatewayStatus {
return { ...this.status };
}
isConnected(isSocketOpen: boolean): boolean {
return this.status.state === 'running' && isSocketOpen;
}
setStatus(update: Partial<GatewayStatus>): void {
const previousState = this.status.state;
this.status = { ...this.status, ...update };
if (this.status.state === 'running' && this.status.connectedAt) {
this.status.uptime = Date.now() - this.status.connectedAt;
}
this.hooks.emitStatus(this.status);
if (previousState !== this.status.state) {
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
this.hooks.onTransition?.(previousState, this.status.state);
}
}
}

View File

@@ -0,0 +1,348 @@
import { app, utilityProcess } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import WebSocket from 'ws';
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
import { logger } from '../utils/logger';
export function warmupManagedPythonReadiness(): void {
void isPythonReady().then((pythonReady) => {
if (!pythonReady) {
logger.info('Python environment missing or incomplete, attempting background repair...');
void setupManagedPython().catch((err) => {
logger.error('Background Python repair failed:', err);
});
}
}).catch((err) => {
logger.error('Failed to check Python environment:', err);
});
}
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
let exited = false;
await new Promise<void>((resolve) => {
child.once('exit', () => {
exited = true;
resolve();
});
const pid = child.pid;
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
try {
child.kill();
} catch {
// ignore if already exited
}
const timeout = setTimeout(() => {
if (!exited) {
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
}
}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timeout);
});
});
}
export async function unloadLaunchctlGatewayService(): Promise<void> {
if (process.platform !== 'darwin') return;
try {
const uid = process.getuid?.();
if (uid === undefined) return;
const launchdLabel = 'ai.openclaw.gateway';
const serviceTarget = `gui/${uid}/${launchdLabel}`;
const cp = await import('child_process');
const fsPromises = await import('fs/promises');
const os = await import('os');
const loaded = await new Promise<boolean>((resolve) => {
cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => {
resolve(!err);
});
});
if (!loaded) return;
logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`);
await new Promise<void>((resolve) => {
cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => {
if (err) {
logger.warn(`Failed to bootout launchctl service: ${err.message}`);
} else {
logger.info('Successfully unloaded launchctl gateway service');
}
resolve();
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
await fsPromises.access(plistPath);
await fsPromises.unlink(plistPath);
logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`);
} catch {
// File doesn't exist or can't be removed -- not fatal
}
} catch (err) {
logger.warn('Error while unloading launchctl gateway service:', err);
}
}
export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<void> {
const net = await import('net');
const start = Date.now();
const pollInterval = 500;
let logged = false;
while (Date.now() - start < timeoutMs) {
const available = await new Promise<boolean>((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
if (available) {
const elapsed = Date.now() - start;
if (elapsed > pollInterval) {
logger.info(`Port ${port} became available after ${elapsed}ms`);
}
return;
}
if (!logged) {
logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`);
logged = true;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
}
async function getListeningProcessIds(port: number): Promise<string[]> {
const cmd = process.platform === 'win32'
? `netstat -ano | findstr :${port}`
: `lsof -i :${port} -sTCP:LISTEN -t`;
const cp = await import('child_process');
const { stdout } = await new Promise<{ stdout: string }>((resolve) => {
cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => {
if (err) {
resolve({ stdout: '' });
} else {
resolve({ stdout });
}
});
});
if (!stdout.trim()) {
return [];
}
if (process.platform === 'win32') {
const pids: string[] = [];
for (const line of stdout.trim().split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[3] === 'LISTENING') {
pids.push(parts[4]);
}
}
return [...new Set(pids)];
}
return [...new Set(stdout.trim().split(/\r?\n/).map((value) => value.trim()).filter(Boolean))];
}
async function terminateOrphanedProcessIds(port: number, pids: string[]): Promise<void> {
logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`);
if (process.platform === 'darwin') {
await unloadLaunchctlGatewayService();
}
for (const pid of pids) {
try {
if (process.platform === 'win32') {
const cp = await import('child_process');
await new Promise<void>((resolve) => {
cp.exec(
`taskkill /F /PID ${pid} /T`,
{ timeout: 5000, windowsHide: true },
() => resolve(),
);
});
} else {
process.kill(parseInt(pid, 10), 'SIGTERM');
}
} catch {
// Ignore processes that have already exited.
}
}
await new Promise((resolve) => setTimeout(resolve, process.platform === 'win32' ? 2000 : 3000));
if (process.platform !== 'win32') {
for (const pid of pids) {
try {
process.kill(parseInt(pid, 10), 0);
process.kill(parseInt(pid, 10), 'SIGKILL');
} catch {
// Already exited.
}
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
export async function findExistingGatewayProcess(options: {
port: number;
ownedPid?: number;
}): Promise<{ port: number; externalToken?: string } | null> {
const { port, ownedPid } = options;
try {
try {
const pids = await getListeningProcessIds(port);
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
await terminateOrphanedProcessIds(port, pids);
return null;
}
} catch (err) {
logger.warn('Error checking for existing process on port:', err);
}
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
const timeout = setTimeout(() => {
testWs.close();
resolve(null);
}, 2000);
testWs.on('open', () => {
clearTimeout(timeout);
testWs.close();
resolve({ port });
});
testWs.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
});
} catch {
return null;
}
}
export async function runOpenClawDoctorRepair(): Promise<boolean> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
if (!existsSync(entryScript)) {
logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`);
return false;
}
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const uvEnv = await getUvMirrorEnv();
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
logger.info(
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
);
return await new Promise<boolean>((resolve) => {
const forkEnv: Record<string, string | undefined> = {
...process.env,
PATH: finalPath,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
};
const child = utilityProcess.fork(entryScript, doctorArgs, {
cwd: openclawDir,
stdio: 'pipe',
env: forkEnv as NodeJS.ProcessEnv,
});
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const timeout = setTimeout(() => {
logger.error('OpenClaw doctor repair timed out after 120000ms');
try {
child.kill();
} catch {
// ignore
}
finish(false);
}, 120000);
child.on('error', (err) => {
clearTimeout(timeout);
logger.error('Failed to spawn OpenClaw doctor repair process:', err);
finish(false);
});
child.stdout?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
const normalized = line.trim();
if (!normalized) continue;
logger.debug(`[Gateway doctor stdout] ${normalized}`);
}
});
child.stderr?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
const normalized = line.trim();
if (!normalized) continue;
logger.warn(`[Gateway doctor stderr] ${normalized}`);
}
});
child.on('exit', (code: number) => {
clearTimeout(timeout);
if (code === 0) {
logger.info('OpenClaw doctor repair completed successfully');
finish(true);
return;
}
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
finish(false);
});
});
}

View File

@@ -0,0 +1,318 @@
import WebSocket from 'ws';
import type { DeviceIdentity } from '../utils/device-identity';
import type { PendingGatewayRequest } from './request-store';
import {
buildDeviceAuthPayload,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
export async function probeGatewayReady(
port: number,
timeoutMs = 1500,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
let settled = false;
const resolveOnce = (value: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
testWs.close();
} catch {
// ignore
}
resolve(value);
};
const timeout = setTimeout(() => {
resolveOnce(false);
}, timeoutMs);
testWs.on('open', () => {
// Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket
// connection before it is ready to issue protocol challenges, which previously
// caused a false "ready" result and then a full connect() stall.
});
testWs.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as { type?: string; event?: string };
if (message.type === 'event' && message.event === 'connect.challenge') {
resolveOnce(true);
}
} catch {
// ignore malformed probe payloads
}
});
testWs.on('error', () => {
resolveOnce(false);
});
testWs.on('close', () => {
resolveOnce(false);
});
});
}
export async function waitForGatewayReady(options: {
port: number;
getProcessExitCode: () => number | null;
retries?: number;
intervalMs?: number;
}): Promise<void> {
const retries = options.retries ?? 2400;
const intervalMs = options.intervalMs ?? 200;
for (let i = 0; i < retries; i++) {
const exitCode = options.getProcessExitCode();
if (exitCode !== null) {
logger.error(`Gateway process exited before ready (code=${exitCode})`);
throw new Error(`Gateway process exited before becoming ready (code=${exitCode})`);
}
try {
const ready = await probeGatewayReady(options.port, 1500);
if (ready) {
logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
return;
}
} catch {
// Gateway not ready yet.
}
if (i > 0 && i % 10 === 0) {
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
logger.error(`Gateway failed to become ready after ${retries} attempts on port ${options.port}`);
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
deviceIdentity: DeviceIdentity | null;
platform: string;
}): { connectId: string; frame: Record<string, unknown> } {
const connectId = `connect-${Date.now()}`;
const role = 'operator';
const scopes = ['operator.admin'];
const signedAtMs = Date.now();
const clientId = 'gateway-client';
const clientMode = 'ui';
const device = (() => {
if (!options.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: options.deviceIdentity.deviceId,
clientId,
clientMode,
role,
scopes,
signedAtMs,
token: options.token ?? null,
nonce: options.challengeNonce,
});
const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload);
return {
id: options.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
nonce: options.challengeNonce,
};
})();
return {
connectId,
frame: {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
displayName: 'ClawX',
version: '0.1.0',
platform: options.platform,
mode: clientMode,
},
auth: {
token: options.token,
},
caps: [],
role,
scopes,
device,
},
},
};
}
export async function connectGatewaySocket(options: {
port: number;
deviceIdentity: DeviceIdentity | null;
platform: string;
pendingRequests: Map<string, PendingGatewayRequest>;
getToken: () => Promise<string>;
onHandshakeComplete: (ws: WebSocket) => void;
onMessage: (message: unknown) => void;
onCloseAfterHandshake: () => void;
}): Promise<WebSocket> {
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
return await new Promise<WebSocket>((resolve, reject) => {
const wsUrl = `ws://localhost:${options.port}/ws`;
const ws = new WebSocket(wsUrl);
let handshakeComplete = false;
let connectId: string | null = null;
let handshakeTimeout: NodeJS.Timeout | null = null;
let challengeTimer: NodeJS.Timeout | null = null;
let challengeReceived = false;
let settled = false;
const cleanupHandshakeRequest = () => {
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
if (handshakeTimeout) {
clearTimeout(handshakeTimeout);
handshakeTimeout = null;
}
if (connectId && options.pendingRequests.has(connectId)) {
const request = options.pendingRequests.get(connectId);
if (request) {
clearTimeout(request.timeout);
}
options.pendingRequests.delete(connectId);
}
};
const resolveOnce = () => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
resolve(ws);
};
const rejectOnce = (error: unknown) => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
reject(error instanceof Error ? error : new Error(String(error)));
};
const sendConnectHandshake = async (challengeNonce: string) => {
logger.debug('Sending connect handshake with challenge nonce');
const currentToken = await options.getToken();
const connectPayload = buildGatewayConnectFrame({
challengeNonce,
token: currentToken,
deviceIdentity: options.deviceIdentity,
platform: options.platform,
});
connectId = connectPayload.connectId;
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
if (!handshakeComplete) {
logger.error('Gateway connect handshake timed out');
ws.close();
rejectOnce(new Error('Connect handshake timeout'));
}
}, 10000);
handshakeTimeout = requestTimeout;
options.pendingRequests.set(connectId, {
resolve: () => {
handshakeComplete = true;
logger.debug('Gateway connect handshake completed');
options.onHandshakeComplete(ws);
resolveOnce();
},
reject: (error) => {
logger.error('Gateway connect handshake failed:', error);
rejectOnce(error);
},
timeout: requestTimeout,
});
};
challengeTimer = setTimeout(() => {
if (!challengeReceived && !settled) {
logger.error('Gateway connect.challenge not received within timeout');
ws.close();
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
}
}, 10000);
ws.on('open', () => {
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
message.type === 'event' && message.event === 'connect.challenge'
) {
challengeReceived = true;
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
const nonce = message.payload?.nonce as string | undefined;
if (!nonce) {
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
return;
}
logger.debug('Received connect.challenge, sending handshake');
void sendConnectHandshake(nonce);
return;
}
options.onMessage(message);
} catch (error) {
logger.debug('Failed to parse Gateway WebSocket message:', error);
}
});
ws.on('close', (code, reason) => {
const reasonStr = reason?.toString() || 'unknown';
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
if (!handshakeComplete) {
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
return;
}
cleanupHandshakeRequest();
options.onCloseAfterHandshake();
});
ws.on('error', (error) => {
if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`);
} else {
logger.error('Gateway WebSocket error:', error);
}
if (!handshakeComplete) {
rejectOnce(error);
}
});
});
}

View File

@@ -3,6 +3,7 @@
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import type { Server } from 'node:http';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
@@ -20,6 +21,12 @@ import { isQuitting, setQuitting } from './app-state';
import { applyProxySettings } from './proxy';
import { getSetting } from '../utils/store';
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
import { startHostApiServer } from '../api/server';
import { HostEventBus } from '../api/event-bus';
import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
@@ -58,6 +65,8 @@ if (!gotTheLock) {
let mainWindow: BrowserWindow | null = null;
const gatewayManager = new GatewayManager();
const clawHubService = new ClawHubService();
const hostEventBus = new HostEventBus();
let hostApiServer: Server | null = null;
/**
* Resolve the icons directory path (works in both dev and packaged mode)
@@ -185,6 +194,13 @@ async function initialize(): Promise<void> {
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
hostApiServer = startHostApiServer({
gatewayManager,
clawHubService,
eventBus: hostEventBus,
mainWindow,
});
// Register update handlers
registerUpdateHandlers(appUpdater, mainWindow);
@@ -216,10 +232,82 @@ async function initialize(): Promise<void> {
logger.warn('Failed to install built-in skills:', error);
});
// Bridge gateway and host-side events before any auto-start logic runs, so
// renderer subscribers observe the full startup lifecycle.
gatewayManager.on('status', (status: { state: string }) => {
hostEventBus.emit('gateway:status', status);
if (status.state === 'running') {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
});
}
});
gatewayManager.on('error', (error) => {
hostEventBus.emit('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
hostEventBus.emit('gateway:notification', notification);
});
gatewayManager.on('chat:message', (data) => {
hostEventBus.emit('gateway:chat-message', data);
});
gatewayManager.on('channel:status', (data) => {
hostEventBus.emit('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
hostEventBus.emit('gateway:exit', { code });
});
deviceOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
deviceOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
});
deviceOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
deviceOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
});
browserOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
browserOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
});
whatsAppLoginManager.on('qr', (data) => {
hostEventBus.emit('channel:whatsapp-qr', data);
});
whatsAppLoginManager.on('success', (data) => {
hostEventBus.emit('channel:whatsapp-success', data);
});
whatsAppLoginManager.on('error', (error) => {
hostEventBus.emit('channel:whatsapp-error', error);
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (gatewayAutoStart) {
try {
await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...');
await gatewayManager.start();
logger.info('Gateway auto-start succeeded');
@@ -247,16 +335,6 @@ async function initialize(): Promise<void> {
}).catch((error) => {
logger.warn('CLI auto-install failed:', error);
});
// Re-apply ClawX context after every gateway restart because the gateway
// may re-seed workspace files with clean templates (losing ClawX markers).
gatewayManager.on('status', (status: { state: string }) => {
if (status.state === 'running') {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
});
}
});
}
// When a second instance is launched, focus the existing window instead.
@@ -293,6 +371,8 @@ app.on('window-all-closed', () => {
app.on('before-quit', () => {
setQuitting();
hostEventBus.closeAll();
hostApiServer?.close();
// Fire-and-forget: do not await gatewayManager.stop() here.
// Awaiting inside before-quit can stall Electron's quit sequence.
void gatewayManager.stop().catch((err) => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import type { ProviderConfig } from '../../shared/providers/types';
import {
getDefaultProviderAccountId,
providerConfigToAccount,
saveProviderAccount,
} from './provider-store';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 1;
export async function ensureProviderStoreMigrated(): Promise<void> {
const store = await getClawXProviderStore();
const schemaVersion = Number(store.get('schemaVersion') ?? 0);
if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) {
return;
}
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
const existingDefaultAccountId = await getDefaultProviderAccountId();
for (const provider of Object.values(legacyProviders)) {
const account = providerConfigToAccount(provider, {
isDefault: provider.id === defaultProviderId,
});
await saveProviderAccount(account);
}
if (!existingDefaultAccountId && defaultProviderId) {
store.set('defaultProviderAccountId', defaultProviderId);
}
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
}

View File

@@ -0,0 +1,460 @@
import type { GatewayManager } from '../../gateway/manager';
import { getProviderAccount, listProviderAccounts } from './provider-store';
import { getProviderSecret } from '../secrets/secret-store';
import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
removeProviderFromOpenClaw,
saveOAuthTokenToOpenClaw,
saveProviderKeyToOpenClaw,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
} from '../../utils/openclaw-auth';
import { logger } from '../../utils/logger';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
export function getOpenClawProviderKey(type: string, providerId: string): string {
if (type === 'custom' || type === 'ollama') {
const suffix = providerId.replace(/-/g, '').slice(0, 8);
return `${type}-${suffix}`;
}
if (type === 'minimax-portal-cn') {
return 'minimax-portal';
}
return type;
}
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (config.type === 'google' && account?.authMode === 'oauth_browser') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
return getOpenClawProviderKey(config.type, config.id);
}
async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise<boolean> {
const account = await getProviderAccount(config.id);
if (config.type !== 'google' || account?.authMode !== 'oauth_browser') {
return false;
}
const secret = await getProviderSecret(config.id);
return secret?.type === 'oauth';
}
export function getProviderModelRef(config: ProviderConfig): string | undefined {
const providerKey = getOpenClawProviderKey(config.type, config.id);
if (config.model) {
return config.model.startsWith(`${providerKey}/`)
? config.model
: `${providerKey}/${config.model}`;
}
const defaultModel = getProviderDefaultModel(config.type);
if (!defaultModel) {
return undefined;
}
return defaultModel.startsWith(`${providerKey}/`)
? defaultModel
: `${providerKey}/${defaultModel}`;
}
export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
const allProviders = await getAllProviders();
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
const seen = new Set<string>();
const results: string[] = [];
const providerKey = getOpenClawProviderKey(config.type, config.id);
for (const fallbackModel of config.fallbackModels ?? []) {
const normalizedModel = fallbackModel.trim();
if (!normalizedModel) continue;
const modelRef = normalizedModel.startsWith(`${providerKey}/`)
? normalizedModel
: `${providerKey}/${normalizedModel}`;
if (seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
for (const fallbackId of config.fallbackProviderIds ?? []) {
if (!fallbackId || fallbackId === config.id) continue;
const fallbackProvider = providerMap.get(fallbackId);
if (!fallbackProvider) continue;
const modelRef = getProviderModelRef(fallbackProvider);
if (!modelRef || seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
return results;
}
function scheduleGatewayRestart(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean },
): void {
if (!gatewayManager) {
return;
}
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
return;
}
logger.info(message);
gatewayManager.debouncedRestart(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
providerType: string,
providerId: string,
apiKey: string,
): Promise<void> {
const ock = getOpenClawProviderKey(providerType, providerId);
await saveProviderKeyToOpenClaw(ock, apiKey);
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
id: account.id,
name: account.label,
type: account.vendorId,
baseUrl: account.baseUrl,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
enabled: account.enabled,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
});
const secret = await getProviderSecret(account.id);
if (!secret) {
continue;
}
if (secret.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
}
}
export async function syncSavedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const ock = await resolveRuntimeProviderKey(config);
const secret = await getProviderSecret(config.id);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(ock, trimmedKey);
}
} else if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
} else if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(ock, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
} else if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
}
const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
if (!api) {
return;
}
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
headers: meta?.headers,
});
if (config.type === 'custom' || config.type === 'ollama') {
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
}
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after saving provider "${ock}" config`,
);
}
export async function syncUpdatedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const ock = await resolveRuntimeProviderKey(config);
const fallbackModels = await getProviderFallbackModelRefs(config);
const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
const secret = await getProviderSecret(config.id);
if (!api) {
return;
}
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(ock, trimmedKey);
}
} else if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
} else if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(ock, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
} else if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
}
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
headers: meta?.headers,
});
if (config.type === 'custom' || config.type === 'ollama') {
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
}
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom' && config.type !== 'ollama') {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
} else {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: config.baseUrl,
api: 'openai-completions',
}, fallbackModels);
}
}
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after updating provider "${ock}" config`,
);
}
export async function syncDeletedProviderToRuntime(
provider: ProviderConfig | null,
providerId: string,
gatewayManager?: GatewayManager,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
);
}
export async function syncDeletedProviderApiKeyToRuntime(
provider: ProviderConfig | null,
providerId: string,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
}
export async function syncDefaultProviderToRuntime(
providerId: string,
gatewayManager?: GatewayManager,
): Promise<void> {
const provider = await getProvider(providerId);
if (!provider) {
return;
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider;
if (!isOAuthProvider) {
const modelOverride = provider.model
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined;
if (provider.type === 'custom' || provider.type === 'ollama') {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
}
if (providerKey) {
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
if (isGoogleOAuthProvider) {
const secret = await getProviderSecret(provider.id);
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
const modelOverride = provider.model
? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`)
? provider.model
: `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`)
: GOOGLE_OAUTH_DEFAULT_MODEL_REF;
await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`);
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`,
);
return;
}
const defaultBaseUrl = provider.type === 'minimax-portal'
? 'https://api.minimax.io/anthropic'
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
const api: 'anthropic-messages' | 'openai-completions' =
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'anthropic-messages'
: 'openai-completions';
let baseUrl = provider.baseUrl || defaultBaseUrl;
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'minimax-portal'
: provider.type;
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
}, fallbackModels);
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
try {
const defaultModelId = provider.model?.split('/').pop();
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
}
}
if (
(provider.type === 'custom' || provider.type === 'ollama') &&
providerKey &&
provider.baseUrl
) {
const modelId = provider.model;
await updateAgentModelProvider(ock, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey,
});
}
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}

View File

@@ -0,0 +1,168 @@
import {
PROVIDER_DEFINITIONS,
getProviderDefinition,
} from '../../shared/providers/registry';
import type {
ProviderAccount,
ProviderConfig,
ProviderDefinition,
} from '../../shared/providers/types';
import { ensureProviderStoreMigrated } from './provider-migration';
import {
getDefaultProviderAccountId,
getProviderAccount,
listProviderAccounts,
providerAccountToConfig,
providerConfigToAccount,
saveProviderAccount,
setDefaultProviderAccount,
} from './provider-store';
import {
deleteApiKey,
deleteProvider,
getAllProviders,
getAllProvidersWithKeyInfo,
getApiKey,
getDefaultProvider,
getProvider,
hasApiKey,
saveProvider,
setDefaultProvider,
storeApiKey,
} from '../../utils/secure-storage';
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
export class ProviderService {
async listVendors(): Promise<ProviderDefinition[]> {
return PROVIDER_DEFINITIONS;
}
async listAccounts(): Promise<ProviderAccount[]> {
await ensureProviderStoreMigrated();
return listProviderAccounts();
}
async getAccount(accountId: string): Promise<ProviderAccount | null> {
await ensureProviderStoreMigrated();
return getProviderAccount(accountId);
}
async getDefaultAccountId(): Promise<string | undefined> {
await ensureProviderStoreMigrated();
return (await getDefaultProvider()) ?? getDefaultProviderAccountId();
}
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
await saveProvider(providerAccountToConfig(account));
await saveProviderAccount(account);
if (apiKey !== undefined && apiKey.trim()) {
await storeApiKey(account.id, apiKey.trim());
}
return (await getProviderAccount(account.id)) ?? account;
}
async updateAccount(
accountId: string,
patch: Partial<ProviderAccount>,
apiKey?: string,
): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
const existing = await getProviderAccount(accountId);
if (!existing) {
throw new Error('Provider account not found');
}
const nextAccount: ProviderAccount = {
...existing,
...patch,
id: accountId,
updatedAt: patch.updatedAt ?? new Date().toISOString(),
};
await saveProvider(providerAccountToConfig(nextAccount));
await saveProviderAccount(nextAccount);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(accountId, trimmedKey);
} else {
await deleteApiKey(accountId);
}
}
return (await getProviderAccount(accountId)) ?? nextAccount;
}
async deleteAccount(accountId: string): Promise<boolean> {
await ensureProviderStoreMigrated();
return deleteProvider(accountId);
}
async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config, options);
await saveProviderAccount(account);
return account;
}
async listLegacyProviders(): Promise<ProviderConfig[]> {
return getAllProviders();
}
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
return getAllProvidersWithKeyInfo();
}
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
return getProvider(providerId);
}
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
await saveProvider(config);
}
async deleteLegacyProvider(providerId: string): Promise<boolean> {
return deleteProvider(providerId);
}
async setDefaultLegacyProvider(providerId: string): Promise<void> {
await setDefaultProvider(providerId);
}
async getDefaultLegacyProvider(): Promise<string | undefined> {
return getDefaultProvider();
}
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
return storeApiKey(providerId, apiKey);
}
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
return getApiKey(providerId);
}
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
return deleteApiKey(providerId);
}
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
return hasApiKey(providerId);
}
async setDefaultAccount(accountId: string): Promise<void> {
await ensureProviderStoreMigrated();
await setDefaultProviderAccount(accountId);
await setDefaultProvider(accountId);
}
getVendorDefinition(vendorId: string): ProviderDefinition | undefined {
return getProviderDefinition(vendorId);
}
}
const providerService = new ProviderService();
export function getProviderService(): ProviderService {
return providerService;
}

View File

@@ -0,0 +1,103 @@
import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types';
import { getProviderDefinition } from '../../shared/providers/registry';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 1;
function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] {
if (type === 'ollama') {
return 'local';
}
const definition = getProviderDefinition(type);
if (definition?.defaultAuthMode) {
return definition.defaultAuthMode;
}
return 'api_key';
}
export function providerConfigToAccount(
config: ProviderConfig,
options?: { isDefault?: boolean },
): ProviderAccount {
return {
id: config.id,
vendorId: config.type,
label: config.name,
authMode: inferAuthMode(config.type),
baseUrl: config.baseUrl,
apiProtocol: config.type === 'custom' || config.type === 'ollama'
? 'openai-completions'
: getProviderDefinition(config.type)?.providerConfig?.api,
model: config.model,
fallbackModels: config.fallbackModels,
fallbackAccountIds: config.fallbackProviderIds,
enabled: config.enabled,
isDefault: options?.isDefault ?? false,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
export function providerAccountToConfig(account: ProviderAccount): ProviderConfig {
return {
id: account.id,
name: account.label,
type: account.vendorId,
baseUrl: account.baseUrl,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
enabled: account.enabled,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
};
}
export async function listProviderAccounts(): Promise<ProviderAccount[]> {
const store = await getClawXProviderStore();
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
return Object.values(accounts ?? {});
}
export async function getProviderAccount(accountId: string): Promise<ProviderAccount | null> {
const store = await getClawXProviderStore();
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
return accounts?.[accountId] ?? null;
}
export async function saveProviderAccount(account: ProviderAccount): Promise<void> {
const store = await getClawXProviderStore();
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
accounts[account.id] = account;
store.set('providerAccounts', accounts);
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
}
export async function deleteProviderAccount(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
delete accounts[accountId];
store.set('providerAccounts', accounts);
if (store.get('defaultProviderAccountId') === accountId) {
store.delete('defaultProviderAccountId');
}
}
export async function setDefaultProviderAccount(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
store.set('defaultProviderAccountId', accountId);
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
for (const account of Object.values(accounts)) {
account.isDefault = account.id === accountId;
}
store.set('providerAccounts', accounts);
}
export async function getDefaultProviderAccountId(): Promise<string | undefined> {
const store = await getClawXProviderStore();
return store.get('defaultProviderAccountId') as string | undefined;
}

View File

@@ -0,0 +1,238 @@
import { proxyAwareFetch } from '../../utils/proxy-fetch';
type ValidationProfile =
| 'openai-compatible'
| 'google-query-key'
| 'anthropic-header'
| 'openrouter'
| 'none';
function logValidationStatus(provider: string, status: number): void {
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
}
function maskSecret(secret: string): string {
if (!secret) return '';
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
}
function sanitizeValidationUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const key = url.searchParams.get('key');
if (key) url.searchParams.set('key', maskSecret(key));
return url.toString();
} catch {
return rawUrl;
}
}
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
if (next.Authorization?.startsWith('Bearer ')) {
const token = next.Authorization.slice('Bearer '.length);
next.Authorization = `Bearer ${maskSecret(token)}`;
}
if (next['x-api-key']) {
next['x-api-key'] = maskSecret(next['x-api-key']);
}
return next;
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}
function logValidationRequest(
provider: string,
method: string,
url: string,
headers: Record<string, string>,
): void {
console.log(
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
);
}
function getValidationProfile(providerType: string): ValidationProfile {
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
case 'google':
return 'google-query-key';
case 'openrouter':
return 'openrouter';
case 'ollama':
return 'none';
default:
return 'openai-compatible';
}
}
async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await proxyAwareFetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function classifyAuthResponse(
status: number,
data: unknown,
): { valid: boolean; error?: string } {
if (status >= 200 && status < 300) return { valid: true };
if (status === 429) return { valid: true };
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
const obj = data as { error?: { message?: string }; message?: string } | null;
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
return { valid: false, error: msg };
}
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const headers = { Authorization: `Bearer ${apiKey}` };
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
if (modelsResult.error?.includes('API error: 404')) {
console.log(
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`,
);
const base = normalizeBaseUrl(trimmedBaseUrl);
const chatUrl = `${base}/chat/completions`;
return await performChatCompletionsProbe(providerType, chatUrl, headers);
}
return modelsResult;
}
async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function validateGoogleQueryKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
}
async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
return await performProviderValidationRequest(providerType, url, headers);
}
async function validateOpenRouterKey(
providerType: string,
apiKey: string,
): Promise<{ valid: boolean; error?: string }> {
const url = 'https://openrouter.ai/api/v1/auth/key';
const headers = { Authorization: `Bearer ${apiKey}` };
return await performProviderValidationRequest(providerType, url, headers);
}
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string },
): Promise<{ valid: boolean; error?: string }> {
const profile = getValidationProfile(providerType);
if (profile === 'none') {
return { valid: true };
}
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}
try {
switch (profile) {
case 'openai-compatible':
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
case 'openrouter':
return await validateOpenRouterKey(providerType, trimmedKey);
default:
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { valid: false, error: errorMessage };
}
}

View File

@@ -0,0 +1,23 @@
// Lazy-load electron-store (ESM module) from the main process only.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
export async function getClawXProviderStore() {
if (!providerStore) {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
defaults: {
schemaVersion: 0,
providers: {} as Record<string, unknown>,
providerAccounts: {} as Record<string, unknown>,
apiKeys: {} as Record<string, string>,
providerSecrets: {} as Record<string, unknown>,
defaultProvider: null as string | null,
defaultProviderAccountId: null as string | null,
},
});
}
return providerStore;
}

View File

@@ -0,0 +1,82 @@
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXProviderStore } from '../providers/store-instance';
export interface SecretStore {
get(accountId: string): Promise<ProviderSecret | null>;
set(secret: ProviderSecret): Promise<void>;
delete(accountId: string): Promise<void>;
}
export class ElectronStoreSecretStore implements SecretStore {
async get(accountId: string): Promise<ProviderSecret | null> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const secret = secrets[accountId];
if (secret) {
return secret;
}
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
const apiKey = apiKeys[accountId];
if (!apiKey) {
return null;
}
return {
type: 'api_key',
accountId,
apiKey,
};
}
async set(secret: ProviderSecret): Promise<void> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
secrets[secret.accountId] = secret;
store.set('providerSecrets', secrets);
// Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets.
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
if (secret.type === 'api_key') {
apiKeys[secret.accountId] = secret.apiKey;
} else if (secret.type === 'local') {
if (secret.apiKey) {
apiKeys[secret.accountId] = secret.apiKey;
} else {
delete apiKeys[secret.accountId];
}
} else {
delete apiKeys[secret.accountId];
}
store.set('apiKeys', apiKeys);
}
async delete(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
delete secrets[accountId];
store.set('providerSecrets', secrets);
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
delete apiKeys[accountId];
store.set('apiKeys', apiKeys);
}
}
const secretStore = new ElectronStoreSecretStore();
export function getSecretStore(): SecretStore {
return secretStore;
}
export async function getProviderSecret(accountId: string): Promise<ProviderSecret | null> {
return getSecretStore().get(accountId);
}
export async function setProviderSecret(secret: ProviderSecret): Promise<void> {
await getSecretStore().set(secret);
}
export async function deleteProviderSecret(accountId: string): Promise<void> {
await getSecretStore().delete(accountId);
}

View File

@@ -0,0 +1,293 @@
import type {
ProviderBackendConfig,
ProviderDefinition,
ProviderType,
ProviderTypeInfo,
} from './types';
export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
{
id: 'anthropic',
name: 'Anthropic',
icon: '🤖',
placeholder: 'sk-ant-api03-...',
model: 'Claude',
requiresApiKey: true,
category: 'official',
envVar: 'ANTHROPIC_API_KEY',
defaultModelId: 'claude-opus-4-6',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
{
id: 'openai',
name: 'OpenAI',
icon: '💚',
placeholder: 'sk-proj-...',
model: 'GPT',
requiresApiKey: true,
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.2',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-responses',
apiKeyEnv: 'OPENAI_API_KEY',
},
},
{
id: 'google',
name: 'Google',
icon: '🔷',
placeholder: 'AIza...',
model: 'Gemini',
requiresApiKey: true,
category: 'official',
envVar: 'GEMINI_API_KEY',
defaultModelId: 'gemini-3.1-pro-preview',
isOAuth: true,
supportsApiKey: true,
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
{
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',
category: 'compatible',
envVar: 'OPENROUTER_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://openrouter.ai/api/v1',
api: 'openai-completions',
apiKeyEnv: 'OPENROUTER_API_KEY',
headers: {
'HTTP-Referer': 'https://claw-x.com',
'X-Title': 'ClawX',
},
},
},
{
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',
category: 'official',
envVar: 'ARK_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
api: 'openai-completions',
apiKeyEnv: 'ARK_API_KEY',
},
},
{
id: 'moonshot',
name: 'Moonshot (CN)',
icon: '🌙',
placeholder: 'sk-...',
model: 'Kimi',
requiresApiKey: true,
defaultBaseUrl: 'https://api.moonshot.cn/v1',
defaultModelId: 'kimi-k2.5',
category: 'official',
envVar: 'MOONSHOT_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.moonshot.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'MOONSHOT_API_KEY',
models: [
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192,
},
],
},
},
{
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',
category: 'compatible',
envVar: 'SILICONFLOW_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.siliconflow.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'SILICONFLOW_API_KEY',
},
},
{
id: 'minimax-portal',
name: 'MiniMax (Global)',
icon: '☁️',
placeholder: 'sk-...',
model: 'MiniMax',
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.5',
apiKeyUrl: 'https://intl.minimaxi.com/',
category: 'official',
envVar: 'MINIMAX_API_KEY',
supportedAuthModes: ['oauth_device', 'api_key'],
defaultAuthMode: 'oauth_device',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
},
},
{
id: 'minimax-portal-cn',
name: 'MiniMax (CN)',
icon: '☁️',
placeholder: 'sk-...',
model: 'MiniMax',
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.5',
apiKeyUrl: 'https://platform.minimaxi.com/',
category: 'official',
envVar: 'MINIMAX_CN_API_KEY',
supportedAuthModes: ['oauth_device', 'api_key'],
defaultAuthMode: 'oauth_device',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
},
},
{
id: 'qwen-portal',
name: 'Qwen',
icon: '☁️',
placeholder: 'sk-...',
model: 'Qwen',
requiresApiKey: false,
isOAuth: true,
defaultModelId: 'coder-model',
category: 'official',
envVar: 'QWEN_API_KEY',
supportedAuthModes: ['oauth_device'],
defaultAuthMode: 'oauth_device',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://portal.qwen.ai/v1',
api: 'openai-completions',
apiKeyEnv: 'QWEN_API_KEY',
},
},
{
id: 'ollama',
name: 'Ollama',
icon: '🦙',
placeholder: 'Not required',
requiresApiKey: false,
defaultBaseUrl: 'http://localhost:11434/v1',
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'qwen3:latest',
category: 'local',
supportedAuthModes: ['local'],
defaultAuthMode: 'local',
supportsMultipleAccounts: true,
},
{
id: 'custom',
name: 'Custom',
icon: '⚙️',
placeholder: 'API key...',
requiresApiKey: true,
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'your-provider/model-id',
category: 'custom',
envVar: 'CUSTOM_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
];
const PROVIDER_DEFINITION_MAP = new Map(
PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
export function getProviderDefinition(
type: ProviderType | string,
): ProviderDefinition | undefined {
return PROVIDER_DEFINITION_MAP.get(type as ProviderType);
}
export function getProviderTypeInfo(
type: ProviderType,
): ProviderTypeInfo | undefined {
return getProviderDefinition(type);
}
export function getProviderEnvVar(type: string): string | undefined {
return getProviderDefinition(type)?.envVar;
}
export function getProviderDefaultModel(type: string): string | undefined {
return getProviderDefinition(type)?.defaultModelId;
}
export function getProviderBackendConfig(
type: string,
): ProviderBackendConfig | undefined {
return getProviderDefinition(type)?.providerConfig;
}
export function getProviderUiInfoList(): ProviderTypeInfo[] {
return PROVIDER_DEFINITIONS;
}
export function getKeyableProviderTypes(): string[] {
return PROVIDER_DEFINITIONS.filter((definition) => definition.envVar).map(
(definition) => definition.id,
);
}

View File

@@ -0,0 +1,169 @@
export const PROVIDER_TYPES = [
'anthropic',
'openai',
'google',
'openrouter',
'ark',
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
'custom',
] as const;
export const BUILTIN_PROVIDER_TYPES = [
'anthropic',
'openai',
'google',
'openrouter',
'ark',
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'anthropic-messages';
export type ProviderAuthMode =
| 'api_key'
| 'oauth_device'
| 'oauth_browser'
| 'local';
export type ProviderVendorCategory =
| 'official'
| 'compatible'
| 'local'
| 'custom';
export interface ProviderConfig {
id: string;
name: string;
type: ProviderType;
baseUrl?: string;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface ProviderWithKeyInfo extends ProviderConfig {
hasKey: boolean;
keyMasked: string | null;
}
export interface ProviderTypeInfo {
id: ProviderType;
name: string;
icon: string;
placeholder: string;
model?: string;
requiresApiKey: boolean;
defaultBaseUrl?: string;
showBaseUrl?: boolean;
showModelId?: boolean;
showModelIdInDevModeOnly?: boolean;
modelIdPlaceholder?: string;
defaultModelId?: string;
isOAuth?: boolean;
supportsApiKey?: boolean;
apiKeyUrl?: string;
}
export interface ProviderModelEntry extends Record<string, unknown> {
id: string;
name: string;
}
export interface ProviderBackendConfig {
baseUrl: string;
api: ProviderProtocol;
apiKeyEnv: string;
models?: ProviderModelEntry[];
headers?: Record<string, string>;
}
export interface ProviderDefinition extends ProviderTypeInfo {
category: ProviderVendorCategory;
envVar?: string;
providerConfig?: ProviderBackendConfig;
supportedAuthModes: ProviderAuthMode[];
defaultAuthMode: ProviderAuthMode;
supportsMultipleAccounts: boolean;
}
export interface ProviderAccount {
id: string;
vendorId: ProviderType;
label: string;
authMode: ProviderAuthMode;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
model?: string;
fallbackModels?: string[];
fallbackAccountIds?: string[];
enabled: boolean;
isDefault: boolean;
metadata?: {
region?: string;
email?: string;
resourceUrl?: string;
customModels?: string[];
};
createdAt: string;
updatedAt: string;
}
export type ProviderSecret =
| {
type: 'api_key';
accountId: string;
apiKey: string;
}
| {
type: 'oauth';
accountId: string;
accessToken: string;
refreshToken: string;
expiresAt: number;
scopes?: string[];
email?: string;
subject?: string;
}
| {
type: 'local';
accountId: string;
apiKey?: string;
};
export interface ModelSummary {
id: string;
name: string;
vendorId: string;
accountId?: string;
supportsVision?: boolean;
supportsReasoning?: boolean;
contextWindow?: number;
pricing?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
source: 'builtin' | 'remote' | 'gateway' | 'custom';
}

View File

@@ -0,0 +1,161 @@
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
export type BrowserOAuthProviderType = 'google';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
async startFlow(
provider: BrowserOAuthProviderType,
options?: { accountId?: string; label?: string },
): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
try {
if (provider !== 'google') {
throw new Error(`Unsupported browser OAuth provider type: ${provider}`);
}
const token = await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
});
await this.onSuccess(provider, token);
return true;
} catch (error) {
if (!this.active) {
return false;
}
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
}
}
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info('[BrowserOAuth] Flow explicitly stopped');
}
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: GeminiCliOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const nextAccount = await providerService.createAccount({
id: accountId,
vendorId: providerType,
label: accountLabel || existing?.label || 'Google Gemini',
authMode: 'oauth_browser',
baseUrl: existing?.baseUrl,
apiProtocol: existing?.apiProtocol,
model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL,
fallbackModels: existing?.fallbackModels,
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: existing?.enabled ?? true,
isDefault: existing?.isDefault ?? false,
metadata: {
...existing?.metadata,
email: token.email,
resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID,
},
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await getSecretStore().set({
type: 'oauth',
accountId,
accessToken: token.access,
refreshToken: token.refresh,
expiresAt: token.expires,
email: token.email,
subject: token.projectId,
});
await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
});
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
provider: providerType,
accountId: nextAccount.id,
success: true,
});
}
}
private emitError(message: string) {
this.emit('oauth:error', { message });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}
}
}
export const browserOAuthManager = new BrowserOAuthManager();

View File

@@ -111,21 +111,6 @@ export async function saveChannelConfig(
}
}
// DingTalk is a channel plugin; make sure it's explicitly allowed.
// Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty.
if (channelType === 'dingtalk') {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
currentConfig.plugins.enabled = true;
const allow = Array.isArray(currentConfig.plugins.allow)
? currentConfig.plugins.allow as string[]
: [];
if (!allow.includes('dingtalk')) {
currentConfig.plugins.allow = [...allow, 'dingtalk'];
}
}
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) {

View File

@@ -12,6 +12,9 @@ export const PORTS = {
/** ClawX GUI production port (for reference) */
CLAWX_GUI: 23333,
/** Local host API server port */
CLAWX_HOST_API: 3210,
/** OpenClaw Gateway port */
OPENCLAW_GATEWAY: 18789,

View File

@@ -42,6 +42,8 @@ export type { MiniMaxRegion };
class DeviceOAuthManager extends EventEmitter {
private activeProvider: OAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active: boolean = false;
private mainWindow: BrowserWindow | null = null;
@@ -49,14 +51,20 @@ class DeviceOAuthManager extends EventEmitter {
this.mainWindow = window;
}
async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise<boolean> {
async startFlow(
provider: OAuthProviderType,
region: MiniMaxRegion = 'global',
options?: { accountId?: string; label?: string },
): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.emit('oauth:start', { provider: provider });
this.emit('oauth:start', { provider, accountId: options?.accountId || provider });
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
try {
if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') {
@@ -77,6 +85,8 @@ class DeviceOAuthManager extends EventEmitter {
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
}
}
@@ -84,6 +94,8 @@ class DeviceOAuthManager extends EventEmitter {
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info('[DeviceOAuth] Flow explicitly stopped');
}
@@ -194,8 +206,12 @@ class DeviceOAuthManager extends EventEmitter {
api: 'anthropic-messages' | 'openai-completions';
region?: MiniMaxRegion;
}) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`);
// 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format.
@@ -254,15 +270,15 @@ class DeviceOAuthManager extends EventEmitter {
}
// 3. Save provider record in ClawX's own store so UI shows it as configured
const existing = await getProvider(providerType);
const existing = await getProvider(accountId);
const nameMap: Record<OAuthProviderType, string> = {
'minimax-portal': 'MiniMax (Global)',
'minimax-portal-cn': 'MiniMax (CN)',
'qwen-portal': 'Qwen',
};
const providerConfig: ProviderConfig = {
id: providerType,
name: nameMap[providerType as OAuthProviderType] || providerType,
id: accountId,
name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType,
type: providerType,
enabled: existing?.enabled ?? true,
baseUrl, // Save the dynamically resolved URL (Global vs CN)
@@ -274,11 +290,11 @@ class DeviceOAuthManager extends EventEmitter {
await saveProvider(providerConfig);
// 4. Emit success internally so the main process can restart the Gateway
this.emit('oauth:success', providerType);
this.emit('oauth:success', { provider: providerType, accountId });
// 5. Emit success to frontend
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true });
this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true });
}
}
@@ -331,12 +347,14 @@ class DeviceOAuthManager extends EventEmitter {
userCode: string;
expiresIn: number;
}) {
this.emit('oauth:code', data);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', data);
}
}
private emitError(message: string) {
this.emit('oauth:error', { message });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}

View File

@@ -0,0 +1,738 @@
import { execFile, execFileSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { delimiter, dirname, join } from 'node:path';
import { getClawXConfigDir } from './paths';
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
const CLIENT_SECRET_KEYS = [
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
'GEMINI_CLI_OAUTH_CLIENT_SECRET',
];
const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const TIER_FREE = 'free-tier';
const TIER_LEGACY = 'legacy-tier';
const TIER_STANDARD = 'standard-tier';
const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli');
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
export class DetailedError extends Error {
detail: string;
constructor(message: string, detail: string) {
super(message);
this.name = 'DetailedError';
this.detail = detail;
}
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function findInPath(name: string): string | null {
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
if (!dir) continue;
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) {
return p;
}
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const next = join(dir, entry.name);
if (entry.isFile() && entry.name === name) {
return next;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const found = findFile(next, name, depth - 1);
if (found) {
return found;
}
}
}
} catch {
return null;
}
return null;
}
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath));
const searchPaths = [
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'src',
'code_assist',
'oauth2.js',
),
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'code_assist',
'oauth2.js',
),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(geminiCliDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
return cachedGeminiCliCredentials;
}
} catch {
return null;
}
return null;
}
function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null {
const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
if (!existsSync(coreDir)) {
return null;
}
const searchPaths = [
join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'),
join(coreDir, 'dist', 'code_assist', 'oauth2.js'),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(coreDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
}
return null;
}
async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolean> {
const npmBin = findInPath('npm');
if (!npmBin) {
return false;
}
onProgress?.('Installing Gemini OAuth helper...');
return await new Promise((resolve) => {
const useShell = process.platform === 'win32';
const child = execFile(
npmBin,
['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'],
{ timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } },
(err) => {
if (err) {
onProgress?.(`Gemini helper install failed, falling back to direct download...`);
resolve(false);
} else {
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper installed');
resolve(true);
}
},
);
child.stderr?.on('data', () => {
// Suppress npm noise.
});
});
}
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
try {
onProgress?.('Downloading Gemini OAuth helper...');
const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
if (!metaRes.ok) {
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
return false;
}
const meta = (await metaRes.json()) as { dist?: { tarball?: string } };
const tarballUrl = meta.dist?.tarball;
if (!tarballUrl) {
onProgress?.('Gemini package tarball URL missing');
return false;
}
const tarRes = await fetch(tarballUrl);
if (!tarRes.ok) {
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
return false;
}
const buffer = Buffer.from(await tarRes.arrayBuffer());
const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
mkdirSync(targetDir, { recursive: true });
const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz');
writeFileSync(tmpFile, buffer);
try {
execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], {
timeout: 30_000,
});
} finally {
try {
unlinkSync(tmpFile);
} catch {
// ignore
}
}
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper ready');
return true;
} catch (err) {
onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
async function ensureOAuthClientConfig(
onProgress?: (msg: string) => void,
): Promise<{ clientId: string; clientSecret?: string }> {
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
const extracted = extractGeminiCliCredentials();
if (extracted) {
return extracted;
}
const localExtracted = extractFromLocalInstall();
if (localExtracted) {
return localExtracted;
}
mkdirSync(LOCAL_GEMINI_DIR, { recursive: true });
const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress);
if (installed) {
const installedExtracted = extractFromLocalInstall();
if (installedExtracted) {
return installedExtracted;
}
}
throw new Error(
'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.',
);
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('hex');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function buildAuthUrl(clientId: string, challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state: verifier,
access_type: 'offline',
prompt: 'consent',
});
return `${AUTH_URL}?${params.toString()}`;
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = '127.0.0.1';
const expectedPath = '/oauth2callback';
return new Promise((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not found');
return;
}
const error = requestUrl.searchParams.get('error');
const code = requestUrl.searchParams.get('code')?.trim();
const state = requestUrl.searchParams.get('state')?.trim();
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end('Missing code or state');
finish(new Error('Missing OAuth code or state'));
return;
}
if (state !== params.expectedState) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Session expired</h2><p>This authorization link is from a previous attempt. Please go back to ClawX and try again.</p></body></html>",
);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Gemini CLI OAuth complete</h2><p>You can close this window and return to ClawX.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error('OAuth callback failed'));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
// ignore
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once('error', (err) => {
finish(err instanceof Error ? err : new Error('OAuth callback server error'));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`);
});
timeout = setTimeout(() => {
finish(new DetailedError(
'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.',
`Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`,
));
}, params.timeoutMs);
});
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') {
return false;
}
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== 'object') {
return false;
}
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === 'object'
&& item
&& (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED',
);
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
if (!response.ok) {
continue;
}
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) {
return data;
}
}
throw new Error('Operation polling timeout');
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'gl-node/clawx',
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: 'POST',
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === 'string' && project) {
return project;
}
if (typeof project === 'object' && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
}
const hasExistingTierButNoProject = !!data.currentTier;
const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new DetailedError(
'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.',
`tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`,
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: 'POST',
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
const respText = await onboardResponse.text().catch(() => '');
throw new DetailedError(
'Google project provisioning failed. Please try again later.',
`onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`,
);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new DetailedError(
'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.',
`tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`,
);
}
async function exchangeCodeForTokens(
code: string,
verifier: string,
clientConfig: { clientId: string; clientSecret?: string },
): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = clientConfig;
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set('client_secret', clientSecret);
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error('No refresh token received. Please try again.');
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
export async function loginGeminiCliOAuth(
ctx: GeminiCliOAuthContext,
): Promise<GeminiCliOAuthCredentials> {
if (ctx.isRemote) {
throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.');
}
await ctx.note(
[
'Browser will open for Google authentication.',
'Sign in with your Google account for Gemini CLI access.',
'The callback will be captured automatically on 127.0.0.1:8085.',
].join('\n'),
'Gemini CLI OAuth',
);
ctx.progress.update('Preparing Google OAuth...');
const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg));
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier);
ctx.progress.update('Complete sign-in in browser...');
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update('Exchanging authorization code for tokens...');
return await exchangeCodeForTokens(code, verifier, clientConfig);
} catch (err) {
if (
err instanceof Error
&& (err.message.includes('EADDRINUSE')
|| err.message.includes('port')
|| err.message.includes('listen'))
) {
throw new Error(
'Port 8085 is in use by another process. Close the other application using port 8085 and try again.',
{ cause: err },
);
}
throw err;
}
}
// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale.
export function detectGeminiCliVersion(): string | null {
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
}

View File

@@ -26,6 +26,10 @@ import {
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
function getOAuthPluginId(provider: string): string {
return `${provider}-auth`;
}
// ── Helpers ──────────────────────────────────────────────────────
/** Non-throwing async existence check (replaces existsSync). */
@@ -76,6 +80,8 @@ interface OAuthProfileEntry {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
}
interface AuthProfilesStore {
@@ -155,7 +161,7 @@ async function writeOpenClawJson(config: Record<string, unknown>): Promise<void>
*/
export async function saveOAuthTokenToOpenClaw(
provider: string,
token: { access: string; refresh: string; expires: number },
token: { access: string; refresh: string; expires: number; email?: string; projectId?: string },
agentId?: string
): Promise<void> {
const agentIds = agentId ? [agentId] : await discoverAgentIds();
@@ -171,6 +177,8 @@ export async function saveOAuthTokenToOpenClaw(
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
};
if (!store.order) store.order = {};
@@ -378,7 +386,10 @@ export async function setOpenClawDefaultModel(
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = modelOverride || getProviderDefaultModel(provider);
const rawModel = modelOverride || getProviderDefaultModel(provider);
const model = rawModel
? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`)
: undefined;
if (!model) {
console.warn(`No default model mapping for provider "${provider}"`);
return;
@@ -542,8 +553,14 @@ export async function syncProviderConfigToOpenClaw(
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
pEntries[`${provider}-auth`] = { enabled: true };
const pluginId = getOAuthPluginId(provider);
if (!allow.includes(pluginId)) {
allow.push(pluginId);
}
pEntries[pluginId] = { enabled: true };
plugins.allow = allow;
plugins.entries = pEntries;
config.plugins = plugins;
}
@@ -563,7 +580,10 @@ export async function setOpenClawDefaultModelWithOverride(
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = modelOverride || getProviderDefaultModel(provider);
const rawModel = modelOverride || getProviderDefaultModel(provider);
const model = rawModel
? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`)
: undefined;
if (!model) {
console.warn(`No default model mapping for provider "${provider}"`);
return;
@@ -622,8 +642,14 @@ export async function setOpenClawDefaultModelWithOverride(
// Ensure the extension plugin is marked as enabled in openclaw.json
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
pEntries[`${provider}-auth`] = { enabled: true };
const pluginId = getOAuthPluginId(provider);
if (!allow.includes(pluginId)) {
allow.push(pluginId);
}
pEntries[pluginId] = { enabled: true };
plugins.allow = allow;
plugins.entries = pEntries;
config.plugins = plugins;
}
@@ -689,6 +715,22 @@ export async function syncGatewayTokenToConfig(token: string): Promise<void> {
auth.mode = 'token';
auth.token = token;
gateway.auth = auth;
// Packaged ClawX loads the renderer from file://, so the gateway must allow
// that origin for the chat WebSocket handshake.
const controlUi = (
gateway.controlUi && typeof gateway.controlUi === 'object'
? { ...(gateway.controlUi as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const allowedOrigins = Array.isArray(controlUi.allowedOrigins)
? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string')
: [];
if (!allowedOrigins.includes('file://')) {
controlUi.allowedOrigins = [...allowedOrigins, 'file://'];
}
gateway.controlUi = controlUi;
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;

View File

@@ -1,147 +1,25 @@
/**
* Provider Registry — single source of truth for backend provider metadata.
* Centralizes env var mappings, default models, and OpenClaw provider configs.
*
* NOTE: When adding a new provider type, also update src/lib/providers.ts
* Backend compatibility layer around the shared provider registry.
*/
export const BUILTIN_PROVIDER_TYPES = [
'anthropic',
'openai',
'google',
'openrouter',
'ark',
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
] as const;
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
export type ProviderType = BuiltinProviderType | 'custom';
export {
BUILTIN_PROVIDER_TYPES,
type BuiltinProviderType,
type ProviderType,
} from '../shared/providers/types';
import {
type ProviderBackendConfig,
type ProviderModelEntry,
} from '../shared/providers/types';
import {
getKeyableProviderTypes as getSharedKeyableProviderTypes,
getProviderBackendConfig,
getProviderDefaultModel as getSharedProviderDefaultModel,
getProviderEnvVar as getSharedProviderEnvVar,
} from '../shared/providers/registry';
interface ProviderModelEntry extends Record<string, unknown> {
id: string;
name: string;
}
interface ProviderBackendMeta {
envVar?: string;
defaultModel?: string;
/** OpenClaw models.providers config (omit for built-in providers like anthropic) */
providerConfig?: {
baseUrl: string;
api: string;
apiKeyEnv: string;
models?: ProviderModelEntry[];
headers?: Record<string, string>;
};
}
const REGISTRY: Record<string, ProviderBackendMeta> = {
anthropic: {
envVar: 'ANTHROPIC_API_KEY',
defaultModel: 'anthropic/claude-opus-4-6',
// anthropic is built-in to OpenClaw's model registry, no provider config needed
},
openai: {
envVar: 'OPENAI_API_KEY',
defaultModel: 'openai/gpt-5.2',
providerConfig: {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-responses',
apiKeyEnv: 'OPENAI_API_KEY',
},
},
google: {
envVar: 'GEMINI_API_KEY',
defaultModel: 'google/gemini-3.1-pro-preview',
// google is built-in to OpenClaw's pi-ai catalog, no providerConfig needed.
// Adding models.providers.google overrides the built-in and can break Gemini.
},
openrouter: {
envVar: 'OPENROUTER_API_KEY',
defaultModel: 'openrouter/anthropic/claude-opus-4.6',
providerConfig: {
baseUrl: 'https://openrouter.ai/api/v1',
api: 'openai-completions',
apiKeyEnv: 'OPENROUTER_API_KEY',
headers: {
'HTTP-Referer': 'https://claw-x.com',
'X-Title': 'ClawX',
},
},
},
ark: {
envVar: 'ARK_API_KEY',
providerConfig: {
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
api: 'openai-completions',
apiKeyEnv: 'ARK_API_KEY',
},
},
moonshot: {
envVar: 'MOONSHOT_API_KEY',
defaultModel: 'moonshot/kimi-k2.5',
providerConfig: {
baseUrl: 'https://api.moonshot.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'MOONSHOT_API_KEY',
models: [
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192,
},
],
},
},
siliconflow: {
envVar: 'SILICONFLOW_API_KEY',
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
providerConfig: {
baseUrl: 'https://api.siliconflow.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'SILICONFLOW_API_KEY',
},
},
'minimax-portal': {
envVar: 'MINIMAX_API_KEY',
defaultModel: 'minimax-portal/MiniMax-M2.5',
providerConfig: {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
},
},
'minimax-portal-cn': {
envVar: 'MINIMAX_CN_API_KEY',
defaultModel: 'minimax-portal/MiniMax-M2.5',
providerConfig: {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
},
},
'qwen-portal': {
envVar: 'QWEN_API_KEY',
defaultModel: 'qwen-portal/coder-model',
providerConfig: {
baseUrl: 'https://portal.qwen.ai/v1',
api: 'openai-completions',
apiKeyEnv: 'QWEN_API_KEY',
},
},
custom: {
envVar: 'CUSTOM_API_KEY',
},
// Additional providers with env var mappings but no default model
// Additional env-backed providers that are not yet exposed in the UI.
const EXTRA_ENV_ONLY_PROVIDERS: Record<string, { envVar: string }> = {
groq: { envVar: 'GROQ_API_KEY' },
deepgram: { envVar: 'DEEPGRAM_API_KEY' },
cerebras: { envVar: 'CEREBRAS_API_KEY' },
@@ -151,26 +29,25 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
/** Get the environment variable name for a provider type */
export function getProviderEnvVar(type: string): string | undefined {
return REGISTRY[type]?.envVar;
return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar;
}
/** Get all environment variable names for a provider type (primary first). */
export function getProviderEnvVars(type: string): string[] {
const meta = REGISTRY[type];
if (!meta?.envVar) return [];
return [meta.envVar];
const envVar = getProviderEnvVar(type);
return envVar ? [envVar] : [];
}
/** Get the default model string for a provider type */
export function getProviderDefaultModel(type: string): string | undefined {
return REGISTRY[type]?.defaultModel;
return getSharedProviderDefaultModel(type);
}
/** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models, headers) */
export function getProviderConfig(
type: string
): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[]; headers?: Record<string, string> } | undefined {
return REGISTRY[type]?.providerConfig;
return getProviderBackendConfig(type) as ProviderBackendConfig | undefined;
}
/**
@@ -178,7 +55,5 @@ export function getProviderConfig(
* Used by GatewayManager to inject API keys as env vars.
*/
export function getKeyableProviderTypes(): string[] {
return Object.entries(REGISTRY)
.filter(([, meta]) => meta.envVar)
.map(([type]) => type);
return [...getSharedKeyableProviderTypes(), ...Object.keys(EXTRA_ENV_ONLY_PROVIDERS)];
}

View File

@@ -1,32 +1,30 @@
/**
* Provider Storage
* Manages provider configurations and API keys.
* Keys are stored in plain text alongside provider configs in a single electron-store.
* This file remains the legacy compatibility layer while the app migrates to
* account-based provider storage and a dedicated secret-store abstraction.
*/
import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry';
import { getActiveOpenClawProviders } from './openclaw-auth';
import {
deleteProviderAccount,
getProviderAccount,
listProviderAccounts,
providerAccountToConfig,
providerConfigToAccount,
saveProviderAccount,
setDefaultProviderAccount,
} from '../services/providers/provider-store';
import { ensureProviderStoreMigrated } from '../services/providers/provider-migration';
import { getClawXProviderStore } from '../services/providers/store-instance';
import {
deleteProviderSecret,
getProviderSecret,
setProviderSecret,
} from '../services/secrets/secret-store';
import { getOpenClawProviderKeyForType } from './provider-keys';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
async function getProviderStore() {
if (!providerStore) {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
defaults: {
providers: {} as Record<string, ProviderConfig>,
apiKeys: {} as Record<string, string>,
defaultProvider: null as string | null,
},
});
}
return providerStore;
}
/**
* Provider configuration
*/
@@ -50,10 +48,16 @@ export interface ProviderConfig {
*/
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
try {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
keys[providerId] = apiKey;
s.set('apiKeys', keys);
await setProviderSecret({
type: 'api_key',
accountId: providerId,
apiKey,
});
return true;
} catch (error) {
console.error('Failed to store API key:', error);
@@ -66,7 +70,16 @@ export async function storeApiKey(providerId: string, apiKey: string): Promise<b
*/
export async function getApiKey(providerId: string): Promise<string | null> {
try {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const secret = await getProviderSecret(providerId);
if (secret?.type === 'api_key') {
return secret.apiKey;
}
if (secret?.type === 'local') {
return secret.apiKey ?? null;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return keys[providerId] || null;
} catch (error) {
@@ -80,10 +93,12 @@ export async function getApiKey(providerId: string): Promise<string | null> {
*/
export async function deleteApiKey(providerId: string): Promise<boolean> {
try {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
delete keys[providerId];
s.set('apiKeys', keys);
await deleteProviderSecret(providerId);
return true;
} catch (error) {
console.error('Failed to delete API key:', error);
@@ -95,7 +110,13 @@ export async function deleteApiKey(providerId: string): Promise<boolean> {
* Check if an API key exists for a provider
*/
export async function hasApiKey(providerId: string): Promise<boolean> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const secret = await getProviderSecret(providerId);
if (secret?.type === 'api_key') {
return true;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return providerId in keys;
}
@@ -104,7 +125,8 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
* List all provider IDs that have stored keys
*/
export async function listStoredKeyIds(): Promise<string[]> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return Object.keys(keys);
}
@@ -115,28 +137,47 @@ export async function listStoredKeyIds(): Promise<string[]> {
* Save a provider configuration
*/
export async function saveProvider(config: ProviderConfig): Promise<void> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
providers[config.id] = config;
s.set('providers', providers);
const defaultProviderId = (s.get('defaultProvider') ?? null) as string | null;
await saveProviderAccount(
providerConfigToAccount(config, { isDefault: defaultProviderId === config.id }),
);
}
/**
* Get a provider configuration
*/
export async function getProvider(providerId: string): Promise<ProviderConfig | null> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
return providers[providerId] || null;
if (providers[providerId]) {
return providers[providerId];
}
const account = await getProviderAccount(providerId);
return account ? providerAccountToConfig(account) : null;
}
/**
* Get all provider configurations
*/
export async function getAllProviders(): Promise<ProviderConfig[]> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
return Object.values(providers);
const legacyProviders = Object.values(providers);
if (legacyProviders.length > 0) {
return legacyProviders;
}
const accounts = await listProviderAccounts();
return accounts.map(providerAccountToConfig);
}
/**
@@ -144,18 +185,21 @@ export async function getAllProviders(): Promise<ProviderConfig[]> {
*/
export async function deleteProvider(providerId: string): Promise<boolean> {
try {
await ensureProviderStoreMigrated();
// Delete the API key
await deleteApiKey(providerId);
// Delete the provider config
const s = await getProviderStore();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
delete providers[providerId];
s.set('providers', providers);
await deleteProviderAccount(providerId);
// Clear default if this was the default
if (s.get('defaultProvider') === providerId) {
s.delete('defaultProvider');
s.delete('defaultProviderAccountId');
}
return true;
@@ -169,16 +213,20 @@ export async function deleteProvider(providerId: string): Promise<boolean> {
* Set the default provider
*/
export async function setDefaultProvider(providerId: string): Promise<void> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
s.set('defaultProvider', providerId);
await setDefaultProviderAccount(providerId);
}
/**
* Get the default provider
*/
export async function getDefaultProvider(): Promise<string | undefined> {
const s = await getProviderStore();
return s.get('defaultProvider') as string | undefined;
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
return (s.get('defaultProvider') as string | undefined)
?? (s.get('defaultProviderAccountId') as string | undefined);
}
/**

View File

@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.1.23",
"version": "0.1.24-alpha.1",
"pnpm": {
"onlyBuiltDependencies": [
"@whiskeysockets/baileys",
@@ -107,4 +107,4 @@
"zx": "^8.8.5"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
}
}

View File

@@ -24,7 +24,7 @@ import { useChatStore } from '@/stores/chat';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { useTranslation } from 'react-i18next';
type SessionBucketKey =
@@ -115,11 +115,11 @@ export function Sidebar() {
const openDevConsole = async () => {
try {
const result = await invokeIpc('gateway:getControlUiUrl') as {
const result = await hostApiFetch<{
success: boolean;
url?: string;
error?: string;
};
}>('/api/gateway/control-ui');
if (result.success && result.url) {
window.electron.openExternal(result.url);
} else {
@@ -297,4 +297,4 @@ export function Sidebar() {
/>
</aside>
);
}
}

View File

@@ -2,7 +2,7 @@
* Providers Settings Component
* Manage AI provider configurations and API keys
*/
import React, { useState, useEffect } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
Plus,
Trash2,
@@ -24,7 +24,12 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useProviderStore, type ProviderConfig, type ProviderWithKeyInfo } from '@/stores/providers';
import {
useProviderStore,
type ProviderAccount,
type ProviderConfig,
type ProviderVendorInfo,
} from '@/stores/providers';
import {
PROVIDER_TYPE_INFO,
type ProviderType,
@@ -34,11 +39,18 @@ import {
shouldShowProviderModelId,
shouldInvertInDark,
} from '@/lib/providers';
import {
buildProviderAccountId,
buildProviderListItems,
type ProviderListItem,
} from '@/lib/provider-accounts';
import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { invokeIpc } from '@/lib/api-client';
import { useSettingsStore } from '@/stores/settings';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
function normalizeFallbackProviderIds(ids?: string[]): string[] {
return Array.from(new Set((ids ?? []).filter(Boolean)));
@@ -60,55 +72,82 @@ function fallbackModelsEqual(a?: string[], b?: string[]): boolean {
return left.length === right.length && left.every((model, index) => model === right[index]);
}
function getAuthModeLabel(
authMode: ProviderAccount['authMode'],
t: (key: string) => string
): string {
switch (authMode) {
case 'api_key':
return t('aiProviders.authModes.apiKey');
case 'oauth_device':
return t('aiProviders.authModes.oauthDevice');
case 'oauth_browser':
return t('aiProviders.authModes.oauthBrowser');
case 'local':
return t('aiProviders.authModes.local');
default:
return authMode;
}
}
export function ProvidersSettings() {
const { t } = useTranslation('settings');
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const {
providers,
defaultProviderId,
statuses,
accounts,
vendors,
defaultAccountId,
loading,
fetchProviders,
addProvider,
deleteProvider,
updateProviderWithKey,
setDefaultProvider,
validateApiKey,
refreshProviderSnapshot,
createAccount,
removeAccount,
updateAccount,
setDefaultAccount,
validateAccountApiKey,
} = useProviderStore();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
const existingVendorIds = new Set(accounts.map((account) => account.vendorId));
const displayProviders = useMemo(
() => buildProviderListItems(accounts, statuses, vendors, defaultAccountId),
[accounts, statuses, vendors, defaultAccountId],
);
// Fetch providers on mount
useEffect(() => {
fetchProviders();
}, [fetchProviders]);
refreshProviderSnapshot();
}, [refreshProviderSnapshot]);
const handleAddProvider = async (
type: ProviderType,
name: string,
apiKey: string,
options?: { baseUrl?: string; model?: string }
options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] }
) => {
// Only custom supports multiple instances.
// Built-in providers remain singleton by type.
const id = type === 'custom' ? `custom-${crypto.randomUUID()}` : type;
const vendor = vendorMap.get(type);
const id = buildProviderAccountId(type, null, vendors);
const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey);
try {
await addProvider(
{
id,
type,
name,
baseUrl: options?.baseUrl,
model: options?.model,
enabled: true,
},
effectiveApiKey
);
await createAccount({
id,
vendorId: type,
label: name,
authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'),
baseUrl: options?.baseUrl,
apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined,
model: options?.model,
enabled: true,
isDefault: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}, effectiveApiKey);
// Auto-set as default if no default is currently configured
if (!defaultProviderId) {
await setDefaultProvider(id);
if (!defaultAccountId) {
await setDefaultAccount(id);
}
setShowAddDialog(false);
@@ -120,7 +159,7 @@ export function ProvidersSettings() {
const handleDeleteProvider = async (providerId: string) => {
try {
await deleteProvider(providerId);
await removeAccount(providerId);
toast.success(t('aiProviders.toast.deleted'));
} catch (error) {
toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`);
@@ -129,7 +168,7 @@ export function ProvidersSettings() {
const handleSetDefault = async (providerId: string) => {
try {
await setDefaultProvider(providerId);
await setDefaultAccount(providerId);
toast.success(t('aiProviders.toast.defaultUpdated'));
} catch (error) {
toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`);
@@ -149,7 +188,7 @@ export function ProvidersSettings() {
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : providers.length === 0 ? (
) : displayProviders.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Key className="h-12 w-12 text-muted-foreground mb-4" />
@@ -165,26 +204,35 @@ export function ProvidersSettings() {
</Card>
) : (
<div className="space-y-4">
{providers.map((provider) => (
{displayProviders.map((item) => (
<ProviderCard
key={provider.id}
provider={provider}
allProviders={providers}
isDefault={provider.id === defaultProviderId}
isEditing={editingProvider === provider.id}
onEdit={() => setEditingProvider(provider.id)}
key={item.account.id}
item={item}
allProviders={displayProviders}
isDefault={item.account.id === defaultAccountId}
isEditing={editingProvider === item.account.id}
onEdit={() => setEditingProvider(item.account.id)}
onCancelEdit={() => setEditingProvider(null)}
onDelete={() => handleDeleteProvider(provider.id)}
onSetDefault={() => handleSetDefault(provider.id)}
onDelete={() => handleDeleteProvider(item.account.id)}
onSetDefault={() => handleSetDefault(item.account.id)}
onSaveEdits={async (payload) => {
await updateProviderWithKey(
provider.id,
payload.updates || {},
const updates: Partial<ProviderAccount> = {};
if (payload.updates) {
if (payload.updates.baseUrl !== undefined) updates.baseUrl = payload.updates.baseUrl;
if (payload.updates.model !== undefined) updates.model = payload.updates.model;
if (payload.updates.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels;
if (payload.updates.fallbackProviderIds !== undefined) {
updates.fallbackAccountIds = payload.updates.fallbackProviderIds;
}
}
await updateAccount(
item.account.id,
updates,
payload.newApiKey
);
setEditingProvider(null);
}}
onValidateKey={(key, options) => validateApiKey(provider.id, key, options)}
onValidateKey={(key, options) => validateAccountApiKey(item.account.id, key, options)}
devModeUnlocked={devModeUnlocked}
/>
))}
@@ -194,10 +242,11 @@ export function ProvidersSettings() {
{/* Add Provider Dialog */}
{showAddDialog && (
<AddProviderDialog
existingTypes={new Set(providers.map((p) => p.type))}
existingVendorIds={existingVendorIds}
vendors={vendors}
onClose={() => setShowAddDialog(false)}
onAdd={handleAddProvider}
onValidateKey={(type, key, options) => validateApiKey(type, key, options)}
onValidateKey={(type, key, options) => validateAccountApiKey(type, key, options)}
devModeUnlocked={devModeUnlocked}
/>
)}
@@ -206,8 +255,8 @@ export function ProvidersSettings() {
}
interface ProviderCardProps {
provider: ProviderWithKeyInfo;
allProviders: ProviderWithKeyInfo[];
item: ProviderListItem;
allProviders: ProviderListItem[];
isDefault: boolean;
isEditing: boolean;
onEdit: () => void;
@@ -225,7 +274,7 @@ interface ProviderCardProps {
function ProviderCard({
provider,
item,
allProviders,
isDefault,
isEditing,
@@ -238,20 +287,21 @@ function ProviderCard({
devModeUnlocked,
}: ProviderCardProps) {
const { t } = useTranslation('settings');
const { account, vendor, status } = item;
const [newKey, setNewKey] = useState('');
const [baseUrl, setBaseUrl] = useState(provider.baseUrl || '');
const [modelId, setModelId] = useState(provider.model || '');
const [baseUrl, setBaseUrl] = useState(account.baseUrl || '');
const [modelId, setModelId] = useState(account.model || '');
const [fallbackModelsText, setFallbackModelsText] = useState(
normalizeFallbackModels(provider.fallbackModels).join('\n')
normalizeFallbackModels(account.fallbackModels).join('\n')
);
const [fallbackProviderIds, setFallbackProviderIds] = useState<string[]>(
normalizeFallbackProviderIds(provider.fallbackProviderIds)
normalizeFallbackProviderIds(account.fallbackAccountIds)
);
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === account.vendorId);
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField);
@@ -259,14 +309,14 @@ function ProviderCard({
if (isEditing) {
setNewKey('');
setShowKey(false);
setBaseUrl(provider.baseUrl || '');
setModelId(provider.model || '');
setFallbackModelsText(normalizeFallbackModels(provider.fallbackModels).join('\n'));
setFallbackProviderIds(normalizeFallbackProviderIds(provider.fallbackProviderIds));
setBaseUrl(account.baseUrl || '');
setModelId(account.model || '');
setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n'));
setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds));
}
}, [isEditing, provider.baseUrl, provider.fallbackModels, provider.fallbackProviderIds, provider.model]);
}, [isEditing, account.baseUrl, account.fallbackModels, account.fallbackAccountIds, account.model]);
const fallbackOptions = allProviders.filter((candidate) => candidate.id !== provider.id);
const fallbackOptions = allProviders.filter((candidate) => candidate.account.id !== account.id);
const toggleFallbackProvider = (providerId: string) => {
setFallbackProviderIds((current) => (
@@ -304,16 +354,16 @@ function ProviderCard({
}
const updates: Partial<ProviderConfig> = {};
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) {
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || undefined)) {
updates.baseUrl = baseUrl.trim() || undefined;
}
if (showModelIdField && (modelId.trim() || undefined) !== (provider.model || undefined)) {
if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) {
updates.model = modelId.trim() || undefined;
}
if (!fallbackModelsEqual(normalizedFallbackModels, provider.fallbackModels)) {
if (!fallbackModelsEqual(normalizedFallbackModels, account.fallbackModels)) {
updates.fallbackModels = normalizedFallbackModels;
}
if (!fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)) {
if (!fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds)) {
updates.fallbackProviderIds = normalizeFallbackProviderIds(fallbackProviderIds);
}
if (Object.keys(updates).length > 0) {
@@ -323,8 +373,8 @@ function ProviderCard({
// Keep Ollama key optional in UI, but persist a placeholder when
// editing legacy configs that have no stored key.
if (provider.type === 'ollama' && !provider.hasKey && !payload.newApiKey) {
payload.newApiKey = resolveProviderApiKeyForSave(provider.type, '') as string;
if (account.vendorId === 'ollama' && !status?.hasKey && !payload.newApiKey) {
payload.newApiKey = resolveProviderApiKeyForSave(account.vendorId, '') as string;
}
if (!payload.newApiKey && !payload.updates) {
@@ -350,16 +400,23 @@ function ProviderCard({
{/* Top row: icon + name */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getProviderIconUrl(provider.type) ? (
<img src={getProviderIconUrl(provider.type)} alt={typeInfo?.name || provider.type} className={cn('h-5 w-5', shouldInvertInDark(provider.type) && 'dark:invert')} />
{getProviderIconUrl(account.vendorId) ? (
<img src={getProviderIconUrl(account.vendorId)} alt={typeInfo?.name || account.vendorId} className={cn('h-5 w-5', shouldInvertInDark(account.vendorId) && 'dark:invert')} />
) : (
<span className="text-xl">{typeInfo?.icon || '⚙️'}</span>
<span className="text-xl">{vendor?.icon || typeInfo?.icon || '⚙️'}</span>
)}
<div>
<div className="flex items-center gap-2">
<span className="font-semibold">{provider.name}</span>
<span className="font-semibold">{account.label}</span>
<Badge variant="secondary">{vendor?.name || account.vendorId}</Badge>
<Badge variant="outline">{getAuthModeLabel(account.authMode, t)}</Badge>
</div>
<div className="mt-1 space-y-0.5">
<p className="text-xs text-muted-foreground capitalize">{account.vendorId}</p>
<p className="text-xs text-muted-foreground truncate">
{t('aiProviders.dialog.modelId')}: {account.model || t('aiProviders.card.none')}
</p>
</div>
<span className="text-xs text-muted-foreground capitalize">{provider.type}</span>
</div>
</div>
</div>
@@ -415,14 +472,16 @@ function ProviderCard({
) : (
<div className="space-y-2 rounded-md border p-2">
{fallbackOptions.map((candidate) => (
<label key={candidate.id} className="flex items-center gap-2 text-sm">
<label key={candidate.account.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={fallbackProviderIds.includes(candidate.id)}
onChange={() => toggleFallbackProvider(candidate.id)}
checked={fallbackProviderIds.includes(candidate.account.id)}
onChange={() => toggleFallbackProvider(candidate.account.id)}
/>
<span className="font-medium">{candidate.name}</span>
<span className="text-xs text-muted-foreground">{candidate.model || candidate.type}</span>
<span className="font-medium">{candidate.account.label}</span>
<span className="text-xs text-muted-foreground">
{candidate.account.model || candidate.vendor?.name || candidate.account.vendorId}
</span>
</label>
))}
</div>
@@ -434,12 +493,12 @@ function ProviderCard({
<div className="space-y-1">
<Label className="text-xs">{t('aiProviders.dialog.apiKey')}</Label>
<p className="text-xs text-muted-foreground">
{provider.hasKey
{status?.hasKey
? t('aiProviders.dialog.apiKeyConfigured')
: t('aiProviders.dialog.apiKeyMissing')}
</p>
</div>
{provider.hasKey ? (
{status?.hasKey ? (
<Badge variant="secondary">{t('aiProviders.card.configured')}</Badge>
) : null}
</div>
@@ -484,10 +543,10 @@ function ProviderCard({
|| saving
|| (
!newKey.trim()
&& (baseUrl.trim() || undefined) === (provider.baseUrl || undefined)
&& (modelId.trim() || undefined) === (provider.model || undefined)
&& fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), provider.fallbackModels)
&& fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)
&& (baseUrl.trim() || undefined) === (account.baseUrl || undefined)
&& (modelId.trim() || undefined) === (account.model || undefined)
&& fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), account.fallbackModels)
&& fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds)
)
|| Boolean(showModelIdField && !modelId.trim())
}
@@ -512,7 +571,7 @@ function ProviderCard({
<div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 min-w-0">
{typeInfo?.isOAuth ? (
{account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' ? (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
@@ -521,13 +580,13 @@ function ProviderCard({
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey
? (provider.keyMasked && provider.keyMasked.length > 12
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
: provider.keyMasked)
{status?.hasKey
? (status.keyMasked && status.keyMasked.length > 12
? `${status.keyMasked.substring(0, 4)}...${status.keyMasked.substring(status.keyMasked.length - 4)}`
: status.keyMasked)
: t('aiProviders.card.noKey')}
</span>
{provider.hasKey && (
{status?.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
)}
</>
@@ -535,11 +594,11 @@ function ProviderCard({
</div>
<p className="text-xs text-muted-foreground truncate">
{t('aiProviders.card.fallbacks', {
count: (provider.fallbackModels?.length ?? 0) + (provider.fallbackProviderIds?.length ?? 0),
count: (account.fallbackModels?.length ?? 0) + (account.fallbackAccountIds?.length ?? 0),
names: [
...normalizeFallbackModels(provider.fallbackModels),
...normalizeFallbackProviderIds(provider.fallbackProviderIds)
.map((fallbackId) => allProviders.find((candidate) => candidate.id === fallbackId)?.name)
...normalizeFallbackModels(account.fallbackModels),
...normalizeFallbackProviderIds(account.fallbackAccountIds)
.map((fallbackId) => allProviders.find((candidate) => candidate.account.id === fallbackId)?.account.label)
.filter(Boolean),
].join(', ') || t('aiProviders.card.none'),
})}
@@ -578,13 +637,14 @@ function ProviderCard({
}
interface AddProviderDialogProps {
existingTypes: Set<string>;
existingVendorIds: Set<string>;
vendors: ProviderVendorInfo[];
onClose: () => void;
onAdd: (
type: ProviderType,
name: string,
apiKey: string,
options?: { baseUrl?: string; model?: string }
options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] }
) => Promise<void>;
onValidateKey: (
type: string,
@@ -595,7 +655,8 @@ interface AddProviderDialogProps {
}
function AddProviderDialog({
existingTypes,
existingVendorIds,
vendors,
onClose,
onAdd,
onValidateKey,
@@ -626,11 +687,19 @@ function AddProviderDialog({
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
const isOAuth = typeInfo?.isOAuth ?? false;
const supportsApiKey = typeInfo?.supportsApiKey ?? false;
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
const selectedVendor = selectedType ? vendorMap.get(selectedType) : undefined;
const preferredOAuthMode = selectedVendor?.supportedAuthModes.includes('oauth_browser')
? 'oauth_browser'
: (selectedVendor?.supportedAuthModes.includes('oauth_device')
? 'oauth_device'
: (selectedType === 'google' ? 'oauth_browser' : null));
// Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
// Keep a ref to the latest values so the effect closure can access them
// Keep refs to the latest values so event handlers see the current dialog state.
const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t });
const pendingOAuthRef = React.useRef<{ accountId: string; label: string } | null>(null);
useEffect(() => {
latestRef.current = { selectedType, typeInfo, onAdd, onClose, t };
});
@@ -642,12 +711,14 @@ function AddProviderDialog({
setOauthError(null);
};
const handleSuccess = async () => {
const handleSuccess = async (data: unknown) => {
setOauthFlowing(false);
setOauthData(null);
setValidationError(null);
const { onClose: close, t: translate } = latestRef.current;
const payload = (data as { accountId?: string } | undefined) || undefined;
const accountId = payload?.accountId || pendingOAuthRef.current?.accountId;
// device-oauth.ts already saved the provider config to the backend,
// including the dynamically resolved baseUrl for the region (e.g. CN vs Global).
@@ -655,17 +726,17 @@ function AddProviderDialog({
// So we just fetch the latest list from the backend to update the UI.
try {
const store = useProviderStore.getState();
await store.fetchProviders();
await store.refreshProviderSnapshot();
// Auto-set as default if no default is currently configured
if (!store.defaultProviderId && latestRef.current.selectedType) {
// Provider type is expected to match provider ID for built-in OAuth providers
await store.setDefaultProvider(latestRef.current.selectedType);
if (!store.defaultAccountId && accountId) {
await store.setDefaultAccount(accountId);
}
} catch (err) {
console.error('Failed to refresh providers after OAuth:', err);
}
pendingOAuthRef.current = null;
close();
toast.success(translate('aiProviders.toast.added'));
};
@@ -673,29 +744,28 @@ function AddProviderDialog({
const handleError = (data: unknown) => {
setOauthError((data as { message: string }).message);
setOauthData(null);
pendingOAuthRef.current = null;
};
window.electron.ipcRenderer.on('oauth:code', handleCode);
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
window.electron.ipcRenderer.on('oauth:error', handleError);
const offCode = subscribeHostEvent('oauth:code', handleCode);
const offSuccess = subscribeHostEvent('oauth:success', handleSuccess);
const offError = subscribeHostEvent('oauth:error', handleError);
return () => {
if (typeof window.electron.ipcRenderer.off === 'function') {
window.electron.ipcRenderer.off('oauth:code', handleCode);
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
window.electron.ipcRenderer.off('oauth:error', handleError);
}
offCode();
offSuccess();
offError();
};
}, []);
const handleStartOAuth = async () => {
if (!selectedType) return;
if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
toast.error(t('aiProviders.toast.minimaxConflict'));
return;
}
if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
toast.error(t('aiProviders.toast.minimaxConflict'));
return;
}
@@ -705,10 +775,19 @@ function AddProviderDialog({
setOauthError(null);
try {
await invokeIpc('provider:requestOAuth', selectedType);
const vendor = vendorMap.get(selectedType);
const supportsMultipleAccounts = vendor?.supportsMultipleAccounts ?? selectedType === 'custom';
const accountId = supportsMultipleAccounts ? `${selectedType}-${crypto.randomUUID()}` : selectedType;
const label = name || (typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name) || selectedType;
pendingOAuthRef.current = { accountId, label };
await hostApiFetch('/api/providers/oauth/start', {
method: 'POST',
body: JSON.stringify({ provider: selectedType, accountId, label }),
});
} catch (e) {
setOauthError(String(e));
setOauthFlowing(false);
pendingOAuthRef.current = null;
}
};
@@ -716,22 +795,28 @@ function AddProviderDialog({
setOauthFlowing(false);
setOauthData(null);
setOauthError(null);
await invokeIpc('provider:cancelOAuth');
pendingOAuthRef.current = null;
await hostApiFetch('/api/providers/oauth/cancel', {
method: 'POST',
});
};
// Only custom can be added multiple times.
const availableTypes = PROVIDER_TYPE_INFO.filter(
(t) => t.id === 'custom' || !existingTypes.has(t.id),
);
const availableTypes = PROVIDER_TYPE_INFO.filter((type) => {
const vendor = vendorMap.get(type.id);
if (!vendor) {
return !existingVendorIds.has(type.id) || type.id === 'custom';
}
return vendor.supportsMultipleAccounts || !existingVendorIds.has(type.id);
});
const handleAdd = async () => {
if (!selectedType) return;
if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
toast.error(t('aiProviders.toast.minimaxConflict'));
return;
}
if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
toast.error(t('aiProviders.toast.minimaxConflict'));
return;
}
@@ -772,6 +857,11 @@ function AddProviderDialog({
{
baseUrl: baseUrl.trim() || undefined,
model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked),
authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama'
? 'local'
: (isOAuth && supportsApiKey && authMode === 'apikey')
? 'api_key'
: vendorMap.get(selectedType)?.defaultAuthMode || 'api_key',
}
);
} catch {
@@ -1059,4 +1149,4 @@ function AddProviderDialog({
</Card>
</div>
);
}
}

View File

@@ -13,6 +13,19 @@
"aiProviders": {
"title": "AI Providers",
"description": "Configure your AI model providers and API keys",
"overview": {
"title": "Provider Accounts",
"description": "A summary of the provider accounts and models currently configured.",
"noModelSelected": "No model selected",
"multiAccountReady": "Multi-account ready",
"singletonVendor": "Single-account vendor"
},
"authModes": {
"apiKey": "API Key",
"oauthDevice": "OAuth Device",
"oauthBrowser": "OAuth Browser",
"local": "Local"
},
"sections": {
"model": "Model Settings",
"fallback": "Fallback Settings"
@@ -85,6 +98,7 @@
"cancel": "Cancel",
"codeCopied": "Code copied to clipboard",
"authFailed": "Authentication Failed",
"browserFlowUnavailable": "Browser OAuth is not wired for this provider yet.",
"tryAgain": "Try Again",
"approveLogin": "Approve Login",
"step1": "Copy the authorization code below.",

View File

@@ -13,6 +13,19 @@
"aiProviders": {
"title": "AI プロバイダー",
"description": "AI モデルプロバイダーと API キーを設定",
"overview": {
"title": "プロバイダーアカウント",
"description": "現在設定されているプロバイダーアカウントとモデルの概要です。",
"noModelSelected": "モデル未選択",
"multiAccountReady": "複数アカウント対応",
"singletonVendor": "単一アカウントのプロバイダー"
},
"authModes": {
"apiKey": "API キー",
"oauthDevice": "OAuth デバイス",
"oauthBrowser": "OAuth ブラウザ",
"local": "ローカル"
},
"sections": {
"model": "モデル設定",
"fallback": "フォールバック設定"
@@ -84,6 +97,7 @@
"cancel": "キャンセル",
"codeCopied": "コードをクリップボードにコピーしました",
"authFailed": "認証に失敗しました",
"browserFlowUnavailable": "このプロバイダーのブラウザ OAuth はまだ接続されていません。",
"tryAgain": "再試行",
"approveLogin": "ログインを承認",
"step1": "以下の認証コードをコピーしてください。",

View File

@@ -13,6 +13,19 @@
"aiProviders": {
"title": "AI 模型提供商",
"description": "配置 AI 模型提供商和 API 密钥",
"overview": {
"title": "提供商账户",
"description": "这里汇总当前已配置的 provider 账户与模型信息。",
"noModelSelected": "未选择模型",
"multiAccountReady": "支持多账户",
"singletonVendor": "单例提供商"
},
"authModes": {
"apiKey": "API 密钥",
"oauthDevice": "OAuth 设备登录",
"oauthBrowser": "OAuth 浏览器登录",
"local": "本地"
},
"sections": {
"model": "模型配置",
"fallback": "回退配置"
@@ -85,6 +98,7 @@
"cancel": "取消",
"codeCopied": "代码已复制到剪贴板",
"authFailed": "认证失败",
"browserFlowUnavailable": "该提供商的浏览器 OAuth 登录链路暂未接通。",
"tryAgain": "重试",
"approveLogin": "确认登录",
"step1": "复制下方的授权码。",

239
src/lib/gateway-client.ts Normal file
View 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
View 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
View 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);
};
}

View 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,
}));
}

View File

@@ -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' },

View File

@@ -2,7 +2,7 @@
* Channels Page
* Manage messaging channel connections with configuration UI
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Plus,
Radio,
@@ -33,6 +33,9 @@ import { useChannelsStore } from '@/stores/channels';
import { useGatewayStore } from '@/stores/gateway';
import { StatusBadge, type Status } from '@/components/common/StatusBadge';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
import { invokeIpc } from '@/lib/api-client';
import {
CHANNEL_ICONS,
CHANNEL_NAMES,
@@ -45,7 +48,6 @@ import {
} from '@/types/channel';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { invokeIpc } from '@/lib/api-client';
export function Channels() {
const { t } = useTranslation('channels');
@@ -55,26 +57,20 @@ export function Channels() {
const [showAddDialog, setShowAddDialog] = useState(false);
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastGatewayStateRef = useRef(gatewayStatus.state);
// Fetch channels on mount
useEffect(() => {
void fetchChannels({ probe: false });
fetchChannels();
}, [fetchChannels]);
// Fetch configured channel types from config file
const fetchConfiguredTypes = useCallback(async () => {
try {
const result = await invokeIpc('channel:listConfigured') as {
const result = await hostApiFetch<{
success: boolean;
channels?: string[];
};
}>('/api/channels/configured');
if (result.success && result.channels) {
setConfiguredTypes(result.channels);
}
@@ -84,86 +80,29 @@ export function Channels() {
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchConfiguredTypes();
}, [fetchConfiguredTypes]);
useEffect(() => {
const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => {
if (refreshDebounceRef.current) {
clearTimeout(refreshDebounceRef.current);
}
refreshDebounceRef.current = setTimeout(() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, 300);
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
fetchChannels();
fetchConfiguredTypes();
});
return () => {
if (refreshDebounceRef.current) {
clearTimeout(refreshDebounceRef.current);
refreshDebounceRef.current = null;
}
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [fetchChannels, fetchConfiguredTypes]);
useEffect(() => {
if (gatewayStatus.state === 'running') {
setChannelSnapshot(channels);
setConfiguredTypesSnapshot(configuredTypes);
}
}, [gatewayStatus.state, channels, configuredTypes]);
useEffect(() => {
const previousState = lastGatewayStateRef.current;
const currentState = gatewayStatus.state;
const justReconnected =
currentState === 'running' &&
previousState !== 'running';
lastGatewayStateRef.current = currentState;
if (!justReconnected) return;
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]);
// Delay warning to avoid flicker during expected short reload/restart windows.
useEffect(() => {
const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error';
const timer = setTimeout(() => {
setShowGatewayWarning(shouldWarn);
}, shouldWarn ? 1800 : 0);
return () => clearTimeout(timer);
}, [gatewayStatus.state]);
// Get channel types to display
const displayedChannelTypes = getPrimaryChannels();
const isGatewayTransitioning =
gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting';
const channelsForView =
isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels;
const configuredTypesForView =
isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes;
// Single source of truth for configured status across cards, stats and badges.
const configuredTypeSet = useMemo(() => {
const set = new Set<string>(configuredTypesForView);
if (set.size === 0 && channelsForView.length > 0) {
channelsForView.forEach((channel) => set.add(channel.type));
}
return set;
}, [configuredTypesForView, channelsForView]);
const configuredChannels = useMemo(
() => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)),
[channelsForView, configuredTypeSet]
);
// Connected/disconnected channel counts
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
const connectedCount = channels.filter((c) => c.status === 'connected').length;
if (loading && channels.length === 0) {
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner size="lg" />
@@ -182,20 +121,8 @@ export function Channels() {
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={async () => {
try {
setRefreshing(true);
await fetchChannels({ probe: true, silent: true });
await fetchConfiguredTypes();
} finally {
setRefreshing(false);
}
}}
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2${refreshing ? ' animate-spin' : ''}`} />
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="h-4 w-4 mr-2" />
{t('refresh')}
</Button>
<Button onClick={() => setShowAddDialog(true)}>
@@ -214,7 +141,7 @@ export function Channels() {
<Radio className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{configuredChannels.length}</p>
<p className="text-2xl font-bold">{channels.length}</p>
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
</div>
</div>
@@ -240,7 +167,7 @@ export function Channels() {
<PowerOff className="h-6 w-6 text-slate-600" />
</div>
<div>
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
</div>
</div>
@@ -249,7 +176,7 @@ export function Channels() {
</div>
{/* Gateway Warning */}
{showGatewayWarning && (
{gatewayStatus.state !== 'running' && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-500" />
@@ -270,7 +197,7 @@ export function Channels() {
)}
{/* Configured Channels */}
{configuredChannels.length > 0 && (
{channels.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('configured')}</CardTitle>
@@ -278,7 +205,7 @@ export function Channels() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{configuredChannels.map((channel) => (
{channels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
@@ -306,7 +233,7 @@ export function Channels() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{displayedChannelTypes.map((type) => {
const meta = CHANNEL_META[type];
const isConfigured = configuredTypeSet.has(type);
const isConfigured = configuredTypes.includes(type);
return (
<button
key={type}
@@ -319,7 +246,7 @@ export function Channels() {
<span className="text-3xl">{meta.icon}</span>
<p className="font-medium mt-2">{meta.name}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{t(meta.description)}
{meta.description}
</p>
{isConfigured && (
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
@@ -348,12 +275,8 @@ export function Channels() {
setSelectedChannelType(null);
}}
onChannelAdded={() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
setTimeout(() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, 2200);
fetchChannels();
fetchConfiguredTypes();
setShowAddDialog(false);
setSelectedChannelType(null);
}}
@@ -362,16 +285,14 @@ export function Channels() {
<ConfirmDialog
open={!!channelToDelete}
title={t('common:actions.confirm', 'Confirm')}
title={t('common.confirm', 'Confirm')}
message={t('deleteConfirm')}
confirmLabel={t('common:actions.delete', 'Delete')}
cancelLabel={t('common:actions.cancel', 'Cancel')}
confirmLabel={t('common.delete', 'Delete')}
cancelLabel={t('common.cancel', 'Cancel')}
variant="destructive"
onConfirm={async () => {
if (channelToDelete) {
await deleteChannel(channelToDelete.id);
await fetchConfiguredTypes();
await fetchChannels({ probe: false, silent: true });
setChannelToDelete(null);
}
}}
@@ -437,6 +358,7 @@ interface AddChannelDialogProps {
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
const { t } = useTranslation('channels');
const { addChannel } = useChannelsStore();
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState('');
const [connecting, setConnecting] = useState(false);
@@ -463,7 +385,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setChannelName('');
setIsExistingConfig(false);
// Ensure we clean up any pending QR session if switching away
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
return;
}
@@ -520,11 +442,10 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
toast.success(t('toast.whatsappConnected'));
const accountId = data?.accountId || channelName.trim() || 'default';
try {
const saveResult = await invokeIpc(
'channel:saveConfig',
'whatsapp',
{ enabled: true }
) as { success?: boolean; error?: string };
const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', {
method: 'POST',
body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true } }),
});
if (!saveResult?.success) {
console.error('Failed to save WhatsApp config:', saveResult?.error);
} else {
@@ -533,9 +454,15 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
} catch (error) {
console.error('Failed to save WhatsApp config:', error);
}
// channel:saveConfig triggers main-process reload/restart handling.
// UI state refresh is handled by parent onChannelAdded().
onChannelAdded();
// Register the channel locally so it shows up immediately
addChannel({
type: 'whatsapp',
name: channelName || 'WhatsApp',
}).then(() => {
// Restart gateway to pick up the new session
useGatewayStore.getState().restart().catch(console.error);
onChannelAdded();
});
};
const onError = (...args: unknown[]) => {
@@ -546,18 +473,18 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setConnecting(false);
};
const removeQrListener = window.electron.ipcRenderer.on('channel:whatsapp-qr', onQr);
const removeSuccessListener = window.electron.ipcRenderer.on('channel:whatsapp-success', onSuccess);
const removeErrorListener = window.electron.ipcRenderer.on('channel:whatsapp-error', onError);
const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr);
const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess);
const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError);
return () => {
if (typeof removeQrListener === 'function') removeQrListener();
if (typeof removeSuccessListener === 'function') removeSuccessListener();
if (typeof removeErrorListener === 'function') removeErrorListener();
// Cancel when unmounting or switching types
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
};
}, [selectedType, channelName, onChannelAdded, t]);
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
const handleValidate = async () => {
if (!selectedType) return;
@@ -566,17 +493,16 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setValidationResult(null);
try {
const result = await invokeIpc(
'channel:validateCredentials',
selectedType,
configValues
) as {
const result = await hostApiFetch<{
success: boolean;
valid?: boolean;
errors?: string[];
warnings?: string[];
details?: Record<string, string>;
};
}>('/api/channels/credentials/validate', {
method: 'POST',
body: JSON.stringify({ channelType: selectedType, config: configValues }),
});
const warnings = result.warnings || [];
if (result.valid && result.details) {
@@ -613,24 +539,26 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// For QR-based channels, request QR code
if (meta.connectionType === 'qr') {
const accountId = channelName.trim() || 'default';
await invokeIpc('channel:requestWhatsAppQr', accountId);
await hostApiFetch('/api/channels/whatsapp/start', {
method: 'POST',
body: JSON.stringify({ accountId }),
});
// The QR code will be set via event listener
return;
}
// Step 1: Validate credentials against the actual service API
if (meta.connectionType === 'token') {
const validationResponse = await invokeIpc(
'channel:validateCredentials',
selectedType,
configValues
) as {
const validationResponse = await hostApiFetch<{
success: boolean;
valid?: boolean;
errors?: string[];
warnings?: string[];
details?: Record<string, string>;
};
}>('/api/channels/credentials/validate', {
method: 'POST',
body: JSON.stringify({ channelType: selectedType, config: configValues }),
});
if (!validationResponse.valid) {
setValidationResult({
@@ -667,12 +595,15 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// Step 2: Save channel configuration via IPC
const config: Record<string, unknown> = { ...configValues };
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
const saveResult = await hostApiFetch<{
success?: boolean;
error?: string;
warning?: string;
pluginInstalled?: boolean;
};
}>('/api/channels/config', {
method: 'POST',
body: JSON.stringify({ channelType: selectedType, config }),
});
if (!saveResult?.success) {
throw new Error(saveResult?.error || 'Failed to save channel config');
}
@@ -680,13 +611,20 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
toast.warning(saveResult.warning);
}
// Step 3: Do not call channels.add from renderer; this races with
// gateway reload/restart windows and can create stale local entries.
// Step 3: Add a local channel entry for the UI
await addChannel({
type: selectedType,
name: channelName || CHANNEL_NAMES[selectedType],
token: configValues[meta.configFields[0]?.key] || undefined,
});
toast.success(t('toast.channelSaved', { name: meta.name }));
// Gateway reload/restart is handled in the main-process save handler.
// Renderer should only persist config and refresh local UI state.
// Gateway restart is now handled server-side via debouncedRestart()
// inside the channel:saveConfig IPC handler, so we don't need to
// trigger it explicitly here. This avoids cascading restarts when
// multiple config changes happen in quick succession (e.g. during
// the setup wizard).
toast.success(t('toast.channelConnecting', { name: meta.name }));
// Brief delay so user can see the success state before dialog closes

View File

@@ -10,6 +10,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client';
// ── Types ────────────────────────────────────────────────────────
@@ -126,17 +127,17 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
// Stage all files via IPC
console.log('[pickFiles] Staging files:', result.filePaths);
const staged = await invokeIpc(
'file:stage',
result.filePaths,
) as Array<{
const staged = await hostApiFetch<Array<{
id: string;
fileName: string;
mimeType: string;
fileSize: number;
stagedPath: string;
preview: string | null;
}>;
}>>('/api/files/stage-paths', {
method: 'POST',
body: JSON.stringify({ filePaths: result.filePaths }),
});
console.log('[pickFiles] Stage result:', staged?.map(s => ({ id: s?.id, fileName: s?.fileName, mimeType: s?.mimeType, fileSize: s?.fileSize, stagedPath: s?.stagedPath, hasPreview: !!s?.preview })));
// Update each placeholder with real data
@@ -193,18 +194,21 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
console.log(`[stageBuffer] Reading file: ${file.name} (${file.type}, ${file.size} bytes)`);
const base64 = await readFileAsBase64(file);
console.log(`[stageBuffer] Base64 length: ${base64?.length ?? 'null'}`);
const staged = await invokeIpc('file:stageBuffer', {
base64,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
}) as {
const staged = await hostApiFetch<{
id: string;
fileName: string;
mimeType: string;
fileSize: number;
stagedPath: string;
preview: string | null;
};
}>('/api/files/stage-buffer', {
method: 'POST',
body: JSON.stringify({
base64,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
}),
});
console.log(`[stageBuffer] Staged: id=${staged?.id}, path=${staged?.stagedPath}, size=${staged?.fileSize}`);
setAttachments(prev => prev.map(a =>
a.id === tempId ? { ...staged, status: 'ready' as const } : a,

View File

@@ -26,7 +26,7 @@ import { useSkillsStore } from '@/stores/skills';
import { useSettingsStore } from '@/stores/settings';
import { StatusBadge } from '@/components/common/StatusBadge';
import { FeedbackState } from '@/components/common/FeedbackState';
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { trackUiEvent } from '@/lib/telemetry';
import { useTranslation } from 'react-i18next';
@@ -67,7 +67,7 @@ export function Dashboard() {
if (isGatewayRunning) {
fetchChannels();
fetchSkills();
invokeIpc<UsageHistoryEntry[]>('usage:recentTokenHistory')
hostApiFetch<UsageHistoryEntry[]>('/api/usage/recent-token-history')
.then((entries) => {
setUsageHistory(Array.isArray(entries) ? entries : []);
setUsagePage(1);
@@ -111,11 +111,11 @@ export function Dashboard() {
const openDevConsole = async () => {
try {
const result = await invokeIpc<{
const result = await hostApiFetch<{
success: boolean;
url?: string;
error?: string;
}>('gateway:getControlUiUrl');
}>('/api/gateway/control-ui');
if (result.success && result.url) {
trackUiEvent('dashboard.quick_action', { action: 'dev_console' });
window.electron.openExternal(result.url);

View File

@@ -34,6 +34,7 @@ import { invokeIpc, toUserMessage } from '@/lib/api-client';
import { trackUiEvent } from '@/lib/telemetry';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from '@/i18n';
import { hostApiFetch } from '@/lib/host-api';
type ControlUiInfo = {
url: string;
token: string;
@@ -103,8 +104,8 @@ export function Settings() {
const handleShowLogs = async () => {
try {
const logs = await invokeIpc<string>('log:readFile', 100);
setLogContent(logs);
const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100');
setLogContent(logs.content);
setShowLogs(true);
} catch {
setLogContent('(Failed to load logs)');
@@ -114,7 +115,7 @@ export function Settings() {
const handleOpenLogDir = async () => {
try {
const logDir = await invokeIpc<string>('log:getDir');
const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir');
if (logDir) {
await invokeIpc('shell:showItemInFolder', logDir);
}
@@ -126,13 +127,13 @@ export function Settings() {
// Open developer console
const openDevConsole = async () => {
try {
const result = await invokeIpc<{
const result = await hostApiFetch<{
success: boolean;
url?: string;
token?: string;
port?: number;
error?: string;
}>('gateway:getControlUiUrl');
}>('/api/gateway/control-ui');
if (result.success && result.url && result.token && typeof result.port === 'number') {
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
trackUiEvent('settings.open_dev_console');
@@ -147,12 +148,12 @@ export function Settings() {
const refreshControlUiInfo = async () => {
try {
const result = await invokeIpc<{
const result = await hostApiFetch<{
success: boolean;
url?: string;
token?: string;
port?: number;
}>('gateway:getControlUiUrl');
}>('/api/gateway/control-ui');
if (result.success && result.url && result.token && typeof result.port === 'number') {
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
}

View File

@@ -32,6 +32,8 @@ import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from '@/i18n';
import { toast } from 'sonner';
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
interface SetupStep {
id: string;
title: string;
@@ -91,6 +93,8 @@ const defaultSkills: DefaultSkill[] = [
import {
SETUP_PROVIDERS,
type ProviderAccount,
type ProviderType,
type ProviderTypeInfo,
getProviderIconUrl,
resolveProviderApiKeyForSave,
@@ -98,6 +102,12 @@ import {
shouldInvertInDark,
shouldShowProviderModelId,
} from '@/lib/providers';
import {
buildProviderAccountId,
fetchProviderSnapshot,
hasConfiguredCredentials,
pickPreferredAccount,
} from '@/lib/provider-accounts';
import clawxIcon from '@/assets/logo.svg';
// Use the shared provider registry for setup providers
@@ -147,18 +157,6 @@ export function Setup() {
}
}, [safeStepIndex, providerConfigured, runtimeChecksPassed]);
// Keep setup flow linear: advance to provider step automatically
// once runtime checks become healthy.
useEffect(() => {
if (safeStepIndex !== STEP.RUNTIME || !runtimeChecksPassed) {
return;
}
const timer = setTimeout(() => {
setCurrentStep(STEP.PROVIDER);
}, 600);
return () => clearTimeout(timer);
}, [runtimeChecksPassed, safeStepIndex]);
const handleNext = async () => {
if (isLastStep) {
// Complete setup
@@ -539,8 +537,8 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const handleShowLogs = async () => {
try {
const logs = await invokeIpc('log:readFile', 100) as string;
setLogContent(logs);
const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100');
setLogContent(logs.content);
setShowLogs(true);
} catch {
setLogContent('(Failed to load logs)');
@@ -550,7 +548,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const handleOpenLogDir = async () => {
try {
const logDir = await invokeIpc('log:getDir') as string;
const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir');
if (logDir) {
await invokeIpc('shell:showItemInFolder', logDir);
}
@@ -709,7 +707,7 @@ function ProviderContent({
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [keyValid, setKeyValid] = useState<boolean | null>(null);
const [selectedProviderConfigId, setSelectedProviderConfigId] = useState<string | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
const [baseUrl, setBaseUrl] = useState('');
const [modelId, setModelId] = useState('');
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
@@ -725,6 +723,7 @@ function ProviderContent({
expiresIn: number;
} | null>(null);
const [oauthError, setOauthError] = useState<string | null>(null);
const pendingOAuthRef = useRef<{ accountId: string; label: string } | null>(null);
// Manage OAuth events
useEffect(() => {
@@ -733,19 +732,27 @@ function ProviderContent({
setOauthError(null);
};
const handleSuccess = async () => {
const handleSuccess = async (data: unknown) => {
setOauthFlowing(false);
setOauthData(null);
setKeyValid(true);
if (selectedProvider) {
const payload = (data as { accountId?: string } | undefined) || undefined;
const accountId = payload?.accountId || pendingOAuthRef.current?.accountId;
if (accountId) {
try {
await invokeIpc('provider:setDefault', selectedProvider);
await hostApiFetch('/api/provider-accounts/default', {
method: 'PUT',
body: JSON.stringify({ accountId }),
});
setSelectedAccountId(accountId);
} catch (error) {
console.error('Failed to set default provider:', error);
console.error('Failed to set default provider account:', error);
}
}
pendingOAuthRef.current = null;
onConfiguredChange(true);
toast.success(t('provider.valid'));
};
@@ -753,34 +760,31 @@ function ProviderContent({
const handleError = (data: unknown) => {
setOauthError((data as { message: string }).message);
setOauthData(null);
pendingOAuthRef.current = null;
};
window.electron.ipcRenderer.on('oauth:code', handleCode);
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
window.electron.ipcRenderer.on('oauth:error', handleError);
const offCode = subscribeHostEvent('oauth:code', handleCode);
const offSuccess = subscribeHostEvent('oauth:success', handleSuccess);
const offError = subscribeHostEvent('oauth:error', handleError);
return () => {
// Clean up manually if the API provides removeListener, though `on` in preloads might not return an unsub.
// Easiest is to just let it be, or if they have `off`:
if (typeof window.electron.ipcRenderer.off === 'function') {
window.electron.ipcRenderer.off('oauth:code', handleCode);
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
window.electron.ipcRenderer.off('oauth:error', handleError);
}
offCode();
offSuccess();
offError();
};
}, [onConfiguredChange, t, selectedProvider]);
}, [onConfiguredChange, t]);
const handleStartOAuth = async () => {
if (!selectedProvider) return;
try {
const list = await invokeIpc('provider:list') as Array<{ type: string }>;
const existingTypes = new Set(list.map(l => l.type));
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
const snapshot = await fetchProviderSnapshot();
const existingVendorIds = new Set(snapshot.accounts.map((account) => account.vendorId));
if (selectedProvider === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
return;
}
if (selectedProvider === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
if (selectedProvider === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
return;
}
@@ -793,10 +797,22 @@ function ProviderContent({
setOauthError(null);
try {
await invokeIpc('provider:requestOAuth', selectedProvider);
const snapshot = await fetchProviderSnapshot();
const accountId = buildProviderAccountId(
selectedProvider as ProviderType,
selectedAccountId,
snapshot.vendors,
);
const label = selectedProviderData?.name || selectedProvider;
pendingOAuthRef.current = { accountId, label };
await hostApiFetch('/api/providers/oauth/start', {
method: 'POST',
body: JSON.stringify({ provider: selectedProvider, accountId, label }),
});
} catch (e) {
setOauthError(String(e));
setOauthFlowing(false);
pendingOAuthRef.current = null;
}
};
@@ -804,7 +820,8 @@ function ProviderContent({
setOauthFlowing(false);
setOauthData(null);
setOauthError(null);
await invokeIpc('provider:cancelOAuth');
pendingOAuthRef.current = null;
await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' });
};
// On mount, try to restore previously configured provider
@@ -812,26 +829,28 @@ function ProviderContent({
let cancelled = false;
(async () => {
try {
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
const defaultId = await invokeIpc('provider:getDefault') as string | null;
const snapshot = await fetchProviderSnapshot();
const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status]));
const setupProviderTypes = new Set<string>(providers.map((p) => p.id));
const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type));
const setupCandidates = snapshot.accounts.filter((account) => setupProviderTypes.has(account.vendorId));
const preferred =
(defaultId && setupCandidates.find((p) => p.id === defaultId))
|| setupCandidates.find((p) => p.hasKey)
(snapshot.defaultAccountId
&& setupCandidates.find((account) => account.id === snapshot.defaultAccountId))
|| setupCandidates.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id)))
|| setupCandidates[0];
if (preferred && !cancelled) {
onSelectProvider(preferred.type);
setSelectedProviderConfigId(preferred.id);
const typeInfo = providers.find((p) => p.id === preferred.type);
onSelectProvider(preferred.vendorId);
setSelectedAccountId(preferred.id);
const typeInfo = providers.find((p) => p.id === preferred.vendorId);
const requiresKey = typeInfo?.requiresApiKey ?? false;
onConfiguredChange(!requiresKey || preferred.hasKey);
const storedKey = await invokeIpc('provider:getApiKey', preferred.id) as string | null;
if (storedKey) {
onApiKeyChange(storedKey);
}
onConfiguredChange(!requiresKey || hasConfiguredCredentials(preferred, statusMap.get(preferred.id)));
const storedKey = (await hostApiFetch<{ apiKey: string | null }>(
`/api/providers/${encodeURIComponent(preferred.id)}/api-key`,
)).apiKey;
onApiKeyChange(storedKey || '');
} else if (!cancelled) {
onConfiguredChange(false);
onApiKeyChange('');
}
} catch (error) {
if (!cancelled) {
@@ -848,25 +867,25 @@ function ProviderContent({
(async () => {
if (!selectedProvider) return;
try {
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
const defaultId = await invokeIpc('provider:getDefault') as string | null;
const sameType = list.filter((p) => p.type === selectedProvider);
const preferredInstance =
(defaultId && sameType.find((p) => p.id === defaultId))
|| sameType.find((p) => p.hasKey)
|| sameType[0];
const providerIdForLoad = preferredInstance?.id || selectedProvider;
setSelectedProviderConfigId(providerIdForLoad);
const snapshot = await fetchProviderSnapshot();
const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status]));
const preferredAccount = pickPreferredAccount(
snapshot.accounts,
snapshot.defaultAccountId,
selectedProvider,
statusMap,
);
const accountIdForLoad = preferredAccount?.id || selectedProvider;
setSelectedAccountId(preferredAccount?.id || null);
const savedProvider = await invokeIpc(
'provider:get',
providerIdForLoad
) as { baseUrl?: string; model?: string } | null;
const storedKey = await invokeIpc('provider:getApiKey', providerIdForLoad) as string | null;
const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string } | null>(
`/api/providers/${encodeURIComponent(accountIdForLoad)}`,
);
const storedKey = (await hostApiFetch<{ apiKey: string | null }>(
`/api/providers/${encodeURIComponent(accountIdForLoad)}/api-key`,
)).apiKey;
if (!cancelled) {
if (storedKey) {
onApiKeyChange(storedKey);
}
onApiKeyChange(storedKey || '');
const info = providers.find((p) => p.id === selectedProvider);
setBaseUrl(savedProvider?.baseUrl || info?.defaultBaseUrl || '');
@@ -919,13 +938,13 @@ function ProviderContent({
if (!selectedProvider) return;
try {
const list = await invokeIpc('provider:list') as Array<{ type: string }>;
const existingTypes = new Set(list.map(l => l.type));
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
const snapshot = await fetchProviderSnapshot();
const existingVendorIds = new Set(snapshot.accounts.map((account) => account.vendorId));
if (selectedProvider === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
return;
}
if (selectedProvider === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
if (selectedProvider === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
return;
}
@@ -942,7 +961,7 @@ function ProviderContent({
if (isApiKeyRequired && apiKey) {
const result = await invokeIpc(
'provider:validateKey',
selectedProviderConfigId || selectedProvider,
selectedAccountId || selectedProvider,
apiKey,
{ baseUrl: baseUrl.trim() || undefined }
) as { valid: boolean; error?: string };
@@ -963,46 +982,70 @@ function ProviderContent({
modelId,
devModeUnlocked
);
const providerIdForSave =
selectedProvider === 'custom'
? (selectedProviderConfigId?.startsWith('custom-')
? selectedProviderConfigId
: `custom-${crypto.randomUUID()}`)
: selectedProvider;
const snapshot = await fetchProviderSnapshot();
const accountIdForSave = buildProviderAccountId(
selectedProvider as ProviderType,
selectedAccountId,
snapshot.vendors,
);
const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey);
const accountPayload: ProviderAccount = {
id: accountIdForSave,
vendorId: selectedProvider as ProviderType,
label: selectedProvider === 'custom'
? t('settings:aiProviders.custom')
: (selectedProviderData?.name || selectedProvider),
authMode: selectedProvider === 'ollama'
? 'local'
: 'api_key',
baseUrl: baseUrl.trim() || undefined,
model: effectiveModelId,
enabled: true,
isDefault: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Save provider config + API key, then set as default
const saveResult = await invokeIpc(
'provider:save',
{
id: providerIdForSave,
name: selectedProvider === 'custom' ? t('settings:aiProviders.custom') : (selectedProviderData?.name || selectedProvider),
type: selectedProvider,
baseUrl: baseUrl.trim() || undefined,
model: effectiveModelId,
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
effectiveApiKey
) as { success: boolean; error?: string };
const saveResult = selectedAccountId
? await hostApiFetch<{ success: boolean; error?: string }>(
`/api/provider-accounts/${encodeURIComponent(accountIdForSave)}`,
{
method: 'PUT',
body: JSON.stringify({
updates: {
label: accountPayload.label,
authMode: accountPayload.authMode,
baseUrl: accountPayload.baseUrl,
model: accountPayload.model,
enabled: accountPayload.enabled,
},
apiKey: effectiveApiKey,
}),
},
)
: await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', {
method: 'POST',
body: JSON.stringify({ account: accountPayload, apiKey: effectiveApiKey }),
});
if (!saveResult.success) {
throw new Error(saveResult.error || 'Failed to save provider config');
}
const defaultResult = await invokeIpc(
'provider:setDefault',
providerIdForSave
) as { success: boolean; error?: string };
const defaultResult = await hostApiFetch<{ success: boolean; error?: string }>(
'/api/provider-accounts/default',
{
method: 'PUT',
body: JSON.stringify({ accountId: accountIdForSave }),
},
);
if (!defaultResult.success) {
throw new Error(defaultResult.error || 'Failed to set default provider');
}
setSelectedProviderConfigId(providerIdForSave);
setSelectedAccountId(accountIdForSave);
onConfiguredChange(true);
toast.success(t('provider.valid'));
} catch (error) {
@@ -1024,7 +1067,7 @@ function ProviderContent({
const handleSelectProvider = (providerId: string) => {
onSelectProvider(providerId);
setSelectedProviderConfigId(null);
setSelectedAccountId(null);
onConfiguredChange(false);
onApiKeyChange('');
setKeyValid(null);

View File

@@ -39,6 +39,7 @@ import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { trackUiEvent } from '@/lib/telemetry';
import { toast } from 'sonner';
import type { Skill, MarketplaceSkill } from '@/types/skill';
@@ -93,7 +94,10 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
const handleOpenEditor = async () => {
if (skill.slug) {
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:openSkillReadme', skill.slug);
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', {
method: 'POST',
body: JSON.stringify({ slug: skill.slug }),
});
if (result.success) {
toast.success(t('toast.openedEditor'));
} else {

View File

@@ -3,8 +3,9 @@
* Manages messaging channel state
*/
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway';
import type { Channel, ChannelType } from '../types/channel';
import { invokeIpc } from '@/lib/api-client';
interface AddChannelParams {
type: ChannelType;
@@ -18,7 +19,7 @@ interface ChannelsState {
error: string | null;
// Actions
fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise<void>;
fetchChannels: () => Promise<void>;
addChannel: (params: AddChannelParams) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
connectChannel: (channelId: string) => Promise<void>;
@@ -34,20 +35,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
loading: false,
error: null,
fetchChannels: async (options) => {
const probe = options?.probe ?? false;
const silent = options?.silent ?? false;
if (!silent) {
set({ loading: true, error: null });
}
fetchChannels: async () => {
set({ loading: true, error: null });
try {
const result = await invokeIpc(
'gateway:rpc',
'channels.status',
{ probe }
) as {
success: boolean;
result?: {
const data = await useGatewayStore.getState().rpc<{
channelOrder?: string[];
channels?: Record<string, unknown>;
channelAccounts?: Record<string, Array<{
@@ -63,12 +54,8 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
lastOutboundAt?: number | null;
}>>;
channelDefaultAccountId?: Record<string, string>;
};
error?: string;
};
if (result.success && result.result) {
const data = result.result;
}>('channels.status', { probe: true });
if (data) {
const channels: Channel[] = [];
// Parse the complex channels.status response into simple Channel objects
@@ -131,30 +118,26 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
});
}
set((state) => ({ channels, loading: silent ? state.loading : false }));
set({ channels, loading: false });
} else {
// Gateway not available - try to show channels from local config
set((state) => ({ channels: [], loading: silent ? state.loading : false }));
set({ channels: [], loading: false });
}
} catch {
// Gateway not connected, show empty
set((state) => ({ channels: [], loading: silent ? state.loading : false }));
set({ channels: [], loading: false });
}
},
addChannel: async (params) => {
try {
const result = await invokeIpc(
'gateway:rpc',
'channels.add',
params
) as { success: boolean; result?: Channel; error?: string };
const result = await useGatewayStore.getState().rpc<Channel>('channels.add', params);
if (result.success && result.result) {
if (result) {
set((state) => ({
channels: [...state.channels, result.result!],
channels: [...state.channels, result],
}));
return result.result;
return result;
} else {
// If gateway is not available, create a local channel for now
const newChannel: Channel = {
@@ -189,17 +172,15 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
try {
// Delete the channel configuration from openclaw.json
await invokeIpc('channel:deleteConfig', channelType);
await hostApiFetch(`/api/channels/config/${encodeURIComponent(channelType)}`, {
method: 'DELETE',
});
} catch (error) {
console.error('Failed to delete channel config:', error);
}
try {
await invokeIpc(
'gateway:rpc',
'channels.delete',
{ channelId: channelType }
);
await useGatewayStore.getState().rpc('channels.delete', { channelId: channelType });
} catch (error) {
// Continue with local deletion even if gateway fails
console.error('Failed to delete channel from gateway:', error);
@@ -216,17 +197,8 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
updateChannel(channelId, { status: 'connecting', error: undefined });
try {
const result = await invokeIpc(
'gateway:rpc',
'channels.connect',
{ channelId }
) as { success: boolean; error?: string };
if (result.success) {
updateChannel(channelId, { status: 'connected' });
} else {
updateChannel(channelId, { status: 'error', error: result.error });
}
await useGatewayStore.getState().rpc('channels.connect', { channelId });
updateChannel(channelId, { status: 'connected' });
} catch (error) {
updateChannel(channelId, { status: 'error', error: String(error) });
}
@@ -236,11 +208,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
const { updateChannel } = get();
try {
await invokeIpc(
'gateway:rpc',
'channels.disconnect',
{ channelId }
);
await useGatewayStore.getState().rpc('channels.disconnect', { channelId });
} catch (error) {
console.error('Failed to disconnect channel:', error);
}
@@ -249,17 +217,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
},
requestQrCode: async (channelType) => {
const result = await invokeIpc(
'gateway:rpc',
return await useGatewayStore.getState().rpc<{ qrCode: string; sessionId: string }>(
'channels.requestQr',
{ type: channelType }
) as { success: boolean; result?: { qrCode: string; sessionId: string }; error?: string };
if (result.success && result.result) {
return result.result;
}
throw new Error(result.error || 'Failed to request QR code');
{ type: channelType },
);
},
setChannels: (channels) => set({ channels }),

View File

@@ -1,10 +1,11 @@
/**
* Chat State Store
* Manages chat messages, sessions, streaming, and thinking state.
* Communicates with OpenClaw Gateway via gateway:rpc IPC.
* Communicates with OpenClaw Gateway via renderer WebSocket RPC.
*/
import { create } from 'zustand';
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway';
// ── Types ────────────────────────────────────────────────────────
@@ -597,10 +598,13 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
if (needPreview.length === 0) return false;
try {
const thumbnails = await invokeIpc(
'media:getThumbnails',
needPreview,
) as Record<string, { preview: string | null; fileSize: number }>;
const thumbnails = await hostApiFetch<Record<string, { preview: string | null; fileSize: number }>>(
'/api/files/thumbnails',
{
method: 'POST',
body: JSON.stringify({ paths: needPreview }),
},
);
let updated = false;
for (const msg of messages) {
@@ -929,14 +933,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
loadSessions: async () => {
try {
const result = await invokeIpc(
'gateway:rpc',
'sessions.list',
{}
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>('sessions.list', {});
if (data) {
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
@@ -1002,13 +1000,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
void Promise.all(
sessionsToLabel.map(async (session) => {
try {
const r = await invokeIpc(
'gateway:rpc',
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: session.key, limit: 1000 },
) as { success: boolean; result?: Record<string, unknown> };
if (!r.success || !r.result) return;
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
);
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
const firstUser = msgs.find((m) => m.role === 'user');
const lastMsg = msgs[msgs.length - 1];
set((s) => {
@@ -1078,10 +1074,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
// sessions.list and token-usage queries both skip it automatically.
try {
const result = await invokeIpc('session:delete', key) as {
const result = await hostApiFetch<{
success: boolean;
error?: string;
};
}>('/api/sessions/delete', {
method: 'POST',
body: JSON.stringify({ sessionKey: key }),
});
if (!result.success) {
console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error);
}
@@ -1186,14 +1185,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!quiet) set({ loading: true, error: null });
try {
const result = await invokeIpc(
'gateway:rpc',
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
{ sessionKey: currentSessionKey, limit: 200 },
);
if (data) {
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
// Before filtering: attach images/files from tool_result messages to the next assistant message
@@ -1426,23 +1422,25 @@ export const useChatStore = create<ChatState>((set, get) => ({
const CHAT_SEND_TIMEOUT_MS = 120_000;
if (hasMedia) {
result = await invokeIpc(
'chat:sendWithMedia',
result = await hostApiFetch<{ success: boolean; result?: { runId?: string }; error?: string }>(
'/api/chat/send-with-media',
{
sessionKey: currentSessionKey,
message: trimmed || 'Process the attached file(s).',
deliver: false,
idempotencyKey,
media: attachments.map((a) => ({
filePath: a.stagedPath,
mimeType: a.mimeType,
fileName: a.fileName,
})),
method: 'POST',
body: JSON.stringify({
sessionKey: currentSessionKey,
message: trimmed || 'Process the attached file(s).',
deliver: false,
idempotencyKey,
media: attachments.map((a) => ({
filePath: a.stagedPath,
mimeType: a.mimeType,
fileName: a.fileName,
})),
}),
},
) as { success: boolean; result?: { runId?: string }; error?: string };
);
} else {
result = await invokeIpc(
'gateway:rpc',
const rpcResult = await useGatewayStore.getState().rpc<{ runId?: string }>(
'chat.send',
{
sessionKey: currentSessionKey,
@@ -1451,7 +1449,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
idempotencyKey,
},
CHAT_SEND_TIMEOUT_MS,
) as { success: boolean; result?: { runId?: string }; error?: string };
);
result = { success: true, result: rpcResult };
}
console.log(`[sendMessage] RPC result: success=${result.success}, runId=${result.result?.runId || 'none'}`);
@@ -1478,8 +1477,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ streamingTools: [] });
try {
await invokeIpc(
'gateway:rpc',
await useGatewayStore.getState().rpc(
'chat.abort',
{ sessionKey: currentSessionKey },
);

View File

@@ -3,8 +3,8 @@
* Manages scheduled task state
*/
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
import { invokeIpc } from '@/lib/api-client';
interface CronState {
jobs: CronJob[];
@@ -30,7 +30,7 @@ export const useCronStore = create<CronState>((set) => ({
set({ loading: true, error: null });
try {
const result = await invokeIpc<CronJob[]>('cron:list');
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
set({ jobs: result, loading: false });
} catch (error) {
set({ error: String(error), loading: false });
@@ -39,7 +39,10 @@ export const useCronStore = create<CronState>((set) => ({
createJob: async (input) => {
try {
const job = await invokeIpc<CronJob>('cron:create', input);
const job = await hostApiFetch<CronJob>('/api/cron/jobs', {
method: 'POST',
body: JSON.stringify(input),
});
set((state) => ({ jobs: [...state.jobs, job] }));
return job;
} catch (error) {
@@ -50,7 +53,10 @@ export const useCronStore = create<CronState>((set) => ({
updateJob: async (id, input) => {
try {
await invokeIpc('cron:update', id, input);
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
set((state) => ({
jobs: state.jobs.map((job) =>
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
@@ -64,7 +70,9 @@ export const useCronStore = create<CronState>((set) => ({
deleteJob: async (id) => {
try {
await invokeIpc('cron:delete', id);
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
set((state) => ({
jobs: state.jobs.filter((job) => job.id !== id),
}));
@@ -76,7 +84,10 @@ export const useCronStore = create<CronState>((set) => ({
toggleJob: async (id, enabled) => {
try {
await invokeIpc('cron:toggle', id, enabled);
await hostApiFetch('/api/cron/toggle', {
method: 'POST',
body: JSON.stringify({ id, enabled }),
});
set((state) => ({
jobs: state.jobs.map((job) =>
job.id === id ? { ...job, enabled } : job
@@ -90,11 +101,14 @@ export const useCronStore = create<CronState>((set) => ({
triggerJob: async (id) => {
try {
const result = await invokeIpc<unknown>('cron:trigger', id);
const result = await hostApiFetch('/api/cron/trigger', {
method: 'POST',
body: JSON.stringify({ id }),
});
console.log('Cron trigger result:', result);
// Refresh jobs after trigger to update lastRun/nextRun state
try {
const jobs = await invokeIpc<CronJob[]>('cron:list');
const jobs = await hostApiFetch<CronJob[]>('/api/cron/jobs');
set({ jobs });
} catch {
// Ignore refresh error

View File

@@ -1,12 +1,14 @@
/**
* Gateway State Store
* Manages Gateway connection state and communication
* Uses Host API + SSE for lifecycle/status and a direct renderer WebSocket for runtime RPC.
*/
import { create } from 'zustand';
import type { GatewayStatus } from '../types/gateway';
import { createHostEventSource, hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client';
import type { GatewayStatus } from '../types/gateway';
let gatewayInitPromise: Promise<void> | null = null;
let gatewayEventSource: EventSource | null = null;
interface GatewayHealth {
ok: boolean;
@@ -19,8 +21,6 @@ interface GatewayState {
health: GatewayHealth | null;
isInitialized: boolean;
lastError: string | null;
// Actions
init: () => Promise<void>;
start: () => Promise<void>;
stop: () => Promise<void>;
@@ -31,6 +31,102 @@ interface GatewayState {
clearError: () => void;
}
function handleGatewayNotification(notification: { method?: string; params?: Record<string, unknown> } | undefined): void {
const payload = notification;
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
return;
}
const p = payload.params;
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
const phase = data.phase ?? p.phase;
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
if (hasChatData) {
const normalizedEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
stream: p.stream ?? data.stream,
seq: p.seq ?? data.seq,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
import('./chat')
.then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent(normalizedEvent);
})
.catch(() => {});
}
const runId = p.runId ?? data.runId;
const sessionKey = p.sessionKey ?? data.sessionKey;
if (phase === 'started' && runId != null && sessionKey != null) {
import('./chat')
.then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent({
state: 'started',
runId,
sessionKey,
});
})
.catch(() => {});
}
if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') {
import('./chat')
.then(({ useChatStore }) => {
const state = useChatStore.getState();
state.loadHistory(true);
if (state.sending) {
useChatStore.setState({
sending: false,
activeRunId: null,
pendingFinal: false,
lastUserMessageAt: null,
});
}
})
.catch(() => {});
}
}
function handleGatewayChatMessage(data: unknown): void {
import('./chat').then(({ useChatStore }) => {
const chatData = data as Record<string, unknown>;
const payload = ('message' in chatData && typeof chatData.message === 'object')
? chatData.message as Record<string, unknown>
: chatData;
if (payload.state) {
useChatStore.getState().handleChatEvent(payload);
return;
}
useChatStore.getState().handleChatEvent({
state: 'final',
message: payload,
runId: chatData.runId ?? payload.runId,
});
}).catch(() => {});
}
function mapChannelStatus(status: string): 'connected' | 'connecting' | 'disconnected' | 'error' {
switch (status) {
case 'connected':
case 'running':
return 'connected';
case 'connecting':
case 'starting':
return 'connecting';
case 'error':
case 'failed':
return 'error';
default:
return 'disconnected';
}
}
export const useGatewayStore = create<GatewayState>((set, get) => ({
status: {
state: 'stopped',
@@ -49,141 +145,41 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
gatewayInitPromise = (async () => {
try {
// Get initial status first
const status = await invokeIpc('gateway:status') as GatewayStatus;
const status = await hostApiFetch<GatewayStatus>('/api/gateway/status');
set({ status, isInitialized: true });
// Listen for status changes
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
set({ status: newStatus as GatewayStatus });
});
// Listen for errors
window.electron.ipcRenderer.on('gateway:error', (error) => {
set({ lastError: String(error) });
});
// Some Gateway builds stream chat events via generic "agent" notifications.
// Normalize and forward them to the chat store.
// The Gateway may put event fields (state, message, etc.) either inside
// params.data or directly on params — we must handle both layouts.
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
const payload = notification as { method?: string; params?: Record<string, unknown> } | undefined;
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
return;
}
const p = payload.params;
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
const phase = data.phase ?? p.phase;
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
if (hasChatData) {
const normalizedEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
stream: p.stream ?? data.stream,
seq: p.seq ?? data.seq,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
import('./chat')
.then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent(normalizedEvent);
})
.catch(() => {});
}
// When a run starts (e.g. user clicked Send on console), show loading in the app immediately.
const runId = p.runId ?? data.runId;
const sessionKey = p.sessionKey ?? data.sessionKey;
if (phase === 'started' && runId != null && sessionKey != null) {
import('./chat')
.then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent({
state: 'started',
runId,
sessionKey,
});
})
.catch(() => {});
}
// When the agent run completes, reload history to get the final response.
if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') {
import('./chat')
.then(({ useChatStore }) => {
const state = useChatStore.getState();
// Always reload history on agent completion, regardless of
// the `sending` flag. After a transient error the flag may
// already be false, but the Gateway may have retried and
// completed successfully in the background.
state.loadHistory(true);
if (state.sending) {
useChatStore.setState({
sending: false,
activeRunId: null,
pendingFinal: false,
lastUserMessageAt: null,
});
if (!gatewayEventSource) {
gatewayEventSource = createHostEventSource();
gatewayEventSource.addEventListener('gateway:status', (event) => {
set({ status: JSON.parse((event as MessageEvent).data) as GatewayStatus });
});
gatewayEventSource.addEventListener('gateway:error', (event) => {
const payload = JSON.parse((event as MessageEvent).data) as { message?: string };
set({ lastError: payload.message || 'Gateway error' });
});
gatewayEventSource.addEventListener('gateway:notification', (event) => {
handleGatewayNotification(JSON.parse((event as MessageEvent).data) as {
method?: string;
params?: Record<string, unknown>;
});
});
gatewayEventSource.addEventListener('gateway:chat-message', (event) => {
handleGatewayChatMessage(JSON.parse((event as MessageEvent).data));
});
gatewayEventSource.addEventListener('gateway:channel-status', (event) => {
import('./channels')
.then(({ useChannelsStore }) => {
const update = JSON.parse((event as MessageEvent).data) as { channelId?: string; status?: string };
if (!update.channelId || !update.status) return;
const state = useChannelsStore.getState();
const channel = state.channels.find((item) => item.type === update.channelId);
if (channel) {
state.updateChannel(channel.id, { status: mapChannelStatus(update.status) });
}
})
.catch(() => {});
}
});
// Listen for chat events from the gateway and forward to chat store.
// The data arrives as { message: payload } from handleProtocolEvent.
// The payload may be a full event wrapper ({ state, runId, message })
// or the raw chat message itself. We need to handle both.
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
try {
import('./chat').then(({ useChatStore }) => {
const chatData = data as Record<string, unknown>;
const payload = ('message' in chatData && typeof chatData.message === 'object')
? chatData.message as Record<string, unknown>
: chatData;
if (payload.state) {
useChatStore.getState().handleChatEvent(payload);
return;
}
// Raw message without state wrapper — treat as final
useChatStore.getState().handleChatEvent({
state: 'final',
message: payload,
runId: chatData.runId ?? payload.runId,
});
}).catch(() => {});
} catch {
// Silently ignore forwarding failures
}
});
// Catch-all: handle unmatched gateway messages that fell through
// all protocol/notification handlers in the main process.
// This prevents events from being silently lost.
window.electron.ipcRenderer.on('gateway:message', (data) => {
if (!data || typeof data !== 'object') return;
const msg = data as Record<string, unknown>;
// Try to detect if this is a chat-related event and forward it
if (msg.state && msg.message) {
import('./chat').then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent(msg);
}).catch(() => {});
} else if (msg.role && msg.content) {
import('./chat').then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent({
state: 'final',
message: msg,
});
}).catch(() => {});
}
});
});
}
} catch (error) {
console.error('Failed to initialize Gateway:', error);
set({ lastError: String(error) });
@@ -198,25 +194,26 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
start: async () => {
try {
set({ status: { ...get().status, state: 'starting' }, lastError: null });
const result = await invokeIpc('gateway:start') as { success: boolean; error?: string };
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/start', {
method: 'POST',
});
if (!result.success) {
set({
status: { ...get().status, state: 'error', error: result.error },
lastError: result.error || 'Failed to start Gateway'
lastError: result.error || 'Failed to start Gateway',
});
}
} catch (error) {
set({
status: { ...get().status, state: 'error', error: String(error) },
lastError: String(error)
lastError: String(error),
});
}
},
stop: async () => {
try {
await invokeIpc('gateway:stop');
await hostApiFetch('/api/gateway/stop', { method: 'POST' });
set({ status: { ...get().status, state: 'stopped' }, lastError: null });
} catch (error) {
console.error('Failed to stop Gateway:', error);
@@ -227,39 +224,28 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
restart: async () => {
try {
set({ status: { ...get().status, state: 'starting' }, lastError: null });
const result = await invokeIpc('gateway:restart') as { success: boolean; error?: string };
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/restart', {
method: 'POST',
});
if (!result.success) {
set({
status: { ...get().status, state: 'error', error: result.error },
lastError: result.error || 'Failed to restart Gateway'
lastError: result.error || 'Failed to restart Gateway',
});
}
} catch (error) {
set({
status: { ...get().status, state: 'error', error: String(error) },
lastError: String(error)
lastError: String(error),
});
}
},
checkHealth: async () => {
try {
const result = await invokeIpc('gateway:health') as {
success: boolean;
ok: boolean;
error?: string;
uptime?: number
};
const health: GatewayHealth = {
ok: result.ok,
error: result.error,
uptime: result.uptime,
};
set({ health });
return health;
const result = await hostApiFetch<GatewayHealth>('/api/gateway/health');
set({ health: result });
return result;
} catch (error) {
const health: GatewayHealth = { ok: false, error: String(error) };
set({ health });
@@ -268,20 +254,17 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
},
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
const result = await invokeIpc('gateway:rpc', method, params, timeoutMs) as {
const response = await invokeIpc<{
success: boolean;
result?: T;
error?: string;
};
if (!result.success) {
throw new Error(result.error || `RPC call failed: ${method}`);
}>('gateway:rpc', method, params, timeoutMs);
if (!response.success) {
throw new Error(response.error || `Gateway RPC failed: ${method}`);
}
return result.result as T;
return response.result as T;
},
setStatus: (status) => set({ status }),
clearError: () => set({ lastError: null }),
}));

View File

@@ -3,23 +3,53 @@
* Manages AI provider configurations
*/
import { create } from 'zustand';
import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
import { invokeIpc } from '@/lib/api-client';
import type {
ProviderAccount,
ProviderConfig,
ProviderVendorInfo,
ProviderWithKeyInfo,
} from '@/lib/providers';
import { hostApiFetch } from '@/lib/host-api';
import {
fetchProviderSnapshot,
} from '@/lib/provider-accounts';
// Re-export types for consumers that imported from here
export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
export type {
ProviderAccount,
ProviderConfig,
ProviderVendorInfo,
ProviderWithKeyInfo,
} from '@/lib/providers';
export type { ProviderSnapshot } from '@/lib/provider-accounts';
interface ProviderState {
providers: ProviderWithKeyInfo[];
defaultProviderId: string | null;
statuses: ProviderWithKeyInfo[];
accounts: ProviderAccount[];
vendors: ProviderVendorInfo[];
defaultAccountId: string | null;
loading: boolean;
error: string | null;
// Actions
refreshProviderSnapshot: () => Promise<void>;
createAccount: (account: ProviderAccount, apiKey?: string) => Promise<void>;
removeAccount: (accountId: string) => Promise<void>;
validateAccountApiKey: (
accountId: string,
apiKey: string,
options?: { baseUrl?: string }
) => Promise<{ valid: boolean; error?: string }>;
getAccountApiKey: (accountId: string) => Promise<string | null>;
// Legacy compatibility aliases
fetchProviders: () => Promise<void>;
addProvider: (config: Omit<ProviderConfig, 'createdAt' | 'updatedAt'>, apiKey?: string) => Promise<void>;
addAccount: (account: ProviderAccount, apiKey?: string) => Promise<void>;
updateProvider: (providerId: string, updates: Partial<ProviderConfig>, apiKey?: string) => Promise<void>;
updateAccount: (accountId: string, updates: Partial<ProviderAccount>, apiKey?: string) => Promise<void>;
deleteProvider: (providerId: string) => Promise<void>;
deleteAccount: (accountId: string) => Promise<void>;
setApiKey: (providerId: string, apiKey: string) => Promise<void>;
updateProviderWithKey: (
providerId: string,
@@ -28,6 +58,7 @@ interface ProviderState {
) => Promise<void>;
deleteApiKey: (providerId: string) => Promise<void>;
setDefaultProvider: (providerId: string) => Promise<void>;
setDefaultAccount: (accountId: string) => Promise<void>;
validateApiKey: (
providerId: string,
apiKey: string,
@@ -37,27 +68,32 @@ interface ProviderState {
}
export const useProviderStore = create<ProviderState>((set, get) => ({
providers: [],
defaultProviderId: null,
statuses: [],
accounts: [],
vendors: [],
defaultAccountId: null,
loading: false,
error: null,
fetchProviders: async () => {
refreshProviderSnapshot: async () => {
set({ loading: true, error: null });
try {
const providers = await invokeIpc<ProviderWithKeyInfo[]>('provider:list');
const defaultId = await invokeIpc<string | null>('provider:getDefault');
const snapshot = await fetchProviderSnapshot();
set({
providers,
defaultProviderId: defaultId,
statuses: snapshot.statuses,
accounts: snapshot.accounts,
vendors: snapshot.vendors,
defaultAccountId: snapshot.defaultAccountId,
loading: false
});
} catch (error) {
set({ error: String(error), loading: false });
}
},
fetchProviders: async () => get().refreshProviderSnapshot(),
addProvider: async (config, apiKey) => {
try {
@@ -67,23 +103,46 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
updatedAt: new Date().toISOString(),
};
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', fullConfig, apiKey);
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/providers', {
method: 'POST',
body: JSON.stringify({ config: fullConfig, apiKey }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to save provider');
}
// Refresh the list
await get().fetchProviders();
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to add provider:', error);
throw error;
}
},
createAccount: async (account, apiKey) => {
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', {
method: 'POST',
body: JSON.stringify({ account, apiKey }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to create provider account');
}
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to add account:', error);
throw error;
}
},
addAccount: async (account, apiKey) => get().createAccount(account, apiKey),
updateProvider: async (providerId, updates, apiKey) => {
try {
const existing = get().providers.find((p) => p.id === providerId);
const existing = get().statuses.find((p) => p.id === providerId);
if (!existing) {
throw new Error('Provider not found');
}
@@ -96,46 +155,91 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
updatedAt: new Date().toISOString(),
};
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', updatedConfig, apiKey);
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
method: 'PUT',
body: JSON.stringify({ updates: updatedConfig, apiKey }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to update provider');
}
// Refresh the list
await get().fetchProviders();
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to update provider:', error);
throw error;
}
},
updateAccount: async (accountId, updates, apiKey) => {
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, {
method: 'PUT',
body: JSON.stringify({ updates, apiKey }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to update provider account');
}
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to update account:', error);
throw error;
}
},
deleteProvider: async (providerId) => {
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:delete', providerId);
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
method: 'DELETE',
});
if (!result.success) {
throw new Error(result.error || 'Failed to delete provider');
}
// Refresh the list
await get().fetchProviders();
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to delete provider:', error);
throw error;
}
},
removeAccount: async (accountId) => {
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
});
if (!result.success) {
throw new Error(result.error || 'Failed to delete provider account');
}
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to delete account:', error);
throw error;
}
},
deleteAccount: async (accountId) => get().removeAccount(accountId),
setApiKey: async (providerId, apiKey) => {
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setApiKey', providerId, apiKey);
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
method: 'PUT',
body: JSON.stringify({ updates: {}, apiKey }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to set API key');
}
// Refresh the list
await get().fetchProviders();
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to set API key:', error);
throw error;
@@ -144,18 +248,16 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
updateProviderWithKey: async (providerId, updates, apiKey) => {
try {
const result = await invokeIpc<{ success: boolean; error?: string }>(
'provider:updateWithKey',
providerId,
updates,
apiKey
);
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
method: 'PUT',
body: JSON.stringify({ updates, apiKey }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to update provider');
}
await get().fetchProviders();
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to update provider with key:', error);
throw error;
@@ -164,14 +266,17 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
deleteApiKey: async (providerId) => {
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:deleteApiKey', providerId);
const result = await hostApiFetch<{ success: boolean; error?: string }>(
`/api/providers/${encodeURIComponent(providerId)}?apiKeyOnly=1`,
{ method: 'DELETE' },
);
if (!result.success) {
throw new Error(result.error || 'Failed to delete API key');
}
// Refresh the list
await get().fetchProviders();
await get().refreshProviderSnapshot();
} catch (error) {
console.error('Failed to delete API key:', error);
throw error;
@@ -180,38 +285,62 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
setDefaultProvider: async (providerId) => {
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setDefault', providerId);
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/providers/default', {
method: 'PUT',
body: JSON.stringify({ providerId }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to set default provider');
}
set({ defaultProviderId: providerId });
set({ defaultAccountId: providerId });
} catch (error) {
console.error('Failed to set default provider:', error);
throw error;
}
},
validateApiKey: async (providerId, apiKey, options) => {
setDefaultAccount: async (accountId) => {
try {
const result = await invokeIpc<{ valid: boolean; error?: string }>(
'provider:validateKey',
providerId,
apiKey,
options
);
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', {
method: 'PUT',
body: JSON.stringify({ accountId }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to set default provider account');
}
set({ defaultAccountId: accountId });
} catch (error) {
console.error('Failed to set default account:', error);
throw error;
}
},
validateAccountApiKey: async (providerId, apiKey, options) => {
try {
const result = await hostApiFetch<{ valid: boolean; error?: string }>('/api/providers/validate', {
method: 'POST',
body: JSON.stringify({ providerId, apiKey, options }),
});
return result;
} catch (error) {
return { valid: false, error: String(error) };
}
},
validateApiKey: async (providerId, apiKey, options) => get().validateAccountApiKey(providerId, apiKey, options),
getApiKey: async (providerId) => {
getAccountApiKey: async (providerId) => {
try {
return await invokeIpc<string | null>('provider:getApiKey', providerId);
const result = await hostApiFetch<{ apiKey: string | null }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`);
return result.apiKey;
} catch {
return null;
}
},
getApiKey: async (providerId) => get().getAccountApiKey(providerId),
}));

View File

@@ -5,6 +5,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import i18n from '@/i18n';
import { hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client';
type Theme = 'light' | 'dark' | 'system';
@@ -99,7 +100,7 @@ export const useSettingsStore = create<SettingsState>()(
init: async () => {
try {
const settings = await invokeIpc<Partial<typeof defaultSettings>>('settings:getAll');
const settings = await hostApiFetch<Partial<typeof defaultSettings>>('/api/settings');
set((state) => ({ ...state, ...settings }));
if (settings.language) {
i18n.changeLanguage(settings.language);
@@ -111,11 +112,30 @@ export const useSettingsStore = create<SettingsState>()(
},
setTheme: (theme) => set({ theme }),
setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void invokeIpc('settings:set', 'language', language).catch(() => {}); },
setLanguage: (language) => {
i18n.changeLanguage(language);
set({ language });
void hostApiFetch('/api/settings/language', {
method: 'PUT',
body: JSON.stringify({ value: language }),
}).catch(() => {});
},
setStartMinimized: (startMinimized) => set({ startMinimized }),
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void invokeIpc('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); },
setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void invokeIpc('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); },
setGatewayAutoStart: (gatewayAutoStart) => {
set({ gatewayAutoStart });
void hostApiFetch('/api/settings/gatewayAutoStart', {
method: 'PUT',
body: JSON.stringify({ value: gatewayAutoStart }),
}).catch(() => {});
},
setGatewayPort: (gatewayPort) => {
set({ gatewayPort });
void hostApiFetch('/api/settings/gatewayPort', {
method: 'PUT',
body: JSON.stringify({ value: gatewayPort }),
}).catch(() => {});
},
setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }),
setProxyServer: (proxyServer) => set({ proxyServer }),
setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }),

View File

@@ -3,8 +3,9 @@
* Manages skill/plugin state
*/
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway';
import type { Skill, MarketplaceSkill } from '../types/skill';
import { invokeIpc } from '@/lib/api-client';
type GatewaySkillStatus = {
skillKey: string;
@@ -24,12 +25,6 @@ type GatewaySkillsStatusResult = {
skills?: GatewaySkillStatus[];
};
type GatewayRpcResponse<T> = {
success: boolean;
result?: T;
error?: string;
};
type ClawHubListResult = {
slug: string;
version?: string;
@@ -71,27 +66,20 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}
try {
// 1. Fetch from Gateway (running skills)
const gatewayResult = await invokeIpc<GatewayRpcResponse<GatewaySkillsStatusResult>>(
'gateway:rpc',
'skills.status'
);
const gatewayData = await useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status');
// 2. Fetch from ClawHub (installed on disk)
const clawhubResult = await invokeIpc<{ success: boolean; results?: ClawHubListResult[]; error?: string }>(
'clawhub:list'
);
const clawhubResult = await hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list');
// 3. Fetch configurations directly from Electron (since Gateway doesn't return them)
const configResult = await invokeIpc<Record<string, { apiKey?: string; env?: Record<string, string> }>>(
'skill:getAllConfigs'
);
const configResult = await hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs');
let combinedSkills: Skill[] = [];
const currentSkills = get().skills;
// Map gateway skills info
if (gatewayResult.success && gatewayResult.result?.skills) {
combinedSkills = gatewayResult.result.skills.map((s: GatewaySkillStatus) => {
if (gatewayData.skills) {
combinedSkills = gatewayData.skills.map((s: GatewaySkillStatus) => {
// Merge with direct config if available
const directConfig = configResult[s.skillKey] || {};
@@ -156,7 +144,10 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
searchSkills: async (query: string) => {
set({ searching: true, searchError: null });
try {
const result = await invokeIpc<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('clawhub:search', { query });
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
if (result.success) {
set({ searchResults: result.results || [] });
} else {
@@ -178,7 +169,10 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
installSkill: async (slug: string, version?: string) => {
set((state) => ({ installing: { ...state.installing, [slug]: true } }));
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:install', { slug, version });
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', {
method: 'POST',
body: JSON.stringify({ slug, version }),
});
if (!result.success) {
if (result.error?.includes('Timeout')) {
throw new Error('installTimeoutError');
@@ -205,7 +199,10 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
uninstallSkill: async (slug: string) => {
set((state) => ({ installing: { ...state.installing, [slug]: true } }));
try {
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:uninstall', { slug });
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/uninstall', {
method: 'POST',
body: JSON.stringify({ slug }),
});
if (!result.success) {
throw new Error(result.error || 'Uninstall failed');
}
@@ -227,17 +224,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
const { updateSkill } = get();
try {
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
'gateway:rpc',
'skills.update',
{ skillKey: skillId, enabled: true }
);
if (result.success) {
updateSkill(skillId, { enabled: true });
} else {
throw new Error(result.error || 'Failed to enable skill');
}
await useGatewayStore.getState().rpc('skills.update', { skillKey: skillId, enabled: true });
updateSkill(skillId, { enabled: true });
} catch (error) {
console.error('Failed to enable skill:', error);
throw error;
@@ -253,17 +241,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}
try {
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
'gateway:rpc',
'skills.update',
{ skillKey: skillId, enabled: false }
);
if (result.success) {
updateSkill(skillId, { enabled: false });
} else {
throw new Error(result.error || 'Failed to disable skill');
}
await useGatewayStore.getState().rpc('skills.update', { skillKey: skillId, enabled: false });
updateSkill(skillId, { enabled: false });
} catch (error) {
console.error('Failed to disable skill:', error);
throw error;
@@ -279,4 +258,4 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
),
}));
},
}));
}));

View File

@@ -72,13 +72,12 @@ describe('provider metadata', () => {
);
});
it('only exposes OpenRouter and SiliconFlow model overrides in developer mode', () => {
it('exposes OpenRouter model overrides by default and keeps SiliconFlow developer-only', () => {
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
expect(openrouter).toMatchObject({
showModelId: true,
showModelIdInDevModeOnly: true,
defaultModelId: 'anthropic/claude-opus-4.6',
});
expect(siliconflow).toMatchObject({
@@ -87,23 +86,24 @@ describe('provider metadata', () => {
defaultModelId: 'deepseek-ai/DeepSeek-V3',
});
expect(shouldShowProviderModelId(openrouter, false)).toBe(false);
expect(shouldShowProviderModelId(openrouter, false)).toBe(true);
expect(shouldShowProviderModelId(siliconflow, false)).toBe(false);
expect(shouldShowProviderModelId(openrouter, true)).toBe(true);
expect(shouldShowProviderModelId(siliconflow, true)).toBe(true);
});
it('only saves OpenRouter and SiliconFlow model overrides in developer mode', () => {
it('saves OpenRouter model overrides by default and keeps SiliconFlow developer-only', () => {
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
const ark = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'ark');
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', false)).toBeUndefined();
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', false)).toBe('openai/gpt-5');
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', false)).toBeUndefined();
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', true)).toBe('openai/gpt-5');
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', true)).toBe('Qwen/Qwen3-Coder-480B-A35B-Instruct');
expect(resolveProviderModelForSave(openrouter, ' ', false)).toBe('anthropic/claude-opus-4.6');
expect(resolveProviderModelForSave(openrouter, ' ', true)).toBe('anthropic/claude-opus-4.6');
expect(resolveProviderModelForSave(siliconflow, ' ', true)).toBe('deepseek-ai/DeepSeek-V3');
expect(resolveProviderModelForSave(ark, ' ep-custom-model ', false)).toBe('ep-custom-model');

View File

@@ -1,7 +1,7 @@
/**
* Zustand Stores Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useSettingsStore } from '@/stores/settings';
import { useGatewayStore } from '@/stores/gateway';
@@ -72,4 +72,14 @@ describe('Gateway Store', () => {
expect(state.status.state).toBe('running');
expect(state.status.pid).toBe(12345);
});
it('should proxy gateway rpc through ipc', async () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
invoke.mockResolvedValueOnce({ success: true, result: { ok: true } });
const result = await useGatewayStore.getState().rpc<{ ok: boolean }>('chat.history', { limit: 10 }, 5000);
expect(result.ok).toBe(true);
expect(invoke).toHaveBeenCalledWith('gateway:rpc', 'chat.history', { limit: 10 }, 5000);
});
});