diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 59d99c572..94507457b 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -228,9 +228,11 @@ export class GatewayManager extends EventEmitter { }> = new Map(); private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; + private reloadDebounceTimer: NodeJS.Timeout | null = null; private lifecycleEpoch = 0; private deferredRestartPending = false; private restartInFlight: Promise | null = null; + private externalShutdownSupported: boolean | null = null; constructor(config?: Partial) { super(); @@ -259,6 +261,11 @@ export class GatewayManager extends EventEmitter { return sanitized; } + private isUnsupportedShutdownError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /unknown method:\s*shutdown/i.test(message); + } + private formatExit(code: number | null, signal: NodeJS.Signals | null): string { if (code !== null) return `code=${code}`; if (signal) return `signal=${signal}`; @@ -272,6 +279,14 @@ export class GatewayManager extends EventEmitter { // Known noisy lines that are not actionable for Gateway lifecycle debugging. if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg }; if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg }; + // During renderer refresh / transport switching, loopback websocket probes can time out + // while the gateway is reloading. This is expected and not actionable. + if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) { + return { level: 'debug', normalized: msg }; + } + if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) { + return { level: 'debug', normalized: msg }; + } // Downgrade frequent non-fatal noise. if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg }; @@ -536,11 +551,17 @@ export class GatewayManager extends EventEmitter { // If this manager is attached to an external gateway process, ask it to shut down // over protocol before closing the socket. - if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) { + if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN && this.externalShutdownSupported !== false) { try { await this.rpc('shutdown', undefined, 5000); + this.externalShutdownSupported = true; } catch (error) { - logger.warn('Failed to request shutdown for externally managed Gateway:', error); + if (this.isUnsupportedShutdownError(error)) { + this.externalShutdownSupported = false; + logger.info('External Gateway does not support "shutdown"; skipping shutdown RPC for future stops'); + } else { + logger.warn('Failed to request shutdown for externally managed Gateway:', error); + } } } @@ -648,6 +669,71 @@ export class GatewayManager extends EventEmitter { }, delayMs); } + /** + * Ask the Gateway process to reload config in-place when possible. + * Falls back to restart on unsupported platforms or signaling failures. + */ + async reload(): Promise { + if (this.isRestartDeferred()) { + this.markDeferredRestart('reload'); + return; + } + + if (!this.process?.pid || this.status.state !== 'running') { + logger.warn('Gateway reload requested while not running; falling back to restart'); + await this.restart(); + return; + } + + if (process.platform === 'win32') { + logger.debug('Windows detected, falling back to Gateway restart for reload'); + await this.restart(); + return; + } + + const connectedForMs = this.status.connectedAt + ? Date.now() - this.status.connectedAt + : Number.POSITIVE_INFINITY; + + // Avoid signaling a process that just came up; it will already read latest config. + if (connectedForMs < 8000) { + logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`); + return; + } + + try { + process.kill(this.process.pid, 'SIGUSR1'); + logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`); + // Some gateway builds do not handle SIGUSR1 as an in-process reload. + // If process state doesn't recover quickly, fall back to restart. + await new Promise((resolve) => setTimeout(resolve, 1500)); + if (this.status.state !== 'running' || !this.process?.pid) { + logger.warn('Gateway did not stay running after reload signal, falling back to restart'); + await this.restart(); + } + } catch (error) { + logger.warn('Gateway reload signal failed, falling back to restart:', error); + await this.restart(); + } + } + + /** + * Debounced reload — coalesces multiple rapid config-change events into one + * in-process reload when possible. + */ + debouncedReload(delayMs = 1200): void { + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + } + logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`); + this.reloadDebounceTimer = setTimeout(() => { + this.reloadDebounceTimer = null; + void this.reload().catch((err) => { + logger.warn('Debounced Gateway reload failed:', err); + }); + }, delayMs); + } + /** * Clear all active timers */ @@ -668,6 +754,10 @@ export class GatewayManager extends EventEmitter { clearTimeout(this.restartDebounceTimer); this.restartDebounceTimer = null; } + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + this.reloadDebounceTimer = null; + } } /** diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index a690c8d7b..4436c7ba3 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -54,6 +54,25 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; +import { appUpdater } from './updater'; + +type AppRequest = { + id?: string; + module: string; + action: string; + payload?: unknown; +}; + +type AppResponse = { + id?: string; + ok: boolean; + data?: unknown; + error?: { + code: 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED'; + message: string; + details?: unknown; + }; +}; import { getOpenClawProviderKeyForType, getOAuthApiKeyEnv, @@ -136,6 +155,9 @@ export function registerIpcHandlers( clawHubService: ClawHubService, mainWindow: BrowserWindow ): void { + // Unified request protocol (non-breaking: legacy channels remain available) + registerUnifiedRequestHandlers(gatewayManager); + // Gateway handlers registerGatewayHandlers(gatewayManager, mainWindow); @@ -143,7 +165,7 @@ export function registerIpcHandlers( registerClawHubHandlers(clawHubService); // OpenClaw handlers - registerOpenClawHandlers(); + registerOpenClawHandlers(gatewayManager); // Provider handlers registerProviderHandlers(gatewayManager); @@ -191,6 +213,701 @@ export function registerIpcHandlers( registerFileHandlers(); } +function mapAppErrorCode(error: unknown): AppResponse['error']['code'] { + const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if (msg.includes('timeout')) return 'TIMEOUT'; + if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION'; + if (msg.includes('gateway')) return 'GATEWAY'; + if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION'; + return 'INTERNAL'; +} + +function isProxyKey(key: keyof AppSettings): boolean { + return ( + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + ); +} + +function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { + const handleProxySettingsChange = async () => { + const settings = await getAllSettings(); + await applyProxySettings(settings); + if (gatewayManager.getStatus().state === 'running') { + await gatewayManager.restart(); + } + }; + + ipcMain.handle('app:request', async (_, request: AppRequest): Promise => { + if (!request || typeof request.module !== 'string' || typeof request.action !== 'string') { + return { + id: request?.id, + ok: false, + error: { code: 'VALIDATION', message: 'Invalid app request format' }, + }; + } + + try { + let data: unknown; + switch (request.module) { + case 'app': { + if (request.action === 'version') data = app.getVersion(); + else if (request.action === 'name') data = app.getName(); + else if (request.action === 'platform') data = process.platform; + else { + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + break; + } + case 'provider': { + if (request.action === 'list') { + data = await getAllProvidersWithKeyInfo(); + break; + } + if (request.action === 'get') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.get payload'); + data = await getProvider(providerId); + break; + } + if (request.action === 'getDefault') { + data = await getDefaultProvider(); + break; + } + if (request.action === 'hasApiKey') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.hasApiKey payload'); + data = await hasApiKey(providerId); + break; + } + if (request.action === 'getApiKey') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.getApiKey payload'); + data = await getApiKey(providerId); + break; + } + if (request.action === 'validateKey') { + const payload = request.payload as + | { providerId?: string; apiKey?: string; options?: { baseUrl?: string } } + | [string, string, { baseUrl?: string }?] + | undefined; + const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; + const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; + const options = Array.isArray(payload) ? payload[2] : payload?.options; + if (!providerId || typeof apiKey !== 'string') { + throw new Error('Invalid provider.validateKey payload'); + } + + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; + const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; + data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl }); + break; + } + if (request.action === 'save') { + const payload = request.payload as + | { config?: ProviderConfig; apiKey?: string } + | [ProviderConfig, string?] + | undefined; + const config = Array.isArray(payload) ? payload[0] : payload?.config; + const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; + if (!config) throw new Error('Invalid provider.save payload'); + + try { + await saveProvider(config); + const ock = getOpenClawProviderKey(config.type, config.id); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(config.id, trimmedKey); + try { + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } catch (err) { + console.warn('Failed to save key to OpenClaw auth-profiles:', err); + } + } + } + + try { + 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 = apiKey !== undefined + ? (apiKey.trim() || null) + : await getApiKey(config.id); + if (resolvedKey && config.baseUrl) { + const modelId = config.model; + await updateAgentModelProvider(ock, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + + logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`); + gatewayManager.debouncedRestart(); + } + } catch (err) { + console.warn('Failed to sync openclaw provider config:', err); + } + + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'delete') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.delete payload'); + + try { + const existing = await getProvider(providerId); + await deleteProvider(providerId); + if (existing?.type) { + try { + const ock = getOpenClawProviderKey(existing.type, providerId); + await removeProviderFromOpenClaw(ock); + logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`); + gatewayManager.debouncedRestart(); + } catch (err) { + console.warn('Failed to completely remove provider from OpenClaw:', err); + } + } + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'setApiKey') { + const payload = request.payload as + | { providerId?: string; apiKey?: string } + | [string, string] + | undefined; + const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; + const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; + if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload'); + + try { + await storeApiKey(providerId, apiKey); + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + const ock = getOpenClawProviderKey(providerType, providerId); + try { + await saveProviderKeyToOpenClaw(ock, apiKey); + } catch (err) { + console.warn('Failed to save key to OpenClaw auth-profiles:', err); + } + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'updateWithKey') { + const payload = request.payload as + | { providerId?: string; updates?: Partial; apiKey?: string } + | [string, Partial, string?] + | undefined; + const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; + const updates = Array.isArray(payload) ? payload[1] : payload?.updates; + const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey; + if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload'); + + const existing = await getProvider(providerId); + if (!existing) { + data = { success: false, error: 'Provider not found' }; + break; + } + + const previousKey = await getApiKey(providerId); + const previousOck = getOpenClawProviderKey(existing.type, providerId); + + try { + const nextConfig: ProviderConfig = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + const ock = getOpenClawProviderKey(nextConfig.type, providerId); + await saveProvider(nextConfig); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(providerId, trimmedKey); + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } else { + await deleteApiKey(providerId); + await removeProviderFromOpenClaw(ock); + } + } + + try { + 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, + }); + + if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') { + const resolvedKey = apiKey !== undefined + ? (apiKey.trim() || null) + : await getApiKey(providerId); + if (resolvedKey && nextConfig.baseUrl) { + const modelId = nextConfig.model; + await updateAgentModelProvider(ock, { + baseUrl: nextConfig.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + } + + 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); + } + } + + logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`); + gatewayManager.debouncedRestart(); + } catch (err) { + console.warn('Failed to sync openclaw config after provider update:', err); + } + + data = { success: true }; + } catch (error) { + try { + await saveProvider(existing); + if (previousKey) { + await storeApiKey(providerId, previousKey); + await saveProviderKeyToOpenClaw(previousOck, previousKey); + } else { + await deleteApiKey(providerId); + await removeProviderFromOpenClaw(previousOck); + } + } catch (rollbackError) { + console.warn('Failed to rollback provider updateWithKey:', rollbackError); + } + + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'deleteApiKey') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.deleteApiKey payload'); + try { + await deleteApiKey(providerId); + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + const ock = getOpenClawProviderKey(providerType, providerId); + try { + if (ock) { + await removeProviderFromOpenClaw(ock); + } + } catch (err) { + console.warn('Failed to completely remove provider from OpenClaw:', err); + } + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'setDefault') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.setDefault payload'); + + try { + await setDefaultProvider(providerId); + const provider = await getProvider(providerId); + if (provider) { + try { + const ock = getOpenClawProviderKey(provider.type, providerId); + const providerKey = await getApiKey(providerId); + const fallbackModels = await getProviderFallbackModelRefs(provider); + const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; + const isOAuthProvider = OAUTH_PROVIDER_TYPES.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'); + const api: 'anthropic-messages' | 'openai-completions' = + (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'anthropic-messages' + : 'openai-completions'; + + let baseUrl = provider.baseUrl || defaultBaseUrl; + if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { + baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + + const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'minimax-portal' + : provider.type; + + await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }, fallbackModels); + + try { + const defaultModelId = provider.model?.split('/').pop(); + await updateAgentModelProvider(targetProviderKey, { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], + }); + } catch (err) { + logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); + } + } + + if ( + (provider.type === 'custom' || provider.type === 'ollama') && + providerKey && + provider.baseUrl + ) { + const modelId = provider.model; + await updateAgentModelProvider(ock, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: providerKey, + }); + } + + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`); + gatewayManager.debouncedRestart(); + } + } catch (err) { + console.warn('Failed to set OpenClaw default model:', err); + } + } + + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'update': { + if (request.action === 'status') { + data = appUpdater.getStatus(); + break; + } + if (request.action === 'version') { + data = appUpdater.getCurrentVersion(); + break; + } + if (request.action === 'check') { + try { + await appUpdater.checkForUpdates(); + data = { success: true, status: appUpdater.getStatus() }; + } catch (error) { + data = { success: false, error: String(error), status: appUpdater.getStatus() }; + } + break; + } + if (request.action === 'download') { + try { + await appUpdater.downloadUpdate(); + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'install') { + appUpdater.quitAndInstall(); + data = { success: true }; + break; + } + if (request.action === 'setChannel') { + const payload = request.payload as { channel?: 'stable' | 'beta' | 'dev' } | 'stable' | 'beta' | 'dev' | undefined; + const channel = typeof payload === 'string' ? payload : payload?.channel; + if (!channel) throw new Error('Invalid update.setChannel payload'); + appUpdater.setChannel(channel); + data = { success: true }; + break; + } + if (request.action === 'setAutoDownload') { + const payload = request.payload as { enable?: boolean } | boolean | undefined; + const enable = typeof payload === 'boolean' ? payload : payload?.enable; + if (typeof enable !== 'boolean') throw new Error('Invalid update.setAutoDownload payload'); + appUpdater.setAutoDownload(enable); + data = { success: true }; + break; + } + if (request.action === 'cancelAutoInstall') { + appUpdater.cancelAutoInstall(); + data = { success: true }; + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'cron': { + if (request.action === 'list') { + const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); + const jobs = (result as { jobs?: GatewayCronJob[] })?.jobs ?? []; + data = jobs.map(transformCronJob); + break; + } + if (request.action === 'create') { + const payload = request.payload as + | { input?: { name: string; message: string; schedule: string; enabled?: boolean } } + | [{ name: string; message: string; schedule: string; enabled?: boolean }] + | { name: string; message: string; schedule: string; enabled?: boolean } + | undefined; + const input = Array.isArray(payload) + ? payload[0] + : ('input' in (payload ?? {}) ? (payload as { input: { name: string; message: string; schedule: string; enabled?: boolean } }).input : payload); + if (!input) throw new Error('Invalid cron.create payload'); + const gatewayInput = { + 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' }, + }; + const created = await gatewayManager.rpc('cron.add', gatewayInput); + data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created; + break; + } + if (request.action === 'update') { + const payload = request.payload as + | { id?: string; input?: Record } + | [string, Record] + | undefined; + const id = Array.isArray(payload) ? payload[0] : payload?.id; + const input = Array.isArray(payload) ? payload[1] : payload?.input; + if (!id || !input) throw new Error('Invalid cron.update payload'); + 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; + } + data = await gatewayManager.rpc('cron.update', { id, patch }); + break; + } + if (request.action === 'delete') { + const payload = request.payload as { id?: string } | string | undefined; + const id = typeof payload === 'string' ? payload : payload?.id; + if (!id) throw new Error('Invalid cron.delete payload'); + data = await gatewayManager.rpc('cron.remove', { id }); + break; + } + if (request.action === 'toggle') { + const payload = request.payload as { id?: string; enabled?: boolean } | [string, boolean] | undefined; + const id = Array.isArray(payload) ? payload[0] : payload?.id; + const enabled = Array.isArray(payload) ? payload[1] : payload?.enabled; + if (!id || typeof enabled !== 'boolean') throw new Error('Invalid cron.toggle payload'); + data = await gatewayManager.rpc('cron.update', { id, patch: { enabled } }); + break; + } + if (request.action === 'trigger') { + const payload = request.payload as { id?: string } | string | undefined; + const id = typeof payload === 'string' ? payload : payload?.id; + if (!id) throw new Error('Invalid cron.trigger payload'); + data = await gatewayManager.rpc('cron.run', { id, mode: 'force' }); + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'usage': { + if (request.action === 'recentTokenHistory') { + const payload = request.payload as { limit?: number } | number | undefined; + const limit = typeof payload === 'number' ? payload : payload?.limit; + const safeLimit = typeof limit === 'number' && Number.isFinite(limit) + ? Math.max(Math.floor(limit), 1) + : undefined; + data = await getRecentTokenUsageHistory(safeLimit); + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'settings': { + if (request.action === 'getAll') { + data = await getAllSettings(); + break; + } + if (request.action === 'get') { + const payload = request.payload as { key?: keyof AppSettings } | [keyof AppSettings] | undefined; + const key = Array.isArray(payload) ? payload[0] : payload?.key; + if (!key) throw new Error('Invalid settings.get payload'); + data = await getSetting(key); + break; + } + if (request.action === 'set') { + const payload = request.payload as + | { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] } + | [keyof AppSettings, AppSettings[keyof AppSettings]] + | undefined; + const key = Array.isArray(payload) ? payload[0] : payload?.key; + const value = Array.isArray(payload) ? payload[1] : payload?.value; + if (!key) throw new Error('Invalid settings.set payload'); + await setSetting(key, value as never); + if (isProxyKey(key)) { + await handleProxySettingsChange(); + } + data = { success: true }; + break; + } + if (request.action === 'setMany') { + const patch = (request.payload ?? {}) as Partial; + const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; + for (const [key, value] of entries) { + await setSetting(key, value as never); + } + if (entries.some(([key]) => isProxyKey(key))) { + await handleProxySettingsChange(); + } + data = { success: true }; + break; + } + if (request.action === 'reset') { + await resetSettings(); + const settings = await getAllSettings(); + await handleProxySettingsChange(); + data = { success: true, settings }; + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + default: + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + + return { id: request.id, ok: true, data }; + } catch (error) { + return { + id: request.id, + ok: false, + error: { + code: mapAppErrorCode(error), + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }); +} + /** * Skill config IPC handlers * Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC) @@ -493,6 +1210,14 @@ function registerGatewayHandlers( gatewayManager: GatewayManager, mainWindow: BrowserWindow ): void { + type GatewayHttpProxyRequest = { + path?: string; + method?: string; + headers?: Record; + body?: unknown; + timeoutMs?: number; + }; + // Get Gateway status ipcMain.handle('gateway:status', () => { return gatewayManager.getStatus(); @@ -543,6 +1268,72 @@ function registerGatewayHandlers( } }); + // Gateway HTTP proxy + // Renderer must not call gateway HTTP directly (CORS); all HTTP traffic + // should go through this main-process proxy. + ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => { + try { + const status = gatewayManager.getStatus(); + const port = status.port || 18789; + const path = request?.path && request.path.startsWith('/') ? request.path : '/'; + const method = (request?.method || 'GET').toUpperCase(); + const timeoutMs = + typeof request?.timeoutMs === 'number' && request.timeoutMs > 0 + ? request.timeoutMs + : 15000; + + const token = await getSetting('gatewayToken'); + const headers: Record = { + ...(request?.headers ?? {}), + }; + if (!headers.Authorization && !headers.authorization && token) { + headers.Authorization = `Bearer ${token}`; + } + + let body: string | undefined; + if (request?.body !== undefined && request?.body !== null) { + body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body); + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, { + method, + headers, + body, + signal: controller.signal, + }); + clearTimeout(timer); + + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const json = await response.json(); + return { + success: true, + status: response.status, + ok: response.ok, + json, + }; + } + + const text = await response.text(); + return { + success: true, + status: response.status, + ok: response.ok, + text, + }; + } catch (error) { + return { + success: false, + error: String(error), + }; + } + }); + // Chat send with media — reads staged files from disk and builds attachments. // Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments. // All other files are referenced by path in the message text so the model @@ -700,7 +1491,7 @@ function registerGatewayHandlers( * OpenClaw-related IPC handlers * For checking package status and channel configuration */ -function registerOpenClawHandlers(): void { +function registerOpenClawHandlers(gatewayManager: GatewayManager): void { async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); const targetManifest = join(targetDir, 'openclaw.plugin.json'); @@ -813,9 +1604,12 @@ function registerOpenClawHandlers(): void { }; } await saveChannelConfig(channelType, config); - logger.info( - `Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally` - ); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`); + } return { success: true, pluginInstalled: installResult.installed, @@ -823,12 +1617,12 @@ function registerOpenClawHandlers(): void { }; } await saveChannelConfig(channelType, config); - // Do not force stop/start here. Recent Gateway builds detect channel config - // changes and perform an internal service restart; forcing another restart - // from Electron can race with reconnect and kill the newly spawned process. - logger.info( - `Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload` - ); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to save channel config:', error); @@ -862,6 +1656,12 @@ function registerOpenClawHandlers(): void { ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { try { await deleteChannelConfig(channelType); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to delete channel config:', error); @@ -884,6 +1684,12 @@ function registerOpenClawHandlers(): void { ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { try { await setChannelEnabled(channelType, enabled); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to set channel enabled:', error); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f0e7fc316..8ed4ae1bd 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -21,6 +21,7 @@ const electronAPI = { 'gateway:stop', 'gateway:restart', 'gateway:rpc', + 'gateway:httpProxy', 'gateway:health', 'gateway:getControlUiUrl', // OpenClaw @@ -41,6 +42,7 @@ const electronAPI = { 'app:platform', 'app:quit', 'app:relaunch', + 'app:request', // Window controls 'window:minimize', 'window:maximize', diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 1732e414b..f9a335f07 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -39,6 +39,7 @@ export interface PluginsConfig { export interface OpenClawConfig { channels?: Record; plugins?: PluginsConfig; + commands?: Record; [key: string]: unknown; } @@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise await ensureConfigDir(); try { + // Enable graceful in-process reload authorization for SIGUSR1 flows. + const commands = + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {}; + commands.restart = true; + config.commands = commands; + await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); } catch (error) { logger.error('Failed to write OpenClaw config', error); diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 6538e4c9f..fd887783a 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -136,6 +136,15 @@ async function readOpenClawJson(): Promise> { } async function writeOpenClawJson(config: Record): Promise { + // Ensure SIGUSR1 graceful reload is authorized by OpenClaw config. + const commands = ( + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {} + ) as Record; + commands.restart = true; + config.commands = commands; + await writeJsonFile(OPENCLAW_CONFIG_PATH, config); } @@ -819,6 +828,20 @@ export async function sanitizeOpenClawConfig(): Promise { } } + // ── commands section ─────────────────────────────────────────── + // Required for SIGUSR1 in-process reload authorization. + const commands = ( + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {} + ) as Record; + if (commands.restart !== true) { + commands.restart = true; + config.commands = commands; + modified = true; + console.log('[sanitize] Enabling commands.restart for graceful reload support'); + } + // ── tools.web.search.kimi ───────────────────────────────────── // OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over // environment/auth-profiles. A stale inline key can cause persistent 401s. diff --git a/electron/utils/store.ts b/electron/utils/store.ts index a28fb9af5..c3d50055a 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -36,6 +36,7 @@ export interface AppSettings { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; + gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; // Update updateChannel: 'stable' | 'beta' | 'dev'; @@ -73,6 +74,7 @@ const defaults: AppSettings = { proxyHttpsServer: '', proxyAllServer: '', proxyBypassRules: ';localhost;127.0.0.1;::1', + gatewayTransportPreference: 'ws-first', // Update updateChannel: 'stable', diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d45d4dc7..58e9f2a69 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,4 +40,22 @@ export default [ '@typescript-eslint/no-explicit-any': 'warn', }, }, + { + files: ['src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']", + message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.', + }, + ], + }, + }, + { + files: ['src/lib/api-client.ts'], + rules: { + 'no-restricted-syntax': 'off', + }, + }, ]; diff --git a/refactor.md b/refactor.md new file mode 100644 index 000000000..6230f0eef --- /dev/null +++ b/refactor.md @@ -0,0 +1,143 @@ +# Refactor Summary + +## Scope +This branch captures local refactors focused on frontend UX polish, IPC call consolidation, transport abstraction, and channel page responsiveness. + +## Key Changes + +### 1. Frontend IPC consolidation +- Replaced scattered direct `window.electron.ipcRenderer.invoke(...)` calls with unified `invokeIpc(...)` usage. +- Added lint guard to prevent new direct renderer IPC invokes outside the API layer. +- Introduced a centralized API client with: + - error normalization (`AppError`) + - unified `app:request` support + compatibility fallback + - retry helper for timeout/network errors + +### 2. Transport abstraction (extensible protocol layer) +- Added transport routing abstraction inside `src/lib/api-client.ts`: + - `ipc`, `ws`, `http` + - rule-based channel routing + - transport registration/unregistration + - failure backoff and fallback behavior +- Added default transport initialization in app entry. +- Added gateway-specific transport adapters for WS/HTTP. + +### 3. HTTP path moved to Electron main-process proxy +- Added `gateway:httpProxy` IPC handler in main process to avoid renderer-side CORS issues. +- Preload allowlist updated for `gateway:httpProxy`. +- Gateway HTTP transport now uses IPC proxy instead of browser `fetch` direct-to-gateway. + +### 4. Settings improvements (Developer-focused transport control) +- Added persisted setting `gatewayTransportPreference`. +- Added runtime application of transport preference in app bootstrap. +- Added UI option (Developer section) to choose routing strategy: + - WS First / HTTP First / WS Only / HTTP Only / IPC Only +- Added i18n strings for EN/ZH/JA. + +### 5. Channel page performance optimization +- `fetchChannels` now supports options: + - `probe` (manual refresh can force probe) + - `silent` (background refresh without full-page loading lock) +- Channel status event refresh now debounced (300ms) to reduce refresh storms. +- Initial loading spinner only shown when no existing data. +- Manual refresh uses local spinner state and non-blocking update. + +### 6. UX and component enhancements +- Added shared feedback state component for consistent empty/loading/error states. +- Added telemetry helpers and quick-action/dashboard refinements. +- Setup/settings/providers/chat/skills/cron pages received targeted UX and reliability fixes. + +### 7. IPC main handler compatibility improvements +- Expanded `app:request` coverage for provider/update/settings/cron/usage actions. +- Unsupported app requests now return structured error response instead of throwing, reducing noisy handler exceptions. + +### 8. Tests +- Added unit tests for API client behavior and feedback state rendering. +- Added transport fallback/backoff coverage in API client tests. + +## Files Added +- `src/lib/api-client.ts` +- `src/lib/telemetry.ts` +- `src/components/common/FeedbackState.tsx` +- `tests/unit/api-client.test.ts` +- `tests/unit/feedback-state.test.tsx` +- `refactor.md` + +## Notes +- Navigation order in sidebar is kept aligned with `main` ordering. +- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work. + +## Incremental Updates (2026-03-08) + +### 9. Channel i18n fixes +- Added missing `channels` locale keys in EN/ZH/JA to prevent raw key fallback: + - `configured`, `configuredDesc`, `configuredBadge`, `deleteConfirm` +- Fixed confirm dialog namespace usage on Channels page: + - `common:actions.confirm`, `common:actions.delete`, `common:actions.cancel` + +### 10. Channel save/delete behavior aligned to reload-first strategy +- Added Gateway reload capability in `GatewayManager`: + - `reload()` (SIGUSR1 on macOS/Linux, restart fallback on failure/unsupported platforms) + - `debouncedReload()` for coalesced config-change reloads +- Wired channel config operations to reload pipeline: + - `channel:saveConfig` + - `channel:deleteConfig` + - `channel:setEnabled` +- Removed redundant renderer-side forced restart call after WhatsApp configuration. + +### 11. OpenClaw config compatibility for graceful reload +- Ensured `commands.restart = true` is persisted in OpenClaw config write paths: + - `electron/utils/channel-config.ts` + - `electron/utils/openclaw-auth.ts` +- Added sanitize fallback that auto-enables `commands.restart` before Gateway start. + +### 12. Channels page data consistency fixes +- Unified configured state derivation so the following sections share one source: + - stats cards + - configured channels list + - available channel configured badge +- Fixed post-delete refresh by explicitly refetching both: + - configured channel types + - channel status list + +### 13. Channels UX resilience during Gateway restart/reconnect +- Added delayed gateway warning display to reduce transient false alarms. +- Added "running snapshot" rendering strategy: + - keep previous channels/configured view during `starting/reconnecting` when live response is temporarily empty + - avoids UI flashing to zero counts / empty configured state +- Added automatic refresh once Gateway transitions back to `running`. + +### 14. Channel enable/disable UX rollback +- Rolled back renderer-side channel enable/disable controls due to multi-channel state mixing risk. +- Removed channel-card toggle entry point and setup-dialog enable switch. +- Restored stable channel configuration flow (save/delete + refresh consistency). + +### 15. Cron i18n completion and consistency +- Replaced remaining hardcoded Cron UI strings with i18n keys: + - dialog actions (`Cancel`, `Saving...`) + - card actions (`Edit`, `Delete`) + - trigger failure message + - confirm dialog namespace usage (`common:actions.*`) +- Refactored cron schedule display parser to return localized strings instead of hardcoded English. +- Added new locale keys in EN/ZH/JA: + - `toast.failedTrigger` + - `schedule.everySeconds/everyMinutes/everyHours/everyDays/onceAt/weeklyAt/monthlyAtDay/dailyAt/unknown` + +### 16. Gateway log noise reduction +- Added stderr classification downgrade for expected loopback websocket transient logs: + - `[ws] handshake timeout ... remote=127.0.0.1` + - `[ws] closed before connect ... remote=127.0.0.1` +- These lines now log at debug level instead of warn during reload/reconnect windows. + +### 17. External gateway shutdown compatibility +- Added capability cache for externally managed Gateway shutdown RPC. +- If `shutdown` is unsupported (`unknown method: shutdown`), mark it unsupported and skip future shutdown RPC attempts to avoid repeated warnings. + +### 18. Chat history sidebar grouping (ChatGPT-style buckets) +- Updated chat session history display in sidebar to time buckets: + - Today / Yesterday / Within 1 Week / Within 2 Weeks / Within 1 Month / Older than 1 Month +- Added `historyBuckets` locale keys in EN/ZH/JA (`chat` namespace). +- Fixed i18n namespace usage for bucket labels in sidebar: + - explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback. +- Removed forced uppercase rendering for bucket headers to preserve localized casing. +- Grouping now applies to all sessions (including `:main`) for consistent bucket visibility and behavior. diff --git a/src/App.tsx b/src/App.tsx index 95a787bb9..c9d7816dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { useSettingsStore } from './stores/settings'; import { useGatewayStore } from './stores/gateway'; +import { applyGatewayTransportPreference } from './lib/api-client'; /** @@ -90,6 +91,7 @@ function App() { const initSettings = useSettingsStore((state) => state.init); const theme = useSettingsStore((state) => state.theme); const language = useSettingsStore((state) => state.language); + const gatewayTransportPreference = useSettingsStore((state) => state.gatewayTransportPreference); const setupComplete = useSettingsStore((state) => state.setupComplete); const initGateway = useGatewayStore((state) => state.init); @@ -149,6 +151,10 @@ function App() { } }, [theme]); + useEffect(() => { + applyGatewayTransportPreference(gatewayTransportPreference); + }, [gatewayTransportPreference]); + return ( diff --git a/src/components/common/FeedbackState.tsx b/src/components/common/FeedbackState.tsx new file mode 100644 index 000000000..c0a105a3e --- /dev/null +++ b/src/components/common/FeedbackState.tsx @@ -0,0 +1,25 @@ +import { AlertCircle, Inbox, Loader2 } from 'lucide-react'; + +interface FeedbackStateProps { + state: 'loading' | 'empty' | 'error'; + title: string; + description?: string; + action?: React.ReactNode; +} + +export function FeedbackState({ state, title, description, action }: FeedbackStateProps) { + const icon = state === 'loading' + ? + : state === 'error' + ? + : ; + + return ( +
+
{icon}
+

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 933703169..e31c4fa7f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ * Navigation sidebar with menu items. * No longer fixed - sits inside the flex layout below the title bar. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Home, @@ -24,8 +24,17 @@ import { useChatStore } from '@/stores/chat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { invokeIpc } from '@/lib/api-client'; import { useTranslation } from 'react-i18next'; +type SessionBucketKey = + | 'today' + | 'yesterday' + | 'withinWeek' + | 'withinTwoWeeks' + | 'withinMonth' + | 'older'; + interface NavItemProps { to: string; icon: React.ReactNode; @@ -66,6 +75,25 @@ function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) { ); } +function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey { + if (!activityMs || activityMs <= 0) return 'older'; + + const now = new Date(nowMs); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + + if (activityMs >= startOfToday) return 'today'; + if (activityMs >= startOfYesterday) return 'yesterday'; + + const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000); + if (daysAgo <= 7) return 'withinWeek'; + if (daysAgo <= 14) return 'withinTwoWeeks'; + if (daysAgo <= 30) return 'withinMonth'; + return 'older'; +} + +const INITIAL_NOW_MS = Date.now(); + export function Sidebar() { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); @@ -82,15 +110,12 @@ export function Sidebar() { const navigate = useNavigate(); const isOnChat = useLocation().pathname === '/'; - const mainSessions = sessions.filter((s) => s.key.endsWith(':main')); - const otherSessions = sessions.filter((s) => !s.key.endsWith(':main')); - const getSessionLabel = (key: string, displayName?: string, label?: string) => sessionLabels[key] ?? label ?? displayName ?? key; const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await invokeIpc('gateway:getControlUiUrl') as { success: boolean; url?: string; error?: string; @@ -105,8 +130,35 @@ export function Sidebar() { } }; - const { t } = useTranslation(); + const { t } = useTranslation(['common', 'chat']); const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); + const [nowMs, setNowMs] = useState(INITIAL_NOW_MS); + + useEffect(() => { + const timer = window.setInterval(() => { + setNowMs(Date.now()); + }, 60 * 1000); + return () => window.clearInterval(timer); + }, []); + const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [ + { key: 'today', label: t('chat:historyBuckets.today'), sessions: [] }, + { key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] }, + { key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] }, + { key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] }, + { key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] }, + { key: 'older', label: t('chat:historyBuckets.older'), sessions: [] }, + ]; + const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record< + SessionBucketKey, + (typeof sessionBuckets)[number] + >; + + for (const session of [...sessions].sort((a, b) => + (sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0) + )) { + const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs); + sessionBucketMap[bucketKey].sessions.push(session); + } const navItems = [ { to: '/cron', icon: , label: t('sidebar.cronTasks') }, @@ -153,43 +205,47 @@ export function Sidebar() { {/* Session list — below Settings, only when expanded */} {!sidebarCollapsed && sessions.length > 0 && (
- {[...mainSessions, ...[...otherSessions].sort((a, b) => - (sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0) - )].map((s) => ( -
- - {!s.key.endsWith(':main') && ( - - )} -
+ {sessionBuckets.map((bucket) => ( + bucket.sessions.length > 0 ? ( +
+
+ {bucket.label} +
+ {bucket.sessions.map((s) => ( +
+ + +
+ ))} +
+ ) : null ))}
)} diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index ba54751a2..57da0f72f 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -6,6 +6,7 @@ import { useState, useEffect } from 'react'; import { Minus, Square, X, Copy } from 'lucide-react'; import logoSvg from '@/assets/logo.svg'; +import { invokeIpc } from '@/lib/api-client'; const isMac = window.electron?.platform === 'darwin'; @@ -23,25 +24,25 @@ function WindowsTitleBar() { useEffect(() => { // Check initial state - window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => { + invokeIpc('window:isMaximized').then((val) => { setMaximized(val as boolean); }); }, []); const handleMinimize = () => { - window.electron.ipcRenderer.invoke('window:minimize'); + invokeIpc('window:minimize'); }; const handleMaximize = () => { - window.electron.ipcRenderer.invoke('window:maximize').then(() => { - window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => { + invokeIpc('window:maximize').then(() => { + invokeIpc('window:isMaximized').then((val) => { setMaximized(val as boolean); }); }); }; const handleClose = () => { - window.electron.ipcRenderer.invoke('window:close'); + invokeIpc('window:close'); }; return ( diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index edb599c7a..d2bdb5fd3 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -35,6 +35,7 @@ import { shouldInvertInDark, } from '@/lib/providers'; import { cn } from '@/lib/utils'; +import { invokeIpc } from '@/lib/api-client'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useSettingsStore } from '@/stores/settings'; @@ -704,7 +705,7 @@ function AddProviderDialog({ setOauthError(null); try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType); + await invokeIpc('provider:requestOAuth', selectedType); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -715,7 +716,7 @@ function AddProviderDialog({ setOauthFlowing(false); setOauthData(null); setOauthError(null); - await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); + await invokeIpc('provider:cancelOAuth'); }; // Only custom can be added multiple times. @@ -1013,7 +1014,7 @@ function AddProviderDialog({ +
+ Tip: switch sessions from the sidebar to keep context clean. + {hasFailedAttachments && ( + + )} +
); diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx index 416bcd6b6..da9b048a7 100644 --- a/src/pages/Chat/ChatMessage.tsx +++ b/src/pages/Chat/ChatMessage.tsx @@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm'; import { createPortal } from 'react-dom'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { invokeIpc } from '@/lib/api-client'; import type { RawMessage, AttachedFileMeta } from '@/stores/chat'; import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils'; @@ -539,7 +540,7 @@ function ImageLightbox({ const handleShowInFolder = useCallback(() => { if (filePath) { - window.electron.ipcRenderer.invoke('shell:showItemInFolder', filePath); + invokeIpc('shell:showItemInFolder', filePath); } }, [filePath]); diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index e6a55a2e5..9da1e36c5 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -37,17 +37,18 @@ import { toast } from 'sonner'; import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron'; import { CHANNEL_ICONS, type ChannelType } from '@/types/channel'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; // Common cron schedule presets -const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [ - { label: 'Every minute', value: '* * * * *', type: 'interval' }, - { label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' }, - { label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' }, - { label: 'Every hour', value: '0 * * * *', type: 'interval' }, - { label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' }, - { label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' }, - { label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' }, - { label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' }, +const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [ + { key: 'everyMinute', value: '* * * * *', type: 'interval' }, + { key: 'every5Min', value: '*/5 * * * *', type: 'interval' }, + { key: 'every15Min', value: '*/15 * * * *', type: 'interval' }, + { key: 'everyHour', value: '0 * * * *', type: 'interval' }, + { key: 'daily9am', value: '0 9 * * *', type: 'daily' }, + { key: 'daily6pm', value: '0 18 * * *', type: 'daily' }, + { key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' }, + { key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' }, ]; // Parse cron schedule to human-readable format @@ -55,25 +56,25 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] = // { kind: "cron", expr: "...", tz?: "..." } // { kind: "every", everyMs: number } // { kind: "at", at: "..." } -function parseCronSchedule(schedule: unknown): string { +function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string { // Handle Gateway CronSchedule object format if (schedule && typeof schedule === 'object') { const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string }; if (s.kind === 'cron' && typeof s.expr === 'string') { - return parseCronExpr(s.expr); + return parseCronExpr(s.expr, t); } if (s.kind === 'every' && typeof s.everyMs === 'number') { const ms = s.everyMs; - if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`; - if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`; - if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`; - return `Every ${Math.round(ms / 86_400_000)} days`; + if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) }); + if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) }); + if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) }); + return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) }); } if (s.kind === 'at' && typeof s.at === 'string') { try { - return `Once at ${new Date(s.at).toLocaleString()}`; + return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() }); } catch { - return `Once at ${s.at}`; + return t('schedule.onceAt', { time: s.at }); } } return String(schedule); @@ -81,39 +82,96 @@ function parseCronSchedule(schedule: unknown): string { // Handle plain cron string if (typeof schedule === 'string') { - return parseCronExpr(schedule); + return parseCronExpr(schedule, t); } - return String(schedule ?? 'Unknown'); + return String(schedule ?? t('schedule.unknown')); } // Parse a plain cron expression string to human-readable text -function parseCronExpr(cron: string): string { +function parseCronExpr(cron: string, t: TFunction<'cron'>): string { const preset = schedulePresets.find((p) => p.value === cron); - if (preset) return preset.label; + if (preset) return t(`presets.${preset.key}` as const); const parts = cron.split(' '); if (parts.length !== 5) return cron; const [minute, hour, dayOfMonth, , dayOfWeek] = parts; - if (minute === '*' && hour === '*') return 'Every minute'; - if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`; - if (hour === '*' && minute === '0') return 'Every hour'; + if (minute === '*' && hour === '*') return t('presets.everyMinute'); + if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) }); + if (hour === '*' && minute === '0') return t('presets.everyHour'); if (dayOfWeek !== '*' && dayOfMonth === '*') { - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`; + return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` }); } if (dayOfMonth !== '*') { - return `Monthly on day ${dayOfMonth} at ${hour}:${minute.padStart(2, '0')}`; + return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` }); } if (hour !== '*') { - return `Daily at ${hour}:${minute.padStart(2, '0')}`; + return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` }); } return cron; } +function estimateNextRun(scheduleExpr: string): string | null { + const now = new Date(); + const next = new Date(now.getTime()); + + if (scheduleExpr === '* * * * *') { + next.setSeconds(0, 0); + next.setMinutes(next.getMinutes() + 1); + return next.toLocaleString(); + } + + if (scheduleExpr === '*/5 * * * *') { + const delta = 5 - (next.getMinutes() % 5 || 5); + next.setSeconds(0, 0); + next.setMinutes(next.getMinutes() + delta); + return next.toLocaleString(); + } + + if (scheduleExpr === '*/15 * * * *') { + const delta = 15 - (next.getMinutes() % 15 || 15); + next.setSeconds(0, 0); + next.setMinutes(next.getMinutes() + delta); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 * * * *') { + next.setMinutes(0, 0, 0); + next.setHours(next.getHours() + 1); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') { + const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18; + next.setSeconds(0, 0); + next.setHours(targetHour, 0, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 9 * * 1') { + next.setSeconds(0, 0); + next.setHours(9, 0, 0, 0); + const day = next.getDay(); + const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7; + next.setDate(next.getDate() + daysUntilMonday); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 9 1 * *') { + next.setSeconds(0, 0); + next.setDate(1); + next.setHours(9, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 1); + return next.toLocaleString(); + } + + return null; +} + // Create/Edit Task Dialog interface TaskDialogProps { job?: CronJob; @@ -141,6 +199,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { const [customSchedule, setCustomSchedule] = useState(''); const [useCustom, setUseCustom] = useState(false); const [enabled, setEnabled] = useState(job?.enabled ?? true); + const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule); const handleSubmit = async () => { if (!name.trim()) { @@ -226,15 +285,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { className="justify-start" > - {preset.label === 'Every minute' ? t('presets.everyMinute') : - preset.label === 'Every 5 minutes' ? t('presets.every5Min') : - preset.label === 'Every 15 minutes' ? t('presets.every15Min') : - preset.label === 'Every hour' ? t('presets.everyHour') : - preset.label === 'Daily at 9am' ? t('presets.daily9am') : - preset.label === 'Daily at 6pm' ? t('presets.daily6pm') : - preset.label === 'Weekly (Mon 9am)' ? t('presets.weeklyMon') : - preset.label === 'Monthly (1st at 9am)' ? t('presets.monthly1st') : - preset.label} + {t(`presets.${preset.key}` as const)} ))} @@ -254,6 +305,9 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { > {useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')} +

+ {schedulePreview ? `${t('card.next')}: ${schedulePreview}` : t('dialog.cronPlaceholder')} +

{/* Enabled */} @@ -270,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { {/* Actions */}
@@ -423,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard @@ -643,10 +697,10 @@ export function Cron() { { if (jobToDelete) { diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index aec779c42..19141eb75 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -12,9 +12,9 @@ import { Settings, Plus, Terminal, - Coins, ChevronLeft, ChevronRight, + Wrench, } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -25,6 +25,9 @@ import { useChannelsStore } from '@/stores/channels'; import { useSkillsStore } from '@/stores/skills'; import { useSettingsStore } from '@/stores/settings'; import { StatusBadge } from '@/components/common/StatusBadge'; +import { FeedbackState } from '@/components/common/FeedbackState'; +import { invokeIpc } from '@/lib/api-client'; +import { trackUiEvent } from '@/lib/telemetry'; import { useTranslation } from 'react-i18next'; type UsageHistoryEntry = { @@ -60,12 +63,13 @@ export function Dashboard() { // Fetch data only when gateway is running useEffect(() => { + trackUiEvent('dashboard.page_viewed'); if (isGatewayRunning) { fetchChannels(); fetchSkills(); - window.electron.ipcRenderer.invoke('usage:recentTokenHistory') + invokeIpc('usage:recentTokenHistory') .then((entries) => { - setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []); + setUsageHistory(Array.isArray(entries) ? entries : []); setUsagePage(1); }) .catch(() => { @@ -107,12 +111,13 @@ export function Dashboard() { const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await invokeIpc<{ success: boolean; url?: string; error?: string; - }; + }>('gateway:getControlUiUrl'); if (result.success && result.url) { + trackUiEvent('dashboard.quick_action', { action: 'dev_console' }); window.electron.openExternal(result.url); } else { console.error('Failed to get Dev Console URL:', result.error); @@ -196,27 +201,39 @@ export function Dashboard() { {t('quickActions.description')} -
+
+ + -
+ + {t('addFirst')} + + )} + /> ) : (
{channels.slice(0, 5).map((channel) => ( @@ -286,13 +305,15 @@ export function Dashboard() { {skills.filter((s) => s.enabled).length === 0 ? ( -
- -

{t('noSkills')}

- -
+ + {t('enableSome')} + + )} + /> ) : (
{skills @@ -322,17 +343,11 @@ export function Dashboard() { {usageLoading ? ( -
{t('recentTokenHistory.loading')}
+ ) : visibleUsageHistory.length === 0 ? ( -
- -

{t('recentTokenHistory.empty')}

-
+ ) : filteredUsageHistory.length === 0 ? ( -
- -

{t('recentTokenHistory.emptyForWindow')}

-
+ ) : (
diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index dfc10d872..5c9dc431a 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -8,6 +8,8 @@ import { Moon, Monitor, RefreshCw, + ChevronDown, + ChevronRight, Terminal, ExternalLink, Key, @@ -28,6 +30,8 @@ import { useGatewayStore } from '@/stores/gateway'; import { useUpdateStore } from '@/stores/update'; import { ProvidersSettings } from '@/components/settings/ProvidersSettings'; import { UpdateSettings } from '@/components/settings/UpdateSettings'; +import { invokeIpc, toUserMessage } from '@/lib/api-client'; +import { trackUiEvent } from '@/lib/telemetry'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; type ControlUiInfo = { @@ -36,6 +40,8 @@ type ControlUiInfo = { port: number; }; +type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; + export function Settings() { const { t } = useTranslation('settings'); const { @@ -51,12 +57,14 @@ export function Settings() { proxyHttpsServer, proxyAllServer, proxyBypassRules, + gatewayTransportPreference, setProxyEnabled, setProxyServer, setProxyHttpServer, setProxyHttpsServer, setProxyAllServer, setProxyBypassRules, + setGatewayTransportPreference, autoCheckUpdate, setAutoCheckUpdate, autoDownloadUpdate, @@ -77,8 +85,17 @@ export function Settings() { const [proxyAllServerDraft, setProxyAllServerDraft] = useState(''); const [proxyBypassRulesDraft, setProxyBypassRulesDraft] = useState(''); const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false); + const [showAdvancedProxy, setShowAdvancedProxy] = useState(false); const [savingProxy, setSavingProxy] = useState(false); + const transportOptions: Array<{ value: GatewayTransportPreference; labelKey: string; descKey: string }> = [ + { value: 'ws-first', labelKey: 'advanced.transport.options.wsFirst', descKey: 'advanced.transport.descriptions.wsFirst' }, + { value: 'http-first', labelKey: 'advanced.transport.options.httpFirst', descKey: 'advanced.transport.descriptions.httpFirst' }, + { value: 'ws-only', labelKey: 'advanced.transport.options.wsOnly', descKey: 'advanced.transport.descriptions.wsOnly' }, + { value: 'http-only', labelKey: 'advanced.transport.options.httpOnly', descKey: 'advanced.transport.descriptions.httpOnly' }, + { value: 'ipc-only', labelKey: 'advanced.transport.options.ipcOnly', descKey: 'advanced.transport.descriptions.ipcOnly' }, + ]; + const isWindows = window.electron.platform === 'win32'; const showCliTools = true; const [showLogs, setShowLogs] = useState(false); @@ -86,7 +103,7 @@ export function Settings() { const handleShowLogs = async () => { try { - const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string; + const logs = await invokeIpc('log:readFile', 100); setLogContent(logs); setShowLogs(true); } catch { @@ -97,9 +114,9 @@ export function Settings() { const handleOpenLogDir = async () => { try { - const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string; + const logDir = await invokeIpc('log:getDir'); if (logDir) { - await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); + await invokeIpc('shell:showItemInFolder', logDir); } } catch { // ignore @@ -109,15 +126,16 @@ export function Settings() { // Open developer console const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await invokeIpc<{ success: boolean; url?: string; token?: string; port?: number; error?: string; - }; + }>('gateway:getControlUiUrl'); if (result.success && result.url && result.token && typeof result.port === 'number') { setControlUiInfo({ url: result.url, token: result.token, port: result.port }); + trackUiEvent('settings.open_dev_console'); window.electron.openExternal(result.url); } else { console.error('Failed to get Dev Console URL:', result.error); @@ -129,12 +147,12 @@ export function Settings() { const refreshControlUiInfo = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await invokeIpc<{ success: boolean; url?: string; token?: string; port?: number; - }; + }>('gateway:getControlUiUrl'); if (result.success && result.url && result.token && typeof result.port === 'number') { setControlUiInfo({ url: result.url, token: result.token, port: result.port }); } @@ -159,11 +177,11 @@ export function Settings() { (async () => { try { - const result = await window.electron.ipcRenderer.invoke('openclaw:getCliCommand') as { + const result = await invokeIpc<{ success: boolean; command?: string; error?: string; - }; + }>('openclaw:getCliCommand'); if (cancelled) return; if (result.success && result.command) { setOpenclawCliCommand(result.command); @@ -235,7 +253,7 @@ export function Settings() { const normalizedHttpsServer = proxyHttpsServerDraft.trim(); const normalizedAllServer = proxyAllServerDraft.trim(); const normalizedBypassRules = proxyBypassRulesDraft.trim(); - await window.electron.ipcRenderer.invoke('settings:setMany', { + await invokeIpc('settings:setMany', { proxyEnabled: proxyEnabledDraft, proxyServer: normalizedProxyServer, proxyHttpServer: normalizedHttpServer, @@ -252,8 +270,9 @@ export function Settings() { setProxyEnabled(proxyEnabledDraft); toast.success(t('gateway.proxySaved')); + trackUiEvent('settings.proxy_saved', { enabled: proxyEnabledDraft }); } catch (error) { - toast.error(`${t('gateway.proxySaveFailed')}: ${String(error)}`); + toast.error(`${t('gateway.proxySaveFailed')}: ${toUserMessage(error)}`); } finally { setSavingProxy(false); } @@ -438,7 +457,22 @@ export function Settings() {
{devModeUnlocked && ( - <> +
+ + {showAdvancedProxy && ( +
- +
+ )} +
)}
@@ -585,6 +621,34 @@ export function Settings() { {t('developer.description')} +
+
+ +

+ {t('advanced.transport.desc')} +

+
+
+ {transportOptions.map((option) => ( + + ))} +
+
+ + +

diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index c0f1d11a6..c926d3834 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -31,6 +31,7 @@ import { useSettingsStore } from '@/stores/settings'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; import { toast } from 'sonner'; +import { invokeIpc } from '@/lib/api-client'; interface SetupStep { id: string; title: string; @@ -146,6 +147,18 @@ export function Setup() { } }, [safeStepIndex, providerConfigured, runtimeChecksPassed]); + // Keep setup flow linear: advance to provider step automatically + // once runtime checks become healthy. + useEffect(() => { + if (safeStepIndex !== STEP.RUNTIME || !runtimeChecksPassed) { + return; + } + const timer = setTimeout(() => { + setCurrentStep(STEP.PROVIDER); + }, 600); + return () => clearTimeout(timer); + }, [runtimeChecksPassed, safeStepIndex]); + const handleNext = async () => { if (isLastStep) { // Complete setup @@ -382,7 +395,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { // Check OpenClaw package status try { - const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as { + const openclawStatus = await invokeIpc('openclaw:status') as { packageExists: boolean; isBuilt: boolean; dir: string; @@ -526,7 +539,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const handleShowLogs = async () => { try { - const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string; + const logs = await invokeIpc('log:readFile', 100) as string; setLogContent(logs); setShowLogs(true); } catch { @@ -537,9 +550,9 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const handleOpenLogDir = async () => { try { - const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string; + const logDir = await invokeIpc('log:getDir') as string; if (logDir) { - await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); + await invokeIpc('shell:showItemInFolder', logDir); } } catch { // ignore @@ -727,7 +740,7 @@ function ProviderContent({ if (selectedProvider) { try { - await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider); + await invokeIpc('provider:setDefault', selectedProvider); } catch (error) { console.error('Failed to set default provider:', error); } @@ -761,7 +774,7 @@ function ProviderContent({ if (!selectedProvider) return; try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>; + const list = await invokeIpc('provider:list') as Array<{ type: string }>; const existingTypes = new Set(list.map(l => l.type)); if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); @@ -780,7 +793,7 @@ function ProviderContent({ setOauthError(null); try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider); + await invokeIpc('provider:requestOAuth', selectedProvider); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -791,7 +804,7 @@ function ProviderContent({ setOauthFlowing(false); setOauthData(null); setOauthError(null); - await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); + await invokeIpc('provider:cancelOAuth'); }; // On mount, try to restore previously configured provider @@ -799,8 +812,8 @@ 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 invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; + const defaultId = await invokeIpc('provider:getDefault') as string | null; const setupProviderTypes = new Set(providers.map((p) => p.id)); const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type)); const preferred = @@ -813,7 +826,7 @@ 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 invokeIpc('provider:getApiKey', preferred.id) as string | null; if (storedKey) { onApiKeyChange(storedKey); } @@ -835,8 +848,8 @@ 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 invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; + const defaultId = await invokeIpc('provider:getDefault') as string | null; const sameType = list.filter((p) => p.type === selectedProvider); const preferredInstance = (defaultId && sameType.find((p) => p.id === defaultId)) @@ -845,11 +858,11 @@ function ProviderContent({ const providerIdForLoad = preferredInstance?.id || selectedProvider; setSelectedProviderConfigId(providerIdForLoad); - const savedProvider = await window.electron.ipcRenderer.invoke( + const savedProvider = await invokeIpc( 'provider:get', providerIdForLoad ) as { baseUrl?: string; model?: string } | null; - const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', providerIdForLoad) as string | null; + const storedKey = await invokeIpc('provider:getApiKey', providerIdForLoad) as string | null; if (!cancelled) { if (storedKey) { onApiKeyChange(storedKey); @@ -906,7 +919,7 @@ function ProviderContent({ if (!selectedProvider) return; try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>; + const list = await invokeIpc('provider:list') as Array<{ type: string }>; const existingTypes = new Set(list.map(l => l.type)); if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); @@ -927,7 +940,7 @@ function ProviderContent({ // Validate key if the provider requires one and a key was entered const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey'); if (isApiKeyRequired && apiKey) { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'provider:validateKey', selectedProviderConfigId || selectedProvider, apiKey, @@ -961,7 +974,7 @@ function ProviderContent({ const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey); // Save provider config + API key, then set as default - const saveResult = await window.electron.ipcRenderer.invoke( + const saveResult = await invokeIpc( 'provider:save', { id: providerIdForSave, @@ -980,7 +993,7 @@ function ProviderContent({ throw new Error(saveResult.error || 'Failed to save provider config'); } - const defaultResult = await window.electron.ipcRenderer.invoke( + const defaultResult = await invokeIpc( 'provider:setDefault', providerIdForSave ) as { success: boolean; error?: string }; @@ -1275,7 +1288,7 @@ function ProviderContent({ + +

diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 4121b3de7..1fe1e5ebd 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { Channel, ChannelType } from '../types/channel'; +import { invokeIpc } from '@/lib/api-client'; interface AddChannelParams { type: ChannelType; @@ -17,7 +18,7 @@ interface ChannelsState { error: string | null; // Actions - fetchChannels: () => Promise; + fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise; addChannel: (params: AddChannelParams) => Promise; deleteChannel: (channelId: string) => Promise; connectChannel: (channelId: string) => Promise; @@ -33,13 +34,17 @@ export const useChannelsStore = create((set, get) => ({ loading: false, error: null, - fetchChannels: async () => { - set({ loading: true, error: null }); + fetchChannels: async (options) => { + const probe = options?.probe ?? false; + const silent = options?.silent ?? false; + if (!silent) { + set({ loading: true, error: null }); + } try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.status', - { probe: true } + { probe } ) as { success: boolean; result?: { @@ -126,20 +131,20 @@ export const useChannelsStore = create((set, get) => ({ }); } - set({ channels, loading: false }); + set((state) => ({ channels, loading: silent ? state.loading : false })); } else { // Gateway not available - try to show channels from local config - set({ channels: [], loading: false }); + set((state) => ({ channels: [], loading: silent ? state.loading : false })); } } catch { // Gateway not connected, show empty - set({ channels: [], loading: false }); + set((state) => ({ channels: [], loading: silent ? state.loading : false })); } }, addChannel: async (params) => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.add', params @@ -184,13 +189,13 @@ export const useChannelsStore = create((set, get) => ({ try { // Delete the channel configuration from openclaw.json - await window.electron.ipcRenderer.invoke('channel:deleteConfig', channelType); + await invokeIpc('channel:deleteConfig', channelType); } catch (error) { console.error('Failed to delete channel config:', error); } try { - await window.electron.ipcRenderer.invoke( + await invokeIpc( 'gateway:rpc', 'channels.delete', { channelId: channelType } @@ -211,7 +216,7 @@ export const useChannelsStore = create((set, get) => ({ updateChannel(channelId, { status: 'connecting', error: undefined }); try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.connect', { channelId } @@ -231,7 +236,7 @@ export const useChannelsStore = create((set, get) => ({ const { updateChannel } = get(); try { - await window.electron.ipcRenderer.invoke( + await invokeIpc( 'gateway:rpc', 'channels.disconnect', { channelId } @@ -244,7 +249,7 @@ export const useChannelsStore = create((set, get) => ({ }, requestQrCode: async (channelType) => { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.requestQr', { type: channelType } diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 78b9dfd54..fce18a0d5 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -4,6 +4,7 @@ * Communicates with OpenClaw Gateway via gateway:rpc IPC. */ import { create } from 'zustand'; +import { invokeIpc } from '@/lib/api-client'; // ── Types ──────────────────────────────────────────────────────── @@ -596,7 +597,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { if (needPreview.length === 0) return false; try { - const thumbnails = await window.electron.ipcRenderer.invoke( + const thumbnails = await invokeIpc( 'media:getThumbnails', needPreview, ) as Record; @@ -928,7 +929,7 @@ export const useChatStore = create((set, get) => ({ loadSessions: async () => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'sessions.list', {} @@ -1001,7 +1002,7 @@ export const useChatStore = create((set, get) => ({ void Promise.all( sessionsToLabel.map(async (session) => { try { - const r = await window.electron.ipcRenderer.invoke( + const r = await invokeIpc( 'gateway:rpc', 'chat.history', { sessionKey: session.key, limit: 1000 }, @@ -1077,7 +1078,7 @@ 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 invokeIpc('session:delete', key) as { success: boolean; error?: string; }; @@ -1185,7 +1186,7 @@ export const useChatStore = create((set, get) => ({ if (!quiet) set({ loading: true, error: null }); try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'chat.history', { sessionKey: currentSessionKey, limit: 200 } @@ -1425,7 +1426,7 @@ export const useChatStore = create((set, get) => ({ const CHAT_SEND_TIMEOUT_MS = 120_000; if (hasMedia) { - result = await window.electron.ipcRenderer.invoke( + result = await invokeIpc( 'chat:sendWithMedia', { sessionKey: currentSessionKey, @@ -1440,7 +1441,7 @@ export const useChatStore = create((set, get) => ({ }, ) as { success: boolean; result?: { runId?: string }; error?: string }; } else { - result = await window.electron.ipcRenderer.invoke( + result = await invokeIpc( 'gateway:rpc', 'chat.send', { @@ -1477,7 +1478,7 @@ export const useChatStore = create((set, get) => ({ set({ streamingTools: [] }); try { - await window.electron.ipcRenderer.invoke( + await invokeIpc( 'gateway:rpc', 'chat.abort', { sessionKey: currentSessionKey }, diff --git a/src/stores/cron.ts b/src/stores/cron.ts index bd4d3b072..8f84578dc 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; +import { invokeIpc } from '@/lib/api-client'; interface CronState { jobs: CronJob[]; @@ -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 invokeIpc('cron:list'); set({ jobs: result, loading: false }); } catch (error) { set({ error: String(error), loading: false }); @@ -38,7 +39,7 @@ export const useCronStore = create((set) => ({ createJob: async (input) => { try { - const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob; + const job = await invokeIpc('cron:create', input); set((state) => ({ jobs: [...state.jobs, job] })); return job; } catch (error) { @@ -49,7 +50,7 @@ export const useCronStore = create((set) => ({ updateJob: async (id, input) => { try { - await window.electron.ipcRenderer.invoke('cron:update', id, input); + await invokeIpc('cron:update', id, input); set((state) => ({ jobs: state.jobs.map((job) => job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job @@ -63,7 +64,7 @@ export const useCronStore = create((set) => ({ deleteJob: async (id) => { try { - await window.electron.ipcRenderer.invoke('cron:delete', id); + await invokeIpc('cron:delete', id); set((state) => ({ jobs: state.jobs.filter((job) => job.id !== id), })); @@ -75,7 +76,7 @@ export const useCronStore = create((set) => ({ toggleJob: async (id, enabled) => { try { - await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled); + await invokeIpc('cron:toggle', id, enabled); set((state) => ({ jobs: state.jobs.map((job) => job.id === id ? { ...job, enabled } : job @@ -89,11 +90,11 @@ export const useCronStore = create((set) => ({ triggerJob: async (id) => { try { - const result = await window.electron.ipcRenderer.invoke('cron:trigger', id); + const result = await invokeIpc('cron:trigger', 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 invokeIpc('cron:list'); set({ jobs }); } catch { // Ignore refresh error diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 1ee8c03f9..53b3e7f2a 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { GatewayStatus } from '../types/gateway'; +import { invokeIpc } from '@/lib/api-client'; let gatewayInitPromise: Promise | null = null; @@ -49,7 +50,7 @@ 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 invokeIpc('gateway:status') as GatewayStatus; set({ status, isInitialized: true }); // Listen for status changes @@ -197,7 +198,7 @@ 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 invokeIpc('gateway:start') as { success: boolean; error?: string }; if (!result.success) { set({ @@ -215,7 +216,7 @@ export const useGatewayStore = create((set, get) => ({ stop: async () => { try { - await window.electron.ipcRenderer.invoke('gateway:stop'); + await invokeIpc('gateway:stop'); set({ status: { ...get().status, state: 'stopped' }, lastError: null }); } catch (error) { console.error('Failed to stop Gateway:', error); @@ -226,7 +227,7 @@ 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 }; + const result = await invokeIpc('gateway:restart') as { success: boolean; error?: string }; if (!result.success) { set({ @@ -244,7 +245,7 @@ export const useGatewayStore = create((set, get) => ({ checkHealth: async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:health') as { + const result = await invokeIpc('gateway:health') as { success: boolean; ok: boolean; error?: string; @@ -267,7 +268,7 @@ 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 { + const result = await invokeIpc('gateway:rpc', method, params, timeoutMs) as { success: boolean; result?: T; error?: string; diff --git a/src/stores/providers.ts b/src/stores/providers.ts index cd5fd6ba9..3f534ce4f 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 { invokeIpc } from '@/lib/api-client'; // Re-export types for consumers that imported from here export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; @@ -45,8 +46,8 @@ 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 invokeIpc('provider:list'); + const defaultId = await invokeIpc('provider:getDefault'); set({ providers, @@ -66,7 +67,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('provider:save', fullConfig, apiKey); if (!result.success) { throw new Error(result.error || 'Failed to save provider'); @@ -95,7 +96,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('provider:save', updatedConfig, apiKey); if (!result.success) { throw new Error(result.error || 'Failed to update provider'); @@ -111,7 +112,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('provider:delete', providerId); if (!result.success) { throw new Error(result.error || 'Failed to delete provider'); @@ -127,7 +128,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('provider:setApiKey', providerId, apiKey); if (!result.success) { throw new Error(result.error || 'Failed to set API key'); @@ -143,12 +144,12 @@ export const useProviderStore = create((set, get) => ({ updateProviderWithKey: async (providerId, updates, apiKey) => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc<{ success: boolean; error?: string }>( 'provider:updateWithKey', providerId, updates, apiKey - ) as { success: boolean; error?: string }; + ); if (!result.success) { throw new Error(result.error || 'Failed to update provider'); @@ -163,7 +164,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('provider:deleteApiKey', providerId); if (!result.success) { throw new Error(result.error || 'Failed to delete API key'); @@ -179,7 +180,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('provider:setDefault', providerId); if (!result.success) { throw new Error(result.error || 'Failed to set default provider'); @@ -194,12 +195,12 @@ export const useProviderStore = create((set, get) => ({ validateApiKey: async (providerId, apiKey, options) => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc<{ valid: boolean; error?: string }>( 'provider:validateKey', providerId, apiKey, options - ) as { valid: boolean; error?: string }; + ); return result; } catch (error) { return { valid: false, error: String(error) }; @@ -208,7 +209,7 @@ export const useProviderStore = create((set, get) => ({ getApiKey: async (providerId) => { try { - return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null; + return await invokeIpc('provider:getApiKey', providerId); } catch { return null; } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 2aefc081f..e5dfb4d7e 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -5,9 +5,11 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; +import { invokeIpc } from '@/lib/api-client'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; +type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; interface SettingsState { // General @@ -25,6 +27,7 @@ interface SettingsState { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; + gatewayTransportPreference: GatewayTransportPreference; // Update updateChannel: UpdateChannel; @@ -52,6 +55,7 @@ interface SettingsState { setProxyHttpsServer: (value: string) => void; setProxyAllServer: (value: string) => void; setProxyBypassRules: (value: string) => void; + setGatewayTransportPreference: (value: GatewayTransportPreference) => void; setUpdateChannel: (channel: UpdateChannel) => void; setAutoCheckUpdate: (value: boolean) => void; setAutoDownloadUpdate: (value: boolean) => void; @@ -79,6 +83,7 @@ const defaultSettings = { proxyHttpsServer: '', proxyAllServer: '', proxyBypassRules: ';localhost;127.0.0.1;::1', + gatewayTransportPreference: 'ws-first' as GatewayTransportPreference, updateChannel: 'stable' as UpdateChannel, autoCheckUpdate: true, autoDownloadUpdate: false, @@ -94,7 +99,7 @@ export const useSettingsStore = create()( init: async () => { try { - const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial; + const settings = await invokeIpc>('settings:getAll'); set((state) => ({ ...state, ...settings })); if (settings.language) { i18n.changeLanguage(settings.language); @@ -106,17 +111,21 @@ 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 invokeIpc('settings:set', 'language', 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 invokeIpc('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, + setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void invokeIpc('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); }, setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }), setProxyServer: (proxyServer) => set({ proxyServer }), setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }), setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }), setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }), setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }), + setGatewayTransportPreference: (gatewayTransportPreference) => { + set({ gatewayTransportPreference }); + void invokeIpc('settings:set', 'gatewayTransportPreference', gatewayTransportPreference).catch(() => {}); + }, setUpdateChannel: (updateChannel) => set({ updateChannel }), setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }), setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }), diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 2ae75f275..a7d07622c 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { Skill, MarketplaceSkill } from '../types/skill'; +import { invokeIpc } from '@/lib/api-client'; type GatewaySkillStatus = { skillKey: string; @@ -70,20 +71,20 @@ export const useSkillsStore = create((set, get) => ({ } try { // 1. Fetch from Gateway (running skills) - const gatewayResult = await window.electron.ipcRenderer.invoke( + const gatewayResult = await invokeIpc>( 'gateway:rpc', 'skills.status' - ) as GatewayRpcResponse; + ); // 2. Fetch from ClawHub (installed on disk) - const clawhubResult = await window.electron.ipcRenderer.invoke( + const clawhubResult = await invokeIpc<{ success: boolean; results?: ClawHubListResult[]; error?: string }>( 'clawhub:list' - ) as { success: boolean; results?: ClawHubListResult[]; error?: string }; + ); // 3. Fetch configurations directly from Electron (since Gateway doesn't return them) - const configResult = await window.electron.ipcRenderer.invoke( + const configResult = await invokeIpc }>>( 'skill:getAllConfigs' - ) as Record }>; + ); let combinedSkills: Skill[] = []; const currentSkills = get().skills; @@ -155,7 +156,7 @@ 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 invokeIpc<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('clawhub:search', { query }); if (result.success) { set({ searchResults: result.results || [] }); } else { @@ -177,7 +178,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('clawhub:install', { slug, version }); if (!result.success) { if (result.error?.includes('Timeout')) { throw new Error('installTimeoutError'); @@ -204,7 +205,7 @@ 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 invokeIpc<{ success: boolean; error?: string }>('clawhub:uninstall', { slug }); if (!result.success) { throw new Error(result.error || 'Uninstall failed'); } @@ -226,11 +227,11 @@ export const useSkillsStore = create((set, get) => ({ const { updateSkill } = get(); try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc>( 'gateway:rpc', 'skills.update', { skillKey: skillId, enabled: true } - ) as GatewayRpcResponse; + ); if (result.success) { updateSkill(skillId, { enabled: true }); @@ -252,11 +253,11 @@ export const useSkillsStore = create((set, get) => ({ } try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc>( 'gateway:rpc', 'skills.update', { skillKey: skillId, enabled: false } - ) as GatewayRpcResponse; + ); if (result.success) { updateSkill(skillId, { enabled: false }); diff --git a/src/stores/update.ts b/src/stores/update.ts index 5949a395f..e082255cf 100644 --- a/src/stores/update.ts +++ b/src/stores/update.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import { useSettingsStore } from './settings'; +import { invokeIpc } from '@/lib/api-client'; export interface UpdateInfo { version: string; @@ -63,7 +64,7 @@ export const useUpdateStore = create((set, get) => ({ // Get current version try { - const version = await window.electron.ipcRenderer.invoke('update:version'); + const version = await invokeIpc('update:version'); set({ currentVersion: version as string }); } catch (error) { console.error('Failed to get version:', error); @@ -71,12 +72,12 @@ export const useUpdateStore = create((set, get) => ({ // Get current status try { - const status = await window.electron.ipcRenderer.invoke('update:status') as { + const status = await invokeIpc<{ status: UpdateStatus; info?: UpdateInfo; progress?: ProgressInfo; error?: string; - }; + }>('update:status'); set({ status: status.status, updateInfo: status.info || null, @@ -117,7 +118,7 @@ export const useUpdateStore = create((set, get) => ({ // Sync auto-download preference to the main process if (autoDownloadUpdate) { - window.electron.ipcRenderer.invoke('update:setAutoDownload', true).catch(() => {}); + invokeIpc('update:setAutoDownload', true).catch(() => {}); } // Auto-check for updates on startup (respects user toggle) @@ -133,7 +134,7 @@ export const useUpdateStore = create((set, get) => ({ try { const result = await Promise.race([ - window.electron.ipcRenderer.invoke('update:check'), + invokeIpc('update:check'), new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000)) ]) as { success: boolean; @@ -172,10 +173,10 @@ export const useUpdateStore = create((set, get) => ({ set({ status: 'downloading', error: null }); try { - const result = await window.electron.ipcRenderer.invoke('update:download') as { + const result = await invokeIpc<{ success: boolean; error?: string; - }; + }>('update:download'); if (!result.success) { set({ status: 'error', error: result.error || 'Failed to download update' }); @@ -186,12 +187,12 @@ export const useUpdateStore = create((set, get) => ({ }, installUpdate: () => { - window.electron.ipcRenderer.invoke('update:install'); + void invokeIpc('update:install'); }, cancelAutoInstall: async () => { try { - await window.electron.ipcRenderer.invoke('update:cancelAutoInstall'); + await invokeIpc('update:cancelAutoInstall'); } catch (error) { console.error('Failed to cancel auto-install:', error); } @@ -199,7 +200,7 @@ export const useUpdateStore = create((set, get) => ({ setChannel: async (channel) => { try { - await window.electron.ipcRenderer.invoke('update:setChannel', channel); + await invokeIpc('update:setChannel', channel); } catch (error) { console.error('Failed to set update channel:', error); } @@ -207,7 +208,7 @@ export const useUpdateStore = create((set, get) => ({ setAutoDownload: async (enable) => { try { - await window.electron.ipcRenderer.invoke('update:setAutoDownload', enable); + await invokeIpc('update:setAutoDownload', enable); } catch (error) { console.error('Failed to set auto-download:', error); } diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts new file mode 100644 index 000000000..124711f46 --- /dev/null +++ b/tests/unit/api-client.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + invokeIpc, + invokeIpcWithRetry, + AppError, + toUserMessage, + configureApiClient, + registerTransportInvoker, + unregisterTransportInvoker, + clearTransportBackoff, +} from '@/lib/api-client'; + +describe('api-client', () => { + beforeEach(() => { + vi.resetAllMocks(); + configureApiClient({ + enabled: { ws: false, http: false }, + rules: [{ matcher: /.*/, order: ['ipc'] }], + }); + clearTransportBackoff(); + unregisterTransportInvoker('ws'); + unregisterTransportInvoker('http'); + }); + + it('forwards invoke arguments and returns result', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } }); + + const result = await invokeIpc<{ ok: boolean }>('settings:getAll', { a: 1 }); + + expect(result.ok).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'app:request', + expect.objectContaining({ + module: 'settings', + action: 'getAll', + }), + ); + }); + + it('normalizes timeout errors', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockRejectedValueOnce(new Error('Gateway Timeout')); + + await expect(invokeIpc('gateway:status')).rejects.toMatchObject({ code: 'TIMEOUT' }); + }); + + it('retries once for retryable errors', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke + .mockResolvedValueOnce({ ok: false, error: { code: 'TIMEOUT', message: 'network timeout' } }) + .mockResolvedValueOnce({ ok: true, data: { success: true } }); + + const result = await invokeIpcWithRetry<{ success: boolean }>('provider:list', [], 1); + + expect(result.success).toBe(true); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it('returns user-facing message for permission error', () => { + const msg = toUserMessage(new AppError('PERMISSION', 'forbidden')); + expect(msg).toContain('Permission denied'); + }); + + it('falls back to legacy channel when unified route is unsupported', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke + .mockRejectedValueOnce(new Error('APP_REQUEST_UNSUPPORTED:settings.getAll')) + .mockResolvedValueOnce({ foo: 'bar' }); + + const result = await invokeIpc<{ foo: string }>('settings:getAll'); + expect(result.foo).toBe('bar'); + expect(invoke).toHaveBeenNthCalledWith(2, 'settings:getAll'); + }); + + it('sends tuple payload for multi-arg unified requests', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ ok: true, data: { success: true } }); + + const result = await invokeIpc<{ success: boolean }>('settings:set', 'language', 'en'); + + expect(result.success).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'app:request', + expect.objectContaining({ + module: 'settings', + action: 'set', + payload: ['language', 'en'], + }), + ); + }); + + it('falls through ws/http and succeeds via ipc when advanced transports fail', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } }); + + registerTransportInvoker('ws', async () => { + throw new Error('ws unavailable'); + }); + registerTransportInvoker('http', async () => { + throw new Error('http unavailable'); + }); + configureApiClient({ + enabled: { ws: true, http: true }, + rules: [{ matcher: 'gateway:rpc', order: ['ws', 'http', 'ipc'] }], + }); + + const result = await invokeIpc<{ ok: boolean }>('gateway:rpc', 'chat.history', {}); + expect(result.ok).toBe(true); + expect(invoke).toHaveBeenCalledWith('gateway:rpc', 'chat.history', {}); + }); + + it('backs off failed ws transport and skips it on immediate retry', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValue({ ok: true }); + const wsInvoker = vi.fn(async () => { + throw new Error('ws unavailable'); + }); + + registerTransportInvoker('ws', wsInvoker); + configureApiClient({ + enabled: { ws: true, http: false }, + rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }], + }); + + await invokeIpc('gateway:rpc', 'chat.history', {}); + await invokeIpc('gateway:rpc', 'chat.history', {}); + + expect(wsInvoker).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it('retries ws transport after backoff is cleared', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValue({ ok: true }); + const wsInvoker = vi.fn(async () => { + throw new Error('ws unavailable'); + }); + + registerTransportInvoker('ws', wsInvoker); + configureApiClient({ + enabled: { ws: true, http: false }, + rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }], + }); + + await invokeIpc('gateway:rpc', 'chat.history', {}); + clearTransportBackoff('ws'); + await invokeIpc('gateway:rpc', 'chat.history', {}); + + expect(wsInvoker).toHaveBeenCalledTimes(2); + expect(invoke).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/feedback-state.test.tsx b/tests/unit/feedback-state.test.tsx new file mode 100644 index 000000000..158b7090a --- /dev/null +++ b/tests/unit/feedback-state.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FeedbackState } from '@/components/common/FeedbackState'; + +describe('FeedbackState', () => { + it('renders loading state content', () => { + render(); + + expect(screen.getByText('Loading data')).toBeInTheDocument(); + expect(screen.getByText('Please wait')).toBeInTheDocument(); + }); + + it('renders action for empty state', () => { + render( + Create one} + />, + ); + + expect(screen.getByRole('button', { name: 'Create one' })).toBeInTheDocument(); + }); + + it('renders error state title', () => { + render(); + + expect(screen.getByText('Request failed')).toBeInTheDocument(); + }); +});