From 4651f8ec5662c8a28c7d47fd7eef6240ea6d8aa2 Mon Sep 17 00:00:00 2001 From: ashione Date: Sat, 7 Mar 2026 23:56:56 +0800 Subject: [PATCH] Save local refactor snapshot and transport settings --- electron/main/ipc-handlers.ts | 792 ++++++++++++++- electron/preload/index.ts | 2 + electron/utils/store.ts | 2 + eslint.config.mjs | 18 + refactor.md | 68 ++ src/App.tsx | 6 + src/components/common/FeedbackState.tsx | 25 + src/components/layout/Sidebar.tsx | 3 +- src/components/layout/TitleBar.tsx | 11 +- src/components/settings/ProvidersSettings.tsx | 7 +- src/i18n/locales/en/dashboard.json | 4 +- src/i18n/locales/en/settings.json | 21 + src/i18n/locales/en/skills.json | 13 +- src/i18n/locales/ja/dashboard.json | 4 +- src/i18n/locales/ja/settings.json | 21 + src/i18n/locales/ja/skills.json | 13 +- src/i18n/locales/zh/dashboard.json | 4 +- src/i18n/locales/zh/settings.json | 21 + src/i18n/locales/zh/skills.json | 13 +- src/lib/api-client.ts | 916 ++++++++++++++++++ src/lib/telemetry.ts | 29 + src/main.tsx | 3 + src/pages/Channels/index.tsx | 61 +- src/pages/Chat/ChatInput.tsx | 24 +- src/pages/Chat/ChatMessage.tsx | 3 +- src/pages/Cron/index.tsx | 62 ++ src/pages/Dashboard/index.tsx | 85 +- src/pages/Settings/index.tsx | 90 +- src/pages/Setup/index.tsx | 55 +- src/pages/Skills/index.tsx | 59 +- src/stores/channels.ts | 33 +- src/stores/chat.ts | 17 +- src/stores/cron.ts | 15 +- src/stores/gateway.ts | 13 +- src/stores/providers.ts | 27 +- src/stores/settings.ts | 17 +- src/stores/skills.ts | 27 +- src/stores/update.ts | 23 +- tests/unit/api-client.test.ts | 153 +++ tests/unit/feedback-state.test.tsx | 30 + 40 files changed, 2596 insertions(+), 194 deletions(-) create mode 100644 refactor.md create mode 100644 src/components/common/FeedbackState.tsx create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/telemetry.ts create mode 100644 tests/unit/api-client.test.ts create mode 100644 tests/unit/feedback-state.test.tsx diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ced2a1b..24ab292 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; + }; +}; /** * For custom/ollama providers, derive a unique key for OpenClaw config files @@ -131,6 +150,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); @@ -186,6 +208,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) @@ -488,6 +1205,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(); @@ -538,6 +1263,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 @@ -2264,4 +3055,3 @@ function registerSessionHandlers(): void { } }); } - diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f0e7fc3..8ed4ae1 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/store.ts b/electron/utils/store.ts index a28fb9a..c3d5005 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 9d45d4d..58e9f2a 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 0000000..1bff1aa --- /dev/null +++ b/refactor.md @@ -0,0 +1,68 @@ +# 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. diff --git a/src/App.tsx b/src/App.tsx index 95a787b..c9d7816 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 0000000..c0a105a --- /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 9337031..19d6f49 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -24,6 +24,7 @@ import { useChatStore } from '@/stores/chat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { invokeIpc } from '@/lib/api-client'; import { useTranslation } from 'react-i18next'; interface NavItemProps { @@ -90,7 +91,7 @@ export function Sidebar() { 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; diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index ba54751..57da0f7 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 edb599c..d2bdb5f 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 416bcd6..da9b048 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 e6a55a2..8be9a53 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -114,6 +114,64 @@ function parseCronExpr(cron: string): string { 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()) { @@ -254,6 +313,9 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { > {useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')} +

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

{/* Enabled */} diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index aec779c..19141eb 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 dfc10d8..5c9dc43 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 c0f1d11..c926d38 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 4121b3d..1fe1e5e 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 78b9dfd..fce18a0 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 bd4d3b0..8f84578 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 1ee8c03..53b3e7f 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 cd5fd6b..3f534ce 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 2aefc08..e5dfb4d 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 2ae75f2..a7d0762 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 5949a39..e082255 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 0000000..124711f --- /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 0000000..158b709 --- /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(); + }); +});