diff --git a/README.md b/README.md index ad6dbe7..266f9fb 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th 3. **Skill Bundles** – Select pre-configured skills for common use cases 4. **Verification** – Test your configuration before entering the main interface +> Note for Moonshot (Kimi): ClawX keeps Kimi web search enabled by default. +> When Moonshot is configured, ClawX also syncs Kimi web search to the China endpoint (`https://api.moonshot.cn/v1`) in OpenClaw config. + ### Proxy Settings ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5bd77d4..3ba17ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -155,6 +155,9 @@ pnpm dev 3. **技能包** – 选择适用于常见场景的预配置技能 4. **验证** – 在进入主界面前测试你的配置 +> Moonshot(Kimi)说明:ClawX 默认保持开启 Kimi 的 web search。 +> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。 + ### 代理设置 ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。 diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 49a9d03..f7622a5 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -93,6 +93,8 @@ export class GatewayManager extends EventEmitter { private readonly connectionMonitor = new GatewayConnectionMonitor(); private readonly lifecycleController = new GatewayLifecycleController(); private readonly restartController = new GatewayRestartController(); + private reloadDebounceTimer: NodeJS.Timeout | null = null; + private externalShutdownSupported: boolean | null = null; constructor(config?: Partial) { super(); @@ -142,6 +144,10 @@ 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); + } /** * Get current Gateway status */ @@ -284,11 +290,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); + } } } @@ -377,6 +389,77 @@ export class GatewayManager extends EventEmitter { }); } + /** + * 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.restartController.isRestartDeferred({ + state: this.status.state, + startLock: this.startLock, + })) { + this.restartController.markDeferredRestart('reload', { + state: this.status.state, + startLock: this.startLock, + }); + 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 */ @@ -387,6 +470,10 @@ export class GatewayManager extends EventEmitter { } this.connectionMonitor.clear(); this.restartController.clearDebounceTimer(); + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + this.reloadDebounceTimer = null; + } } /** diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index c37a7ee..e1c92c2 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -49,6 +49,25 @@ import { syncUpdatedProviderToRuntime, } from '../services/providers/provider-runtime-sync'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; +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; + }; +}; /** * Register all IPC handlers @@ -58,6 +77,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); @@ -65,7 +87,7 @@ export function registerIpcHandlers( registerClawHubHandlers(clawHubService); // OpenClaw handlers - registerOpenClawHandlers(); + registerOpenClawHandlers(gatewayManager); // Provider handlers registerProviderHandlers(gatewayManager); @@ -113,6 +135,545 @@ 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); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(config.id, trimmedKey); + } + } + + try { + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); + } 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 { + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); + } 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 { + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); + } 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 { + await syncDefaultProviderToRuntime(providerId, gatewayManager); + } 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) @@ -415,6 +976,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(); @@ -465,6 +1034,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 @@ -622,7 +1257,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'); @@ -735,9 +1370,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, @@ -745,12 +1383,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); @@ -784,6 +1422,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); @@ -806,6 +1450,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); @@ -1773,4 +2423,3 @@ function registerSessionHandlers(): void { } }); } - diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 67bd06c..8ed4ae1 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -14,6 +14,16 @@ const electronAPI = { ipcRenderer: { invoke: (channel: string, ...args: unknown[]) => { const validChannels = [ + // Gateway + 'gateway:status', + 'gateway:isConnected', + 'gateway:start', + 'gateway:stop', + 'gateway:restart', + 'gateway:rpc', + 'gateway:httpProxy', + 'gateway:health', + 'gateway:getControlUiUrl', // OpenClaw 'openclaw:status', 'openclaw:isReady', @@ -32,11 +42,19 @@ const electronAPI = { 'app:platform', 'app:quit', 'app:relaunch', + 'app:request', // Window controls 'window:minimize', 'window:maximize', 'window:close', 'window:isMaximized', + // Settings + 'settings:get', + 'settings:set', + 'settings:setMany', + 'settings:getAll', + 'settings:reset', + 'usage:recentTokenHistory', // Update 'update:status', 'update:version', @@ -46,9 +64,73 @@ const electronAPI = { 'update:setChannel', 'update:setAutoDownload', 'update:cancelAutoInstall', + // Env + 'env:getConfig', + 'env:setApiKey', + 'env:deleteApiKey', + // Provider + 'provider:list', + 'provider:get', + 'provider:save', + 'provider:delete', + 'provider:setApiKey', + 'provider:updateWithKey', + 'provider:deleteApiKey', + 'provider:hasApiKey', + 'provider:getApiKey', + 'provider:setDefault', + 'provider:getDefault', + 'provider:validateKey', + 'provider:requestOAuth', + 'provider:cancelOAuth', + // Cron + 'cron:list', + 'cron:create', + 'cron:update', + 'cron:delete', + 'cron:toggle', + 'cron:trigger', + // Channel Config + 'channel:saveConfig', + 'channel:getConfig', + 'channel:getFormValues', + 'channel:deleteConfig', + 'channel:listConfigured', + 'channel:setEnabled', + 'channel:validate', + 'channel:validate', + 'channel:validateCredentials', + // WhatsApp + 'channel:requestWhatsAppQr', + 'channel:cancelWhatsAppQr', + // ClawHub + 'clawhub:search', + 'clawhub:install', + 'clawhub:uninstall', + 'clawhub:list', + 'clawhub:openSkillReadme', // UV 'uv:check', 'uv:install-all', + // Skill config (direct file access) + 'skill:updateConfig', + 'skill:getConfig', + 'skill:getAllConfigs', + // Logs + 'log:getRecent', + 'log:readFile', + 'log:getFilePath', + 'log:getDir', + 'log:listFiles', + // File staging & media + 'file:stage', + 'file:stageBuffer', + 'media:getThumbnails', + 'media:saveImage', + // Chat send with media (reads staged files in main process) + 'chat:sendWithMedia', + // Session management + 'session:delete', // OpenClaw extras 'openclaw:getDir', 'openclaw:getConfigDir', @@ -68,6 +150,16 @@ const electronAPI = { */ on: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ + 'gateway:status-changed', + 'gateway:message', + 'gateway:notification', + 'gateway:channel-status', + 'gateway:chat-message', + 'channel:whatsapp-qr', + 'channel:whatsapp-success', + 'channel:whatsapp-error', + 'gateway:exit', + 'gateway:error', 'navigate', 'update:status-changed', 'update:checking', @@ -77,6 +169,10 @@ const electronAPI = { 'update:downloaded', 'update:error', 'update:auto-install-countdown', + 'cron:updated', + 'oauth:code', + 'oauth:success', + 'oauth:error', 'openclaw:cli-installed', ]; @@ -101,6 +197,13 @@ const electronAPI = { */ once: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ + 'gateway:status-changed', + 'gateway:message', + 'gateway:notification', + 'gateway:channel-status', + 'gateway:chat-message', + 'gateway:exit', + 'gateway:error', 'navigate', 'update:status-changed', 'update:checking', @@ -110,6 +213,9 @@ const electronAPI = { 'update:downloaded', 'update:error', 'update:auto-install-countdown', + 'oauth:code', + 'oauth:success', + 'oauth:error', ]; if (validChannels.includes(channel)) { diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index d68c1de..b1678e8 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 d91ba42..16b028a 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -17,14 +17,14 @@ import { getProviderDefaultModel, getProviderConfig, } from './provider-registry'; +import { + OPENCLAW_PROVIDER_KEY_MOONSHOT, + isOAuthProviderType, + isOpenClawOAuthPluginProviderKey, +} from './provider-keys'; const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; -const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn', 'google-gemini-cli']; - -function shouldEnableOAuthPlugin(provider: string): boolean { - return provider === 'minimax-portal' || provider === 'qwen-portal' || provider === 'google-gemini-cli'; -} function getOAuthPluginId(provider: string): string { return `${provider}-auth`; @@ -142,6 +142,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); } @@ -220,7 +229,7 @@ export async function saveProviderKeyToOpenClaw( apiKey: string, agentId?: string ): Promise { - if (OAUTH_PROVIDERS.includes(provider) && !apiKey) { + if (isOAuthProviderType(provider) && !apiKey) { console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`); return; } @@ -254,7 +263,7 @@ export async function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): Promise { - if (OAUTH_PROVIDERS.includes(provider)) { + if (isOAuthProviderType(provider)) { console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); return; } @@ -375,6 +384,7 @@ export async function setOpenClawDefaultModel( fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const rawModel = modelOverride || getProviderDefaultModel(provider); const model = rawModel @@ -407,6 +417,7 @@ export async function setOpenClawDefaultModel( if (providerCfg) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; + const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers); const existingProvider = providers[provider] && typeof providers[provider] === 'object' @@ -443,6 +454,9 @@ export async function setOpenClawDefaultModel( } providers[provider] = providerEntry; console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); + if (removedLegacyMoonshot) { + console.log('Removed legacy models.providers.moonshot alias entry'); + } models.providers = providers; config.models = models; @@ -475,6 +489,32 @@ interface RuntimeProviderConfigOverride { authHeader?: boolean; } +function removeLegacyMoonshotProviderEntry( + _provider: string, + _providers: Record +): boolean { + return false; +} + +function ensureMoonshotKimiWebSearchCnBaseUrl(config: Record, provider: string): void { + if (provider !== OPENCLAW_PROVIDER_KEY_MOONSHOT) return; + + const tools = (config.tools || {}) as Record; + const web = (tools.web || {}) as Record; + const search = (web.search || {}) as Record; + const kimi = (search.kimi && typeof search.kimi === 'object' && !Array.isArray(search.kimi)) + ? (search.kimi as Record) + : {}; + + // Prefer env/auth-profiles for key resolution; stale inline kimi.apiKey can cause persistent 401. + delete kimi.apiKey; + kimi.baseUrl = 'https://api.moonshot.cn/v1'; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; +} + /** * Register or update a provider's configuration in openclaw.json * without changing the current default model. @@ -485,10 +525,12 @@ export async function syncProviderConfigToOpenClaw( override: RuntimeProviderConfigOverride ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); if (override.baseUrl && override.api) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; + removeLegacyMoonshotProviderEntry(provider, providers); const nextModels: Array> = []; if (modelId) nextModels.push({ id: modelId, name: modelId }); @@ -509,7 +551,7 @@ export async function syncProviderConfigToOpenClaw( } // Ensure extension is enabled for oauth providers to prevent gateway wiping config - if (shouldEnableOAuthPlugin(provider)) { + if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; const pEntries = (plugins.entries || {}) as Record; @@ -536,6 +578,7 @@ export async function setOpenClawDefaultModelWithOverride( fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const rawModel = modelOverride || getProviderDefaultModel(provider); const model = rawModel @@ -565,6 +608,7 @@ export async function setOpenClawDefaultModelWithOverride( if (override.baseUrl && override.api) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; + removeLegacyMoonshotProviderEntry(provider, providers); const nextModels: Array> = []; for (const candidateModelId of [modelId, ...fallbackModelIds]) { @@ -596,7 +640,7 @@ export async function setOpenClawDefaultModelWithOverride( config.gateway = gateway; // Ensure the extension plugin is marked as enabled in openclaw.json - if (shouldEnableOAuthPlugin(provider)) { + if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; const pEntries = (plugins.entries || {}) as Record; @@ -810,6 +854,42 @@ 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. + // When ClawX-managed moonshot provider exists, prefer centralized key + // resolution and strip the inline key. + const providers = ((config.models as Record | undefined)?.providers as Record | undefined) || {}; + if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) { + const tools = (config.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const kimi = (search.kimi as Record | undefined) || {}; + if ('apiKey' in kimi) { + console.log('[sanitize] Removing stale key "tools.web.search.kimi.apiKey" from openclaw.json'); + delete kimi.apiKey; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; + modified = true; + } + } + if (modified) { await writeOpenClawJson(config); console.log('[sanitize] openclaw.json sanitized successfully'); diff --git a/electron/utils/provider-keys.ts b/electron/utils/provider-keys.ts new file mode 100644 index 0000000..7dac441 --- /dev/null +++ b/electron/utils/provider-keys.ts @@ -0,0 +1,73 @@ +const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']); + +export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal'; +export const OPENCLAW_PROVIDER_KEY_QWEN = 'qwen-portal'; +export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot'; +export const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'] as const; +export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [ + OPENCLAW_PROVIDER_KEY_MINIMAX, + OPENCLAW_PROVIDER_KEY_QWEN, +] as const; + +const OAUTH_PROVIDER_TYPE_SET = new Set(OAUTH_PROVIDER_TYPES); +const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET = new Set(OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS); + +const PROVIDER_KEY_ALIASES: Record = { + 'minimax-portal-cn': OPENCLAW_PROVIDER_KEY_MINIMAX, +}; + +export function getOpenClawProviderKeyForType(type: string, providerId: string): string { + if (MULTI_INSTANCE_PROVIDER_TYPES.has(type)) { + const suffix = providerId.replace(/-/g, '').slice(0, 8); + return `${type}-${suffix}`; + } + + return PROVIDER_KEY_ALIASES[type] ?? type; +} + +export function isOAuthProviderType(type: string): boolean { + return OAUTH_PROVIDER_TYPE_SET.has(type); +} + +export function isMiniMaxProviderType(type: string): boolean { + return type === OPENCLAW_PROVIDER_KEY_MINIMAX || type === 'minimax-portal-cn'; +} + +export function getOAuthProviderTargetKey(type: string): string | undefined { + if (!isOAuthProviderType(type)) return undefined; + return isMiniMaxProviderType(type) ? OPENCLAW_PROVIDER_KEY_MINIMAX : OPENCLAW_PROVIDER_KEY_QWEN; +} + +export function getOAuthProviderApi(type: string): 'anthropic-messages' | 'openai-completions' | undefined { + if (!isOAuthProviderType(type)) return undefined; + return isMiniMaxProviderType(type) ? 'anthropic-messages' : 'openai-completions'; +} + +export function getOAuthProviderDefaultBaseUrl(type: string): string | undefined { + if (!isOAuthProviderType(type)) return undefined; + if (type === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'https://api.minimax.io/anthropic'; + if (type === 'minimax-portal-cn') return 'https://api.minimaxi.com/anthropic'; + return 'https://portal.qwen.ai/v1'; +} + +export function normalizeOAuthBaseUrl(type: string, baseUrl?: string): string | undefined { + if (!baseUrl) return undefined; + if (isMiniMaxProviderType(type)) { + return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + return baseUrl; +} + +export function usesOAuthAuthHeader(providerKey: string): boolean { + return providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX; +} + +export function getOAuthApiKeyEnv(providerKey: string): string | undefined { + if (providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'minimax-oauth'; + if (providerKey === OPENCLAW_PROVIDER_KEY_QWEN) return 'qwen-oauth'; + return undefined; +} + +export function isOpenClawOAuthPluginProviderKey(provider: string): boolean { + return OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET.has(provider); +} diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index bad00dd..108a5e8 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -32,6 +32,12 @@ export function getProviderEnvVar(type: string): string | undefined { return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar; } +/** Get all environment variable names for a provider type (primary first). */ +export function getProviderEnvVars(type: string): string[] { + const envVar = getProviderEnvVar(type); + return envVar ? [envVar] : []; +} + /** Get the default model string for a provider type */ export function getProviderDefaultModel(type: string): string | undefined { return getSharedProviderDefaultModel(type); diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index b95758e..023d327 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -23,6 +23,7 @@ import { getProviderSecret, setProviderSecret, } from '../services/secrets/secret-store'; +import { getOpenClawProviderKeyForType } from './provider-keys'; /** * Provider configuration @@ -276,9 +277,7 @@ export async function getAllProvidersWithKeyInfo(): Promise< // e.g. provider.id "custom-a1b2c3d4-..." → strip hyphens → "customa1b2c3d4..." → slice(0,8) → "customa1" // → openClawKey = "custom-customa1" // This must match getOpenClawProviderKey() in ipc-handlers.ts exactly. - const openClawKey = (provider.type === 'custom' || provider.type === 'ollama') - ? `${provider.type}-${provider.id.replace(/-/g, '').slice(0, 8)}` - : provider.type === 'minimax-portal-cn' ? 'minimax-portal' : provider.type; + const openClawKey = getOpenClawProviderKeyForType(provider.type, provider.id); if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id) && !activeOpenClawProviders.has(openClawKey)) { console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`); await deleteProvider(provider.id); 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..6230f0e --- /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 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 0fba8cd..26eda17 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,16 @@ 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 { useTranslation } from 'react-i18next'; import { hostApiFetch } from '@/lib/host-api'; +import { useTranslation } from 'react-i18next'; + +type SessionBucketKey = + | 'today' + | 'yesterday' + | 'withinWeek' + | 'withinTwoWeeks' + | 'withinMonth' + | 'older'; interface NavItemProps { to: string; @@ -67,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); @@ -83,9 +110,6 @@ 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; @@ -106,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') }, @@ -154,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 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 bc50177..eb339e8 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -47,6 +47,7 @@ import { import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; +import { invokeIpc } from '@/lib/api-client'; import { useSettingsStore } from '@/stores/settings'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; @@ -1155,7 +1156,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..9da1e36 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 18d5ad8..0d72066 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,8 +25,10 @@ import { useChannelsStore } from '@/stores/channels'; import { useSkillsStore } from '@/stores/skills'; import { useSettingsStore } from '@/stores/settings'; import { StatusBadge } from '@/components/common/StatusBadge'; -import { useTranslation } from 'react-i18next'; +import { FeedbackState } from '@/components/common/FeedbackState'; import { hostApiFetch } from '@/lib/host-api'; +import { trackUiEvent } from '@/lib/telemetry'; +import { useTranslation } from 'react-i18next'; type UsageHistoryEntry = { timestamp: string; @@ -61,12 +63,13 @@ export function Dashboard() { // Fetch data only when gateway is running useEffect(() => { + trackUiEvent('dashboard.page_viewed'); if (isGatewayRunning) { fetchChannels(); fetchSkills(); - hostApiFetch('/api/usage/recent-token-history') + hostApiFetch('/api/usage/recent-token-history') .then((entries) => { - setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []); + setUsageHistory(Array.isArray(entries) ? entries : []); setUsagePage(1); }) .catch(() => { @@ -114,6 +117,7 @@ export function Dashboard() { error?: string; }>('/api/gateway/control-ui'); 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); @@ -197,27 +201,39 @@ export function Dashboard() { {t('quickActions.description')} -
+
+ + -
+ + {t('addFirst')} + + )} + /> ) : (
{channels.slice(0, 5).map((channel) => ( @@ -287,13 +305,15 @@ export function Dashboard() { {skills.filter((s) => s.enabled).length === 0 ? ( -
- -

{t('noSkills')}

- -
+ + {t('enableSome')} + + )} + /> ) : (
{skills @@ -323,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 2c30f01..fe83440 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'; import { hostApiFetch } from '@/lib/host-api'; @@ -37,6 +41,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 { @@ -52,12 +58,14 @@ export function Settings() { proxyHttpsServer, proxyAllServer, proxyBypassRules, + gatewayTransportPreference, setProxyEnabled, setProxyServer, setProxyHttpServer, setProxyHttpsServer, setProxyAllServer, setProxyBypassRules, + setGatewayTransportPreference, autoCheckUpdate, setAutoCheckUpdate, autoDownloadUpdate, @@ -78,8 +86,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); @@ -100,7 +117,7 @@ export function Settings() { try { const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir'); if (logDir) { - await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); + await invokeIpc('shell:showItemInFolder', logDir); } } catch { // ignore @@ -119,6 +136,7 @@ export function Settings() { }>('/api/gateway/control-ui'); if (result.success && result.url && result.token && typeof result.port === 'number') { setControlUiInfo({ url: result.url, token: result.token, port: result.port }); + trackUiEvent('settings.open_dev_console'); window.electron.openExternal(result.url); } else { console.error('Failed to get Dev Console URL:', result.error); @@ -160,11 +178,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); @@ -236,16 +254,13 @@ export function Settings() { const normalizedHttpsServer = proxyHttpsServerDraft.trim(); const normalizedAllServer = proxyAllServerDraft.trim(); const normalizedBypassRules = proxyBypassRulesDraft.trim(); - await hostApiFetch('/api/settings', { - method: 'PUT', - body: JSON.stringify({ + await invokeIpc('settings:setMany', { proxyEnabled: proxyEnabledDraft, proxyServer: normalizedProxyServer, proxyHttpServer: normalizedHttpServer, proxyHttpsServer: normalizedHttpsServer, proxyAllServer: normalizedAllServer, proxyBypassRules: normalizedBypassRules, - }), }); setProxyServer(normalizedProxyServer); @@ -256,8 +271,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); } @@ -442,7 +458,22 @@ export function Settings() {
{devModeUnlocked && ( - <> +
+ + {showAdvancedProxy && ( +
- +
+ )} +
)}
@@ -589,6 +622,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 2a2203b..4786b7c 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'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; interface SetupStep { @@ -392,7 +393,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; @@ -549,7 +550,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { try { const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir'); if (logDir) { - await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); + await invokeIpc('shell:showItemInFolder', logDir); } } catch { // ignore @@ -958,7 +959,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', selectedAccountId || selectedProvider, apiKey, @@ -1330,7 +1331,7 @@ function ProviderContent({ + +

diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 76c53bd..801a676 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -6,9 +6,11 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; import { hostApiFetch } from '@/lib/host-api'; +import { invokeIpc } from '@/lib/api-client'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; +type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; interface SettingsState { // General @@ -26,6 +28,7 @@ interface SettingsState { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; + gatewayTransportPreference: GatewayTransportPreference; // Update updateChannel: UpdateChannel; @@ -53,6 +56,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; @@ -80,6 +84,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, @@ -137,6 +142,10 @@ export const useSettingsStore = create()( 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/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(); + }); +}); diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts index 633defa..54c1b45 100644 --- a/tests/unit/providers.test.ts +++ b/tests/unit/providers.test.ts @@ -10,6 +10,7 @@ import { BUILTIN_PROVIDER_TYPES, getProviderConfig, getProviderEnvVar, + getProviderEnvVars, } from '@electron/utils/provider-registry'; describe('provider metadata', () => { @@ -40,6 +41,17 @@ describe('provider metadata', () => { }); }); + it('uses a single canonical env key for moonshot provider', () => { + expect(getProviderEnvVar('moonshot')).toBe('MOONSHOT_API_KEY'); + expect(getProviderEnvVars('moonshot')).toEqual(['MOONSHOT_API_KEY']); + expect(getProviderConfig('moonshot')).toEqual( + expect.objectContaining({ + baseUrl: 'https://api.moonshot.cn/v1', + apiKeyEnv: 'MOONSHOT_API_KEY', + }) + ); + }); + it('keeps builtin provider sources in sync', () => { expect(BUILTIN_PROVIDER_TYPES).toEqual( expect.arrayContaining(['anthropic', 'openai', 'google', 'openrouter', 'ark', 'moonshot', 'siliconflow', 'minimax-portal', 'minimax-portal-cn', 'qwen-portal', 'ollama']) diff --git a/tests/unit/sanitize-config.test.ts b/tests/unit/sanitize-config.test.ts index 732b226..21a0eb4 100644 --- a/tests/unit/sanitize-config.test.ts +++ b/tests/unit/sanitize-config.test.ts @@ -53,6 +53,23 @@ async function sanitizeConfig(filePath: string): Promise { } } + // Mirror: remove stale tools.web.search.kimi.apiKey when moonshot provider exists. + const providers = ((config.models as Record | undefined)?.providers as Record | undefined) || {}; + if (providers.moonshot) { + const tools = (config.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const kimi = (search.kimi as Record | undefined) || {}; + if ('apiKey' in kimi) { + delete kimi.apiKey; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; + modified = true; + } + } + if (modified) { await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); } @@ -223,4 +240,58 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => { expect(result.gateway).toEqual({ mode: 'local', auth: { token: 'xyz' } }); expect(result.agents).toEqual({ defaults: { model: { primary: 'gpt-4' } } }); }); + + it('removes tools.web.search.kimi.apiKey when moonshot provider exists', async () => { + await writeConfig({ + models: { + providers: { + moonshot: { baseUrl: 'https://api.moonshot.cn/v1', api: 'openai-completions' }, + }, + }, + tools: { + web: { + search: { + kimi: { + apiKey: 'stale-inline-key', + baseUrl: 'https://api.moonshot.cn/v1', + }, + }, + }, + }, + }); + + const modified = await sanitizeConfig(configPath); + expect(modified).toBe(true); + + const result = await readConfig(); + const kimi = ((((result.tools as Record).web as Record).search as Record).kimi as Record); + expect(kimi).not.toHaveProperty('apiKey'); + expect(kimi.baseUrl).toBe('https://api.moonshot.cn/v1'); + }); + + it('keeps tools.web.search.kimi.apiKey when moonshot provider is absent', async () => { + const original = { + models: { + providers: { + openrouter: { baseUrl: 'https://openrouter.ai/api/v1', api: 'openai-completions' }, + }, + }, + tools: { + web: { + search: { + kimi: { + apiKey: 'should-stay', + }, + }, + }, + }, + }; + await writeConfig(original); + + const modified = await sanitizeConfig(configPath); + expect(modified).toBe(false); + + const result = await readConfig(); + expect(result).toEqual(original); + }); });