From 3c9cec9fb37d2dc64a8f67a3a2477f10ac6af884 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:51:44 +0800 Subject: [PATCH] refactor clawx --- electron/api/context.ts | 11 + electron/api/event-bus.ts | 36 ++ electron/api/route-utils.ts | 39 ++ electron/api/routes/app.ts | 29 ++ electron/api/routes/channels.ts | 167 +++++++ electron/api/routes/cron.ts | 168 +++++++ electron/api/routes/files.ts | 200 ++++++++ electron/api/routes/gateway.ts | 129 +++++ electron/api/routes/logs.ts | 29 ++ electron/api/routes/providers.ts | 440 ++++++++++++++++++ electron/api/routes/sessions.ts | 96 ++++ electron/api/routes/settings.ts | 98 ++++ electron/api/routes/skills.ts | 90 ++++ electron/api/routes/usage.ts | 20 + electron/api/server.ts | 60 +++ electron/gateway/event-dispatch.ts | 63 +++ electron/gateway/manager.ts | 122 +---- electron/gateway/request-store.ts | 42 ++ electron/main/index.ts | 65 +++ electron/preload/index.ts | 104 ----- electron/utils/config.ts | 3 + electron/utils/device-oauth.ts | 2 + src/components/layout/Sidebar.tsx | 5 +- src/components/settings/ProvidersSettings.tsx | 25 +- src/lib/gateway-client.ts | 239 ++++++++++ src/lib/host-api.ts | 42 ++ src/lib/host-events.ts | 25 + src/pages/Channels/index.tsx | 63 +-- src/pages/Chat/ChatInput.tsx | 26 +- src/pages/Dashboard/index.tsx | 7 +- src/pages/Settings/index.tsx | 20 +- src/pages/Setup/index.tsx | 65 +-- src/pages/Skills/index.tsx | 6 +- src/stores/channels.ts | 72 +-- src/stores/chat.ts | 87 ++-- src/stores/cron.ts | 29 +- src/stores/gateway.ts | 328 +++++++------ src/stores/providers.ts | 59 ++- src/stores/settings.ts | 28 +- src/stores/skills.ts | 66 +-- 40 files changed, 2567 insertions(+), 638 deletions(-) create mode 100644 electron/api/context.ts create mode 100644 electron/api/event-bus.ts create mode 100644 electron/api/route-utils.ts create mode 100644 electron/api/routes/app.ts create mode 100644 electron/api/routes/channels.ts create mode 100644 electron/api/routes/cron.ts create mode 100644 electron/api/routes/files.ts create mode 100644 electron/api/routes/gateway.ts create mode 100644 electron/api/routes/logs.ts create mode 100644 electron/api/routes/providers.ts create mode 100644 electron/api/routes/sessions.ts create mode 100644 electron/api/routes/settings.ts create mode 100644 electron/api/routes/skills.ts create mode 100644 electron/api/routes/usage.ts create mode 100644 electron/api/server.ts create mode 100644 electron/gateway/event-dispatch.ts create mode 100644 electron/gateway/request-store.ts create mode 100644 src/lib/gateway-client.ts create mode 100644 src/lib/host-api.ts create mode 100644 src/lib/host-events.ts diff --git a/electron/api/context.ts b/electron/api/context.ts new file mode 100644 index 0000000..0cdc726 --- /dev/null +++ b/electron/api/context.ts @@ -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; +} diff --git a/electron/api/event-bus.ts b/electron/api/event-bus.ts new file mode 100644 index 0000000..c7e442b --- /dev/null +++ b/electron/api/event-bus.ts @@ -0,0 +1,36 @@ +import type { ServerResponse } from 'http'; + +type EventPayload = unknown; + +export class HostEventBus { + private readonly clients = new Set(); + + 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(); + } +} diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts new file mode 100644 index 0000000..a0ea42b --- /dev/null +++ b/electron/api/route-utils.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage, ServerResponse } from 'http'; + +export async function parseJsonBody(req: IncomingMessage): Promise { + 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); +} diff --git a/electron/api/routes/app.ts b/electron/api/routes/app.ts new file mode 100644 index 0000000..92e0d79 --- /dev/null +++ b/electron/api/routes/app.ts @@ -0,0 +1,29 @@ +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 { + 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); + return true; + } + + if (req.method === 'OPTIONS') { + sendNoContent(res); + return true; + } + + return false; +} diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts new file mode 100644 index 0000000..aaded55 --- /dev/null +++ b/electron/api/routes/channels.ts @@ -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 { + 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 }>(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 }>(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; +} diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts new file mode 100644 index 0000000..0ab658a --- /dev/null +++ b/electron/api/routes/cron.ts @@ -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 { + 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>(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; +} diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts new file mode 100644 index 0000000..7931213 --- /dev/null +++ b/electron/api/routes/files.ts @@ -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 = { + '.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 { + 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 { + 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 = {}; + 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; +} diff --git a/electron/api/routes/gateway.ts b/electron/api/routes/gateway.ts new file mode 100644 index 0000000..530e030 --- /dev/null +++ b/electron/api/routes/gateway.ts @@ -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 { + 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 = { + 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; +} diff --git a/electron/api/routes/logs.ts b/electron/api/routes/logs.ts new file mode 100644 index 0000000..6cbc039 --- /dev/null +++ b/electron/api/routes/logs.ts @@ -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 { + 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; +} diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts new file mode 100644 index 0000000..50de6cf --- /dev/null +++ b/electron/api/routes/providers.ts @@ -0,0 +1,440 @@ +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, + getProviderDefaultModel, +} from '../../utils/provider-registry'; +import { + removeProviderFromOpenClaw, + saveProviderKeyToOpenClaw, + setOpenClawDefaultModel, + setOpenClawDefaultModelWithOverride, + syncProviderConfigToOpenClaw, + updateAgentModelProvider, +} from '../../utils/openclaw-auth'; +import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; +import { proxyAwareFetch } from '../../utils/proxy-fetch'; + +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; +} + +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}`; + } + return getProviderDefaultModel(config.type); +} + +async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { + const allProviders = await getAllProvidersWithKeyInfo(); + const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); + const seen = new Set(); + 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; +} + +type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'openrouter' | 'none'; + +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'; + } +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +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; + return { valid: false, error: obj?.error?.message || obj?.message || `API error: ${status}` }; +} + +async function performProviderValidationRequest( + url: string, + headers: Record, +): Promise<{ valid: boolean; error?: string }> { + try { + const response = await proxyAwareFetch(url, { headers }); + 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)}` }; + } +} + +async function performChatCompletionsProbe( + url: string, + headers: Record, +): Promise<{ valid: boolean; error?: string }> { + try { + 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, + }), + }); + 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 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' }; + + switch (profile) { + case 'openai-compatible': { + const trimmedBaseUrl = options?.baseUrl?.trim(); + if (!trimmedBaseUrl) { + return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; + } + const headers = { Authorization: `Bearer ${trimmedKey}` }; + const modelsUrl = `${normalizeBaseUrl(trimmedBaseUrl)}/models?limit=1`; + const modelsResult = await performProviderValidationRequest(modelsUrl, headers); + if (modelsResult.error?.includes('API error: 404')) { + return performChatCompletionsProbe(`${normalizeBaseUrl(trimmedBaseUrl)}/chat/completions`, headers); + } + return modelsResult; + } + case 'google-query-key': { + const base = normalizeBaseUrl(options?.baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); + return performProviderValidationRequest(`${base}/models?pageSize=1&key=${encodeURIComponent(trimmedKey)}`, {}); + } + case 'anthropic-header': { + const base = normalizeBaseUrl(options?.baseUrl || 'https://api.anthropic.com/v1'); + return performProviderValidationRequest(`${base}/models?limit=1`, { + 'x-api-key': trimmedKey, + 'anthropic-version': '2023-06-01', + }); + } + case 'openrouter': + return performProviderValidationRequest('https://openrouter.ai/api/v1/auth/key', { + Authorization: `Bearer ${trimmedKey}`, + }); + default: + return { valid: false, error: `Unsupported provider validation profile: ${providerType}` }; + } +} + +export async function handleProviderRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + 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); + const provider = await getProvider(body.providerId); + if (provider) { + const ock = getOpenClawProviderKey(provider.type, body.providerId); + const providerKey = await getApiKey(body.providerId); + const fallbackModels = await getProviderFallbackModelRefs(provider); + const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; + const isOAuthProvider = oauthTypes.includes(provider.type) && !providerKey; + 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 { + 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'); + 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: targetProviderKey === 'minimax-portal' ? 'anthropic-messages' : 'openai-completions', + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }, fallbackModels); + } + if (ctx.gatewayManager.getStatus().state !== 'stopped') { + ctx.gatewayManager.debouncedRestart(); + } + } + 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; region?: 'global' | 'cn' }>(req); + await deviceOAuthManager.startFlow(body.provider, body.region); + 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(); + 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); + const ock = getOpenClawProviderKey(config.type, config.id); + if (body.apiKey !== undefined) { + const trimmedKey = body.apiKey.trim(); + if (trimmedKey) { + await storeApiKey(config.id, trimmedKey); + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } + } + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + if (api) { + 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 = body.apiKey !== undefined ? (body.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, + }); + } + } + ctx.gatewayManager.debouncedRestart(); + } + 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; 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() }; + const ock = getOpenClawProviderKey(nextConfig.type, providerId); + await saveProvider(nextConfig); + if (body.apiKey !== undefined) { + const trimmedKey = body.apiKey.trim(); + if (trimmedKey) { + await storeApiKey(providerId, trimmedKey); + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } else { + await deleteApiKey(providerId); + await removeProviderFromOpenClaw(ock); + } + } + const fallbackModels = await getProviderFallbackModelRefs(nextConfig); + const meta = getProviderConfig(nextConfig.type); + const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; + if (api) { + await syncProviderConfigToOpenClaw(ock, nextConfig.model, { + baseUrl: nextConfig.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId === providerId) { + const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined; + if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } else { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: nextConfig.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } + } + ctx.gatewayManager.debouncedRestart(); + } + 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); + if (existing?.type) { + await removeProviderFromOpenClaw(getOpenClawProviderKey(existing.type, providerId)); + } + sendJson(res, 200, { success: true }); + return true; + } + await deleteProvider(providerId); + if (existing?.type) { + await removeProviderFromOpenClaw(getOpenClawProviderKey(existing.type, providerId)); + ctx.gatewayManager.debouncedRestart(); + } + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/sessions.ts b/electron/api/routes/sessions.ts new file mode 100644 index 0000000..ed2dfa7 --- /dev/null +++ b/electron/api/routes/sessions.ts @@ -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 { + 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; + + let uuidFileName: string | undefined; + let resolvedSrcPath: string | undefined; + if (Array.isArray(sessionsJson.sessions)) { + const entry = (sessionsJson.sessions as Array>) + .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; + 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; + if (Array.isArray(json2.sessions)) { + json2.sessions = (json2.sessions as Array>) + .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; +} diff --git a/electron/api/routes/settings.ts b/electron/api/routes/settings.ts new file mode 100644 index 0000000..b6be41a --- /dev/null +++ b/electron/api/routes/settings.ts @@ -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 { + const settings = await getAllSettings(); + await applyProxySettings(settings); + if (ctx.gatewayManager.getStatus().state === 'running') { + await ctx.gatewayManager.restart(); + } +} + +function patchTouchesProxy(patch: Partial): 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 { + 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>(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; +} diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts new file mode 100644 index 0000000..7984d5a --- /dev/null +++ b/electron/api/routes/skills.ts @@ -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 { + 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; + }>(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>(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>(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>(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; +} diff --git a/electron/api/routes/usage.ts b/electron/api/routes/usage.ts new file mode 100644 index 0000000..d72be8d --- /dev/null +++ b/electron/api/routes/usage.ts @@ -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 { + 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; +} diff --git a/electron/api/server.ts b/electron/api/server.ts new file mode 100644 index 0000000..5fe1f35 --- /dev/null +++ b/electron/api/server.ts @@ -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; + +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; +} diff --git a/electron/gateway/event-dispatch.ts b/electron/gateway/event-dispatch.ts new file mode 100644 index 0000000..c5af9da --- /dev/null +++ b/electron/gateway/event-dispatch.ts @@ -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; + const data = (p.data && typeof p.data === 'object') ? p.data as Record : {}; + const chatEvent: Record = { + ...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}`); + } +} diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 610d63f..043ec43 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -17,7 +17,7 @@ import { import { getAllSettings, getSetting } from '../utils/store'; import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; -import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; +import { JsonRpcNotification, isNotification, isResponse } from './protocol'; import { logger } from '../utils/logger'; import { getUvMirrorEnv } from '../utils/uv-env'; import { isPythonReady, setupManagedPython } from '../utils/uv-setup'; @@ -40,6 +40,13 @@ import { nextLifecycleEpoch, shouldDeferRestart, } from './process-policy'; +import { + clearPendingGatewayRequests, + rejectPendingGatewayRequest, + resolvePendingGatewayRequest, + type PendingGatewayRequest, +} from './request-store'; +import { dispatchJsonRpcNotification, dispatchProtocolEvent } from './event-dispatch'; /** * Gateway connection status @@ -213,11 +220,7 @@ export class GatewayManager extends EventEmitter { private startLock = false; private lastSpawnSummary: string | null = null; private recentStartupStderrLines: string[] = []; - private pendingRequests: Map void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - }> = new Map(); + private pendingRequests: Map = new Map(); private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; private lifecycleEpoch = 0; @@ -580,12 +583,7 @@ export class GatewayManager extends EventEmitter { } this.ownsProcess = false; - // Reject all pending requests - for (const [, request] of this.pendingRequests) { - clearTimeout(request.timeout); - request.reject(new Error('Gateway stopped')); - } - this.pendingRequests.clear(); + clearPendingGatewayRequests(this.pendingRequests, new Error('Gateway stopped')); this.deferredRestartPending = false; this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined }); @@ -677,8 +675,7 @@ export class GatewayManager extends EventEmitter { // Set timeout for request const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject(new Error(`RPC timeout: ${method}`)); + rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`RPC timeout: ${method}`)); }, timeoutMs); // Store pending request @@ -699,9 +696,7 @@ export class GatewayManager extends EventEmitter { try { this.ws.send(JSON.stringify(request)); } catch (error) { - this.pendingRequests.delete(id); - clearTimeout(timeout); - reject(new Error(`Failed to send RPC request: ${error}`)); + rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`Failed to send RPC request: ${error}`)); } }); } @@ -1565,118 +1560,45 @@ export class GatewayManager extends EventEmitter { // Handle OpenClaw protocol response format: { type: "res", id: "...", ok: true/false, ... } if (msg.type === 'res' && typeof msg.id === 'string') { - if (this.pendingRequests.has(msg.id)) { - const request = this.pendingRequests.get(msg.id)!; - clearTimeout(request.timeout); - this.pendingRequests.delete(msg.id); - - if (msg.ok === false || msg.error) { - const errorObj = msg.error as { message?: string; code?: number } | undefined; - const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error'; - request.reject(new Error(errorMsg)); - } else { - request.resolve(msg.payload ?? msg); + if (msg.ok === false || msg.error) { + const errorObj = msg.error as { message?: string; code?: number } | undefined; + const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error'; + if (rejectPendingGatewayRequest(this.pendingRequests, msg.id, new Error(errorMsg))) { + return; } + } else if (resolvePendingGatewayRequest(this.pendingRequests, msg.id, msg.payload ?? msg)) { return; } } // Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} } if (msg.type === 'event' && typeof msg.event === 'string') { - this.handleProtocolEvent(msg.event, msg.payload); + dispatchProtocolEvent(this, msg.event, msg.payload); return; } // Fallback: Check if this is a JSON-RPC 2.0 response (legacy support) if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) { - const request = this.pendingRequests.get(String(message.id))!; - clearTimeout(request.timeout); - this.pendingRequests.delete(String(message.id)); - if (message.error) { const errorMsg = typeof message.error === 'object' ? (message.error as { message?: string }).message || JSON.stringify(message.error) : String(message.error); - request.reject(new Error(errorMsg)); + rejectPendingGatewayRequest(this.pendingRequests, String(message.id), new Error(errorMsg)); } else { - request.resolve(message.result); + resolvePendingGatewayRequest(this.pendingRequests, String(message.id), message.result); } return; } // Check if this is a JSON-RPC notification (server-initiated event) if (isNotification(message)) { - this.handleNotification(message); + dispatchJsonRpcNotification(this, message); return; } this.emit('message', message); } - /** - * Handle OpenClaw protocol events - */ - private handleProtocolEvent(event: string, payload: unknown): void { - switch (event) { - case 'tick': - break; - case 'chat': - this.emit('chat:message', { message: payload }); - break; - case 'agent': { - // Agent events may carry chat streaming data inside payload.data, - // or be lifecycle events (phase=started/completed) with no message. - const p = payload as Record; - const data = (p.data && typeof p.data === 'object') ? p.data as Record : {}; - const chatEvent: Record = { - ...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) { - this.emit('chat:message', { message: chatEvent }); - } - this.emit('notification', { method: event, params: payload }); - break; - } - case 'channel.status': - this.emit('channel:status', payload as { channelId: string; status: string }); - break; - default: - this.emit('notification', { method: event, params: payload }); - } - } - - /** - * Handle server-initiated notifications - */ - private handleNotification(notification: JsonRpcNotification): void { - this.emit('notification', notification); - - // Route specific events - switch (notification.method) { - case GatewayEventType.CHANNEL_STATUS_CHANGED: - this.emit('channel:status', notification.params as { channelId: string; status: string }); - break; - - case GatewayEventType.MESSAGE_RECEIVED: - this.emit('chat:message', notification.params as { message: unknown }); - break; - - case GatewayEventType.ERROR: { - const errorData = notification.params as { message?: string }; - this.emit('error', new Error(errorData.message || 'Gateway error')); - break; - } - - default: - // Unknown notification type, just log it - logger.debug(`Unknown Gateway notification: ${notification.method}`); - } - } - /** * Start ping interval to keep connection alive */ diff --git a/electron/gateway/request-store.ts b/electron/gateway/request-store.ts new file mode 100644 index 0000000..bf96f9e --- /dev/null +++ b/electron/gateway/request-store.ts @@ -0,0 +1,42 @@ +export interface PendingGatewayRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +} + +export function clearPendingGatewayRequests( + pendingRequests: Map, + error: Error, +): void { + for (const [, request] of pendingRequests) { + clearTimeout(request.timeout); + request.reject(error); + } + pendingRequests.clear(); +} + +export function resolvePendingGatewayRequest( + pendingRequests: Map, + 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, + 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; +} diff --git a/electron/main/index.ts b/electron/main/index.ts index b387c40..2e150d7 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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,10 @@ 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 { whatsAppLoginManager } from '../utils/whatsapp-login'; // Disable GPU hardware acceleration globally for maximum stability across // all GPU configurations (no GPU, integrated, discrete). @@ -58,6 +63,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 +192,13 @@ async function initialize(): Promise { // Register IPC handlers registerIpcHandlers(gatewayManager, clawHubService, mainWindow); + hostApiServer = startHostApiServer({ + gatewayManager, + clawHubService, + eventBus: hostEventBus, + mainWindow, + }); + // Register update handlers registerUpdateHandlers(appUpdater, mainWindow); @@ -251,12 +265,61 @@ async function initialize(): Promise { // 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 }) => { + 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', (provider) => { + hostEventBus.emit('oauth:success', { provider, success: true }); + }); + + deviceOAuthManager.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); + }); } // When a second instance is launched, focus the existing window instead. @@ -293,6 +356,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) => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f0e7fc3..67bd06c 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -14,15 +14,6 @@ const electronAPI = { ipcRenderer: { invoke: (channel: string, ...args: unknown[]) => { const validChannels = [ - // Gateway - 'gateway:status', - 'gateway:isConnected', - 'gateway:start', - 'gateway:stop', - 'gateway:restart', - 'gateway:rpc', - 'gateway:health', - 'gateway:getControlUiUrl', // OpenClaw 'openclaw:status', 'openclaw:isReady', @@ -46,13 +37,6 @@ const electronAPI = { 'window:maximize', 'window:close', 'window:isMaximized', - // Settings - 'settings:get', - 'settings:set', - 'settings:setMany', - 'settings:getAll', - 'settings:reset', - 'usage:recentTokenHistory', // Update 'update:status', 'update:version', @@ -62,73 +46,9 @@ const electronAPI = { 'update:setChannel', 'update:setAutoDownload', 'update:cancelAutoInstall', - // Env - 'env:getConfig', - 'env:setApiKey', - 'env:deleteApiKey', - // Provider - 'provider:list', - 'provider:get', - 'provider:save', - 'provider:delete', - 'provider:setApiKey', - 'provider:updateWithKey', - 'provider:deleteApiKey', - 'provider:hasApiKey', - 'provider:getApiKey', - 'provider:setDefault', - 'provider:getDefault', - 'provider:validateKey', - 'provider:requestOAuth', - 'provider:cancelOAuth', - // Cron - 'cron:list', - 'cron:create', - 'cron:update', - 'cron:delete', - 'cron:toggle', - 'cron:trigger', - // Channel Config - 'channel:saveConfig', - 'channel:getConfig', - 'channel:getFormValues', - 'channel:deleteConfig', - 'channel:listConfigured', - 'channel:setEnabled', - 'channel:validate', - 'channel:validate', - 'channel:validateCredentials', - // WhatsApp - 'channel:requestWhatsAppQr', - 'channel:cancelWhatsAppQr', - // ClawHub - 'clawhub:search', - 'clawhub:install', - 'clawhub:uninstall', - 'clawhub:list', - 'clawhub:openSkillReadme', // UV 'uv:check', 'uv:install-all', - // Skill config (direct file access) - 'skill:updateConfig', - 'skill:getConfig', - 'skill:getAllConfigs', - // Logs - 'log:getRecent', - 'log:readFile', - 'log:getFilePath', - 'log:getDir', - 'log:listFiles', - // File staging & media - 'file:stage', - 'file:stageBuffer', - 'media:getThumbnails', - 'media:saveImage', - // Chat send with media (reads staged files in main process) - 'chat:sendWithMedia', - // Session management - 'session:delete', // OpenClaw extras 'openclaw:getDir', 'openclaw:getConfigDir', @@ -148,16 +68,6 @@ const electronAPI = { */ on: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ - 'gateway:status-changed', - 'gateway:message', - 'gateway:notification', - 'gateway:channel-status', - 'gateway:chat-message', - 'channel:whatsapp-qr', - 'channel:whatsapp-success', - 'channel:whatsapp-error', - 'gateway:exit', - 'gateway:error', 'navigate', 'update:status-changed', 'update:checking', @@ -167,10 +77,6 @@ const electronAPI = { 'update:downloaded', 'update:error', 'update:auto-install-countdown', - 'cron:updated', - 'oauth:code', - 'oauth:success', - 'oauth:error', 'openclaw:cli-installed', ]; @@ -195,13 +101,6 @@ const electronAPI = { */ once: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ - 'gateway:status-changed', - 'gateway:message', - 'gateway:notification', - 'gateway:channel-status', - 'gateway:chat-message', - 'gateway:exit', - 'gateway:error', 'navigate', 'update:status-changed', 'update:checking', @@ -211,9 +110,6 @@ const electronAPI = { 'update:downloaded', 'update:error', 'update:auto-install-countdown', - 'oauth:code', - 'oauth:success', - 'oauth:error', ]; if (validChannels.includes(channel)) { diff --git a/electron/utils/config.ts b/electron/utils/config.ts index 0e837c0..d221c97 100644 --- a/electron/utils/config.ts +++ b/electron/utils/config.ts @@ -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, diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts index 213be03..1287282 100644 --- a/electron/utils/device-oauth.ts +++ b/electron/utils/device-oauth.ts @@ -331,12 +331,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 }); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9337031..0fba8cd 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useTranslation } from 'react-i18next'; +import { hostApiFetch } from '@/lib/host-api'; interface NavItemProps { to: string; @@ -90,11 +91,11 @@ export function Sidebar() { const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('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 { diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index edb599c..2963ff5 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -38,6 +38,8 @@ import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; 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))); @@ -674,16 +676,14 @@ function AddProviderDialog({ setOauthData(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(); }; }, []); @@ -704,7 +704,10 @@ function AddProviderDialog({ setOauthError(null); try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType); + await hostApiFetch('/api/providers/oauth/start', { + method: 'POST', + body: JSON.stringify({ provider: selectedType }), + }); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -715,7 +718,9 @@ function AddProviderDialog({ setOauthFlowing(false); setOauthData(null); setOauthError(null); - await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); + await hostApiFetch('/api/providers/oauth/cancel', { + method: 'POST', + }); }; // Only custom can be added multiple times. diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts new file mode 100644 index 0000000..021164d --- /dev/null +++ b/src/lib/gateway-client.ts @@ -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; +}; + +type GatewayEventHandler = (payload: unknown) => void; + +class GatewayBrowserClient { + private ws: WebSocket | null = null; + private connectPromise: Promise | null = null; + private gatewayInfo: GatewayInfo | null = null; + private pendingRequests = new Map(); + private eventHandlers = new Map>(); + + async connect(): Promise { + 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(method: string, params?: unknown, timeoutMs = 30000): Promise { + 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((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(); + 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 { + this.gatewayInfo = await hostApiFetch('/api/app/gateway-info'); + + await new Promise((resolve, reject) => { + const ws = new WebSocket(this.gatewayInfo!.wsUrl); + let resolved = false; + let challengeTimer: ReturnType | 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; + 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(); diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts new file mode 100644 index 0000000..8c8cf14 --- /dev/null +++ b/src/lib/host-api.ts @@ -0,0 +1,42 @@ +const HOST_API_PORT = 3210; +const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; + +async function parseResponse(response: Response): Promise { + 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(path: string, init?: RequestInit): Promise { + const response = await fetch(`${HOST_API_BASE}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, + }); + return parseResponse(response); +} + +export function createHostEventSource(path = '/api/events'): EventSource { + return new EventSource(`${HOST_API_BASE}${path}`); +} + +export function getHostApiBase(): string { + return HOST_API_BASE; +} diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts new file mode 100644 index 0000000..35f9c3a --- /dev/null +++ b/src/lib/host-events.ts @@ -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( + 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); + }; +} diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 16b53c9..a25d796 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -33,6 +33,8 @@ 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 { CHANNEL_ICONS, CHANNEL_NAMES, @@ -64,10 +66,10 @@ export function Channels() { // Fetch configured channel types from config file const fetchConfiguredTypes = useCallback(async () => { try { - const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as { + const result = await hostApiFetch<{ success: boolean; channels?: string[]; - }; + }>('/api/channels/configured'); if (result.success && result.channels) { setConfiguredTypes(result.channels); } @@ -82,7 +84,7 @@ export function Channels() { }, [fetchConfiguredTypes]); useEffect(() => { - const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => { + const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { fetchChannels(); fetchConfiguredTypes(); }); @@ -382,7 +384,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded setChannelName(''); setIsExistingConfig(false); // Ensure we clean up any pending QR session if switching away - window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { }); + hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); return; } @@ -439,11 +441,10 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded toast.success(t('toast.whatsappConnected')); const accountId = data?.accountId || channelName.trim() || 'default'; try { - const saveResult = await window.electron.ipcRenderer.invoke( - '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 { @@ -458,7 +459,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded name: channelName || 'WhatsApp', }).then(() => { // Restart gateway to pick up the new session - window.electron.ipcRenderer.invoke('gateway:restart').catch(console.error); + useGatewayStore.getState().restart().catch(console.error); onChannelAdded(); }); }; @@ -471,16 +472,16 @@ 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 - window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { }); + hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); }; }, [selectedType, addChannel, channelName, onChannelAdded, t]); @@ -491,17 +492,16 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded setValidationResult(null); try { - const result = await window.electron.ipcRenderer.invoke( - 'channel:validateCredentials', - selectedType, - configValues - ) as { + const result = await hostApiFetch<{ success: boolean; valid?: boolean; errors?: string[]; warnings?: string[]; details?: Record; - }; + }>('/api/channels/credentials/validate', { + method: 'POST', + body: JSON.stringify({ channelType: selectedType, config: configValues }), + }); const warnings = result.warnings || []; if (result.valid && result.details) { @@ -538,24 +538,26 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded // For QR-based channels, request QR code if (meta.connectionType === 'qr') { const accountId = channelName.trim() || 'default'; - await window.electron.ipcRenderer.invoke('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 window.electron.ipcRenderer.invoke( - 'channel:validateCredentials', - selectedType, - configValues - ) as { + const validationResponse = await hostApiFetch<{ success: boolean; valid?: boolean; errors?: string[]; warnings?: string[]; details?: Record; - }; + }>('/api/channels/credentials/validate', { + method: 'POST', + body: JSON.stringify({ channelType: selectedType, config: configValues }), + }); if (!validationResponse.valid) { setValidationResult({ @@ -592,12 +594,15 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded // Step 2: Save channel configuration via IPC const config: Record = { ...configValues }; - const saveResult = await window.electron.ipcRenderer.invoke('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'); } diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index b21f477..dcb4908 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -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'; // ── Types ──────────────────────────────────────────────────────── @@ -125,17 +126,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 window.electron.ipcRenderer.invoke( - 'file:stage', - result.filePaths, - ) as Array<{ + const staged = await hostApiFetch; + }>>('/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 @@ -192,18 +193,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 window.electron.ipcRenderer.invoke('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, diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index aec779c..18d5ad8 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -26,6 +26,7 @@ import { useSkillsStore } from '@/stores/skills'; import { useSettingsStore } from '@/stores/settings'; import { StatusBadge } from '@/components/common/StatusBadge'; import { useTranslation } from 'react-i18next'; +import { hostApiFetch } from '@/lib/host-api'; type UsageHistoryEntry = { timestamp: string; @@ -63,7 +64,7 @@ export function Dashboard() { if (isGatewayRunning) { fetchChannels(); fetchSkills(); - window.electron.ipcRenderer.invoke('usage:recentTokenHistory') + hostApiFetch('/api/usage/recent-token-history') .then((entries) => { setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []); setUsagePage(1); @@ -107,11 +108,11 @@ export function Dashboard() { const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('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 { diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index dfc10d8..2c30f01 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -30,6 +30,7 @@ import { ProvidersSettings } from '@/components/settings/ProvidersSettings'; import { UpdateSettings } from '@/components/settings/UpdateSettings'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; +import { hostApiFetch } from '@/lib/host-api'; type ControlUiInfo = { url: string; token: string; @@ -86,8 +87,8 @@ export function Settings() { const handleShowLogs = async () => { try { - const logs = await window.electron.ipcRenderer.invoke('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)'); @@ -97,7 +98,7 @@ export function Settings() { const handleOpenLogDir = async () => { try { - const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string; + const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir'); if (logDir) { await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); } @@ -109,13 +110,13 @@ export function Settings() { // Open developer console const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await hostApiFetch<{ success: boolean; url?: string; token?: string; port?: number; error?: string; - }; + }>('/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 }); window.electron.openExternal(result.url); @@ -129,12 +130,12 @@ export function Settings() { const refreshControlUiInfo = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await hostApiFetch<{ success: boolean; url?: string; token?: string; port?: number; - }; + }>('/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 }); } @@ -235,13 +236,16 @@ export function Settings() { const normalizedHttpsServer = proxyHttpsServerDraft.trim(); const normalizedAllServer = proxyAllServerDraft.trim(); const normalizedBypassRules = proxyBypassRulesDraft.trim(); - await window.electron.ipcRenderer.invoke('settings:setMany', { + await hostApiFetch('/api/settings', { + method: 'PUT', + body: JSON.stringify({ proxyEnabled: proxyEnabledDraft, proxyServer: normalizedProxyServer, proxyHttpServer: normalizedHttpServer, proxyHttpsServer: normalizedHttpsServer, proxyAllServer: normalizedAllServer, proxyBypassRules: normalizedBypassRules, + }), }); setProxyServer(normalizedProxyServer); diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index c0f1d11..0f63848 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -31,6 +31,8 @@ import { useSettingsStore } from '@/stores/settings'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; import { toast } from 'sonner'; +import { hostApiFetch } from '@/lib/host-api'; +import { subscribeHostEvent } from '@/lib/host-events'; interface SetupStep { id: string; title: string; @@ -526,8 +528,8 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const handleShowLogs = async () => { try { - const logs = await window.electron.ipcRenderer.invoke('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)'); @@ -537,7 +539,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const handleOpenLogDir = async () => { try { - const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string; + const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir'); if (logDir) { await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); } @@ -727,7 +729,10 @@ function ProviderContent({ if (selectedProvider) { try { - await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider); + await hostApiFetch('/api/providers/default', { + method: 'PUT', + body: JSON.stringify({ providerId: selectedProvider }), + }); } catch (error) { console.error('Failed to set default provider:', error); } @@ -742,18 +747,14 @@ function ProviderContent({ setOauthData(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]); @@ -761,7 +762,7 @@ function ProviderContent({ if (!selectedProvider) return; try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>; + const list = await hostApiFetch>('/api/providers'); const existingTypes = new Set(list.map(l => l.type)); if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); @@ -780,7 +781,10 @@ function ProviderContent({ setOauthError(null); try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider); + await hostApiFetch('/api/providers/oauth/start', { + method: 'POST', + body: JSON.stringify({ provider: selectedProvider }), + }); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -791,7 +795,7 @@ function ProviderContent({ setOauthFlowing(false); setOauthData(null); setOauthError(null); - await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); + await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' }); }; // On mount, try to restore previously configured provider @@ -799,8 +803,9 @@ function ProviderContent({ let cancelled = false; (async () => { try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; - const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; + const list = await hostApiFetch>('/api/providers'); + const defaultInfo = await hostApiFetch<{ providerId: string | null }>('/api/providers/default'); + const defaultId = defaultInfo.providerId; const setupProviderTypes = new Set(providers.map((p) => p.id)); const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type)); const preferred = @@ -813,7 +818,9 @@ function ProviderContent({ const typeInfo = providers.find((p) => p.id === preferred.type); const requiresKey = typeInfo?.requiresApiKey ?? false; onConfiguredChange(!requiresKey || preferred.hasKey); - const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', preferred.id) as string | null; + const storedKey = (await hostApiFetch<{ apiKey: string | null }>( + `/api/providers/${encodeURIComponent(preferred.id)}/api-key`, + )).apiKey; if (storedKey) { onApiKeyChange(storedKey); } @@ -835,8 +842,9 @@ function ProviderContent({ (async () => { if (!selectedProvider) return; try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; - const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; + const list = await hostApiFetch>('/api/providers'); + const defaultInfo = await hostApiFetch<{ providerId: string | null }>('/api/providers/default'); + const defaultId = defaultInfo.providerId; const sameType = list.filter((p) => p.type === selectedProvider); const preferredInstance = (defaultId && sameType.find((p) => p.id === defaultId)) @@ -845,11 +853,12 @@ function ProviderContent({ const providerIdForLoad = preferredInstance?.id || selectedProvider; setSelectedProviderConfigId(providerIdForLoad); - const savedProvider = await window.electron.ipcRenderer.invoke( - 'provider:get', - providerIdForLoad - ) as { baseUrl?: string; model?: string } | null; - const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', providerIdForLoad) as string | null; + const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string } | null>( + `/api/providers/${encodeURIComponent(providerIdForLoad)}`, + ); + const storedKey = (await hostApiFetch<{ apiKey: string | null }>( + `/api/providers/${encodeURIComponent(providerIdForLoad)}/api-key`, + )).apiKey; if (!cancelled) { if (storedKey) { onApiKeyChange(storedKey); @@ -906,7 +915,7 @@ function ProviderContent({ if (!selectedProvider) return; try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>; + const list = await hostApiFetch>('/api/providers'); const existingTypes = new Set(list.map(l => l.type)); if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index 0a79c0e..8f7cc28 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -41,6 +41,7 @@ import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import type { Skill, MarketplaceSkill } from '@/types/skill'; import { useTranslation } from 'react-i18next'; +import { hostApiFetch } from '@/lib/host-api'; @@ -91,7 +92,10 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) const handleOpenEditor = async () => { if (skill.slug) { try { - const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string }; + 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 { diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 4121b3d..c38e370 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -3,6 +3,8 @@ * 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'; interface AddChannelParams { @@ -36,13 +38,7 @@ export const useChannelsStore = create((set, get) => ({ fetchChannels: async () => { set({ loading: true, error: null }); try { - const result = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'channels.status', - { probe: true } - ) as { - success: boolean; - result?: { + const data = await useGatewayStore.getState().rpc<{ channelOrder?: string[]; channels?: Record; channelAccounts?: Record((set, get) => ({ lastOutboundAt?: number | null; }>>; channelDefaultAccountId?: Record; - }; - 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 @@ -139,17 +131,13 @@ export const useChannelsStore = create((set, get) => ({ addChannel: async (params) => { try { - const result = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'channels.add', - params - ) as { success: boolean; result?: Channel; error?: string }; + const result = await useGatewayStore.getState().rpc('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 = { @@ -184,17 +172,15 @@ export const useChannelsStore = create((set, get) => ({ try { // Delete the channel configuration from openclaw.json - await window.electron.ipcRenderer.invoke('channel:deleteConfig', channelType); + await hostApiFetch(`/api/channels/config/${encodeURIComponent(channelType)}`, { + method: 'DELETE', + }); } catch (error) { console.error('Failed to delete channel config:', error); } try { - await window.electron.ipcRenderer.invoke( - '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); @@ -211,17 +197,8 @@ export const useChannelsStore = create((set, get) => ({ updateChannel(channelId, { status: 'connecting', error: undefined }); try { - const result = await window.electron.ipcRenderer.invoke( - '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) }); } @@ -231,11 +208,7 @@ export const useChannelsStore = create((set, get) => ({ const { updateChannel } = get(); try { - await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'channels.disconnect', - { channelId } - ); + await useGatewayStore.getState().rpc('channels.disconnect', { channelId }); } catch (error) { console.error('Failed to disconnect channel:', error); } @@ -244,17 +217,10 @@ export const useChannelsStore = create((set, get) => ({ }, requestQrCode: async (channelType) => { - const result = await window.electron.ipcRenderer.invoke( - '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 }), diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 78b9dfd..1ceab93 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1,9 +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 { hostApiFetch } from '@/lib/host-api'; +import { useGatewayStore } from './gateway'; // ── Types ──────────────────────────────────────────────────────── @@ -596,10 +598,13 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { if (needPreview.length === 0) return false; try { - const thumbnails = await window.electron.ipcRenderer.invoke( - 'media:getThumbnails', - needPreview, - ) as Record; + const thumbnails = await hostApiFetch>( + '/api/files/thumbnails', + { + method: 'POST', + body: JSON.stringify({ paths: needPreview }), + }, + ); let updated = false; for (const msg of messages) { @@ -928,14 +933,8 @@ export const useChatStore = create((set, get) => ({ loadSessions: async () => { try { - const result = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'sessions.list', - {} - ) as { success: boolean; result?: Record; error?: string }; - - if (result.success && result.result) { - const data = result.result; + const data = await useGatewayStore.getState().rpc>('sessions.list', {}); + if (data) { const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; const sessions: ChatSession[] = rawSessions.map((s: Record) => ({ key: String(s.key || ''), @@ -1001,13 +1000,11 @@ export const useChatStore = create((set, get) => ({ void Promise.all( sessionsToLabel.map(async (session) => { try { - const r = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', + const r = await useGatewayStore.getState().rpc>( 'chat.history', { sessionKey: session.key, limit: 1000 }, - ) as { success: boolean; result?: Record }; - 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) => { @@ -1077,10 +1074,13 @@ export const useChatStore = create((set, get) => ({ // The main process renames .jsonl → .deleted.jsonl so that // sessions.list and token-usage queries both skip it automatically. try { - const result = await window.electron.ipcRenderer.invoke('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); } @@ -1185,14 +1185,11 @@ export const useChatStore = create((set, get) => ({ if (!quiet) set({ loading: true, error: null }); try { - const result = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', + const data = await useGatewayStore.getState().rpc>( 'chat.history', - { sessionKey: currentSessionKey, limit: 200 } - ) as { success: boolean; result?: Record; 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 @@ -1425,23 +1422,25 @@ export const useChatStore = create((set, get) => ({ const CHAT_SEND_TIMEOUT_MS = 120_000; if (hasMedia) { - result = await window.electron.ipcRenderer.invoke( - '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 window.electron.ipcRenderer.invoke( - 'gateway:rpc', + const rpcResult = await useGatewayStore.getState().rpc<{ runId?: string }>( 'chat.send', { sessionKey: currentSessionKey, @@ -1450,7 +1449,8 @@ export const useChatStore = create((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'}`); @@ -1477,8 +1477,7 @@ export const useChatStore = create((set, get) => ({ set({ streamingTools: [] }); try { - await window.electron.ipcRenderer.invoke( - 'gateway:rpc', + await useGatewayStore.getState().rpc( 'chat.abort', { sessionKey: currentSessionKey }, ); diff --git a/src/stores/cron.ts b/src/stores/cron.ts index bd4d3b0..cec83c7 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -3,6 +3,7 @@ * Manages scheduled task state */ import { create } from 'zustand'; +import { hostApiFetch } from '@/lib/host-api'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; interface CronState { @@ -29,7 +30,7 @@ export const useCronStore = create((set) => ({ set({ loading: true, error: null }); try { - const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[]; + const result = await hostApiFetch('/api/cron/jobs'); set({ jobs: result, loading: false }); } catch (error) { set({ error: String(error), loading: false }); @@ -38,7 +39,10 @@ export const useCronStore = create((set) => ({ createJob: async (input) => { try { - const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob; + const job = await hostApiFetch('/api/cron/jobs', { + method: 'POST', + body: JSON.stringify(input), + }); set((state) => ({ jobs: [...state.jobs, job] })); return job; } catch (error) { @@ -49,7 +53,10 @@ export const useCronStore = create((set) => ({ updateJob: async (id, input) => { try { - await window.electron.ipcRenderer.invoke('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 @@ -63,7 +70,9 @@ export const useCronStore = create((set) => ({ deleteJob: async (id) => { try { - await window.electron.ipcRenderer.invoke('cron:delete', id); + await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); set((state) => ({ jobs: state.jobs.filter((job) => job.id !== id), })); @@ -75,7 +84,10 @@ export const useCronStore = create((set) => ({ toggleJob: async (id, enabled) => { try { - await window.electron.ipcRenderer.invoke('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 @@ -89,11 +101,14 @@ export const useCronStore = create((set) => ({ triggerJob: async (id) => { try { - const result = await window.electron.ipcRenderer.invoke('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 window.electron.ipcRenderer.invoke('cron:list') as CronJob[]; + const jobs = await hostApiFetch('/api/cron/jobs'); set({ jobs }); } catch { // Ignore refresh error diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 1ee8c03..a714ffe 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -1,11 +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 { createHostEventSource, hostApiFetch } from '@/lib/host-api'; +import { gatewayClient } from '@/lib/gateway-client'; import type { GatewayStatus } from '../types/gateway'; let gatewayInitPromise: Promise | null = null; +let gatewayEventSource: EventSource | null = null; interface GatewayHealth { ok: boolean; @@ -18,8 +21,6 @@ interface GatewayState { health: GatewayHealth | null; isInitialized: boolean; lastError: string | null; - - // Actions init: () => Promise; start: () => Promise; stop: () => Promise; @@ -30,6 +31,119 @@ interface GatewayState { clearError: () => void; } +function handleGatewayNotification(notification: { method?: string; params?: Record } | 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) : {}; + const phase = data.phase ?? p.phase; + const hasChatData = (p.state ?? data.state) || (p.message ?? data.message); + + if (hasChatData) { + const normalizedEvent: Record = { + ...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; + const payload = ('message' in chatData && typeof chatData.message === 'object') + ? chatData.message as Record + : chatData; + + if (payload.state) { + useChatStore.getState().handleChatEvent(payload); + return; + } + + useChatStore.getState().handleChatEvent({ + state: 'final', + message: payload, + runId: chatData.runId ?? payload.runId, + }); + }).catch(() => {}); +} + +function handleGatewayMessage(data: unknown): void { + if (!data || typeof data !== 'object') return; + const msg = data as Record; + 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(() => {}); + } +} + +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((set, get) => ({ status: { state: 'stopped', @@ -48,141 +162,36 @@ export const useGatewayStore = create((set, get) => ({ gatewayInitPromise = (async () => { try { - // Get initial status first - const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus; + const status = await hostApiFetch('/api/gateway/status'); set({ status, isInitialized: true }); - // Listen for status changes - window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => { - set({ status: newStatus as GatewayStatus }); - }); + 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' }); + }); + } - // 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 } | 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) : {}; - const phase = data.phase ?? p.phase; - - const hasChatData = (p.state ?? data.state) || (p.message ?? data.message); - if (hasChatData) { - const normalizedEvent: Record = { - ...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, - }); - } - }) - .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; - const payload = ('message' in chatData && typeof chatData.message === 'object') - ? chatData.message as Record - : chatData; - - if (payload.state) { - useChatStore.getState().handleChatEvent(payload); - return; + gatewayClient.on('agent', (payload) => handleGatewayNotification({ method: 'agent', params: payload as Record })); + gatewayClient.on('chat', (payload) => handleGatewayChatMessage({ message: payload })); + gatewayClient.on('message', handleGatewayMessage); + gatewayClient.on('channel.status', (payload) => { + import('./channels') + .then(({ useChannelsStore }) => { + const update = payload 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) }); } - - // 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(() => {}); }); - - // 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; - - // 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) }); @@ -197,25 +206,27 @@ export const useGatewayStore = create((set, get) => ({ start: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); - const result = await window.electron.ipcRenderer.invoke('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 window.electron.ipcRenderer.invoke('gateway:stop'); + await hostApiFetch('/api/gateway/stop', { method: 'POST' }); + gatewayClient.disconnect(); set({ status: { ...get().status, state: 'stopped' }, lastError: null }); } catch (error) { console.error('Failed to stop Gateway:', error); @@ -226,39 +237,29 @@ export const useGatewayStore = create((set, get) => ({ restart: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); - const result = await window.electron.ipcRenderer.invoke('gateway:restart') as { success: boolean; error?: string }; - + gatewayClient.disconnect(); + 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 window.electron.ipcRenderer.invoke('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('/api/gateway/health'); + set({ health: result }); + return result; } catch (error) { const health: GatewayHealth = { ok: false, error: String(error) }; set({ health }); @@ -267,20 +268,9 @@ export const useGatewayStore = create((set, get) => ({ }, rpc: async (method: string, params?: unknown, timeoutMs?: number): Promise => { - const result = await window.electron.ipcRenderer.invoke('gateway:rpc', method, params, timeoutMs) as { - success: boolean; - result?: T; - error?: string; - }; - - if (!result.success) { - throw new Error(result.error || `RPC call failed: ${method}`); - } - - return result.result as T; + return await gatewayClient.rpc(method, params, timeoutMs); }, setStatus: (status) => set({ status }), - clearError: () => set({ lastError: null }), })); diff --git a/src/stores/providers.ts b/src/stores/providers.ts index cd5fd6b..eba05bf 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; +import { hostApiFetch } from '@/lib/host-api'; // Re-export types for consumers that imported from here export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; @@ -45,12 +46,12 @@ export const useProviderStore = create((set, get) => ({ set({ loading: true, error: null }); try { - const providers = await window.electron.ipcRenderer.invoke('provider:list') as ProviderWithKeyInfo[]; - const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; + const providers = await hostApiFetch('/api/providers'); + const defaultInfo = await hostApiFetch<{ providerId: string | null }>('/api/providers/default'); set({ providers, - defaultProviderId: defaultId, + defaultProviderId: defaultInfo.providerId, loading: false }); } catch (error) { @@ -66,7 +67,10 @@ export const useProviderStore = create((set, get) => ({ updatedAt: new Date().toISOString(), }; - const result = await window.electron.ipcRenderer.invoke('provider:save', fullConfig, apiKey) as { success: boolean; error?: string }; + 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'); @@ -95,7 +99,10 @@ export const useProviderStore = create((set, get) => ({ updatedAt: new Date().toISOString(), }; - const result = await window.electron.ipcRenderer.invoke('provider:save', updatedConfig, apiKey) as { success: boolean; error?: string }; + 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'); @@ -111,7 +118,9 @@ export const useProviderStore = create((set, get) => ({ deleteProvider: async (providerId) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:delete', providerId) as { success: boolean; error?: string }; + 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'); @@ -127,7 +136,10 @@ export const useProviderStore = create((set, get) => ({ setApiKey: async (providerId, apiKey) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:setApiKey', providerId, apiKey) as { success: boolean; error?: string }; + 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'); @@ -143,12 +155,10 @@ export const useProviderStore = create((set, get) => ({ updateProviderWithKey: async (providerId, updates, apiKey) => { try { - const result = await window.electron.ipcRenderer.invoke( - 'provider:updateWithKey', - providerId, - updates, - apiKey - ) as { success: boolean; error?: string }; + 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'); @@ -163,7 +173,10 @@ export const useProviderStore = create((set, get) => ({ deleteApiKey: async (providerId) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:deleteApiKey', providerId) as { success: boolean; error?: string }; + 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'); @@ -179,7 +192,10 @@ export const useProviderStore = create((set, get) => ({ setDefaultProvider: async (providerId) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:setDefault', providerId) as { success: boolean; error?: string }; + 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'); @@ -194,12 +210,10 @@ export const useProviderStore = create((set, get) => ({ validateApiKey: async (providerId, apiKey, options) => { try { - const result = await window.electron.ipcRenderer.invoke( - 'provider:validateKey', - providerId, - apiKey, - options - ) as { valid: boolean; error?: string }; + 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) }; @@ -208,7 +222,8 @@ export const useProviderStore = create((set, get) => ({ getApiKey: async (providerId) => { try { - return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null; + const result = await hostApiFetch<{ apiKey: string | null }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`); + return result.apiKey; } catch { return null; } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 2aefc08..76c53bd 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -5,6 +5,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; +import { hostApiFetch } from '@/lib/host-api'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; @@ -94,7 +95,7 @@ export const useSettingsStore = create()( init: async () => { try { - const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial; + const settings = await hostApiFetch>('/api/settings'); set((state) => ({ ...state, ...settings })); if (settings.language) { i18n.changeLanguage(settings.language); @@ -106,11 +107,30 @@ export const useSettingsStore = create()( }, setTheme: (theme) => set({ theme }), - setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void window.electron.ipcRenderer.invoke('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 window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, - setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void window.electron.ipcRenderer.invoke('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 }), diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 2ae75f2..da59a2f 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -3,6 +3,8 @@ * 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'; type GatewaySkillStatus = { @@ -23,12 +25,6 @@ type GatewaySkillsStatusResult = { skills?: GatewaySkillStatus[]; }; -type GatewayRpcResponse = { - success: boolean; - result?: T; - error?: string; -}; - type ClawHubListResult = { slug: string; version?: string; @@ -70,27 +66,20 @@ export const useSkillsStore = create((set, get) => ({ } try { // 1. Fetch from Gateway (running skills) - const gatewayResult = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'skills.status' - ) as GatewayRpcResponse; + const gatewayData = await useGatewayStore.getState().rpc('skills.status'); // 2. Fetch from ClawHub (installed on disk) - const clawhubResult = await window.electron.ipcRenderer.invoke( - 'clawhub:list' - ) as { success: boolean; results?: ClawHubListResult[]; error?: string }; + 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 window.electron.ipcRenderer.invoke( - 'skill:getAllConfigs' - ) as Record }>; + const configResult = await hostApiFetch }>>('/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] || {}; @@ -155,7 +144,10 @@ export const useSkillsStore = create((set, get) => ({ searchSkills: async (query: string) => { set({ searching: true, searchError: null }); try { - const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string }; + 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 { @@ -177,7 +169,10 @@ export const useSkillsStore = create((set, get) => ({ installSkill: async (slug: string, version?: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await window.electron.ipcRenderer.invoke('clawhub:install', { slug, version }) as { success: boolean; error?: string }; + 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'); @@ -204,7 +199,10 @@ export const useSkillsStore = create((set, get) => ({ uninstallSkill: async (slug: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await window.electron.ipcRenderer.invoke('clawhub:uninstall', { slug }) as { success: boolean; error?: string }; + 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'); } @@ -226,17 +224,8 @@ export const useSkillsStore = create((set, get) => ({ const { updateSkill } = get(); try { - const result = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'skills.update', - { skillKey: skillId, enabled: true } - ) as GatewayRpcResponse; - - 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; @@ -252,17 +241,8 @@ export const useSkillsStore = create((set, get) => ({ } try { - const result = await window.electron.ipcRenderer.invoke( - 'gateway:rpc', - 'skills.update', - { skillKey: skillId, enabled: false } - ) as GatewayRpcResponse; - - 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;