From 4651f8ec5662c8a28c7d47fd7eef6240ea6d8aa2 Mon Sep 17 00:00:00 2001 From: ashione Date: Sat, 7 Mar 2026 23:56:56 +0800 Subject: [PATCH 1/8] Save local refactor snapshot and transport settings --- electron/main/ipc-handlers.ts | 792 ++++++++++++++- electron/preload/index.ts | 2 + electron/utils/store.ts | 2 + eslint.config.mjs | 18 + refactor.md | 68 ++ src/App.tsx | 6 + src/components/common/FeedbackState.tsx | 25 + src/components/layout/Sidebar.tsx | 3 +- src/components/layout/TitleBar.tsx | 11 +- src/components/settings/ProvidersSettings.tsx | 7 +- src/i18n/locales/en/dashboard.json | 4 +- src/i18n/locales/en/settings.json | 21 + src/i18n/locales/en/skills.json | 13 +- src/i18n/locales/ja/dashboard.json | 4 +- src/i18n/locales/ja/settings.json | 21 + src/i18n/locales/ja/skills.json | 13 +- src/i18n/locales/zh/dashboard.json | 4 +- src/i18n/locales/zh/settings.json | 21 + src/i18n/locales/zh/skills.json | 13 +- src/lib/api-client.ts | 916 ++++++++++++++++++ src/lib/telemetry.ts | 29 + src/main.tsx | 3 + src/pages/Channels/index.tsx | 61 +- src/pages/Chat/ChatInput.tsx | 24 +- src/pages/Chat/ChatMessage.tsx | 3 +- src/pages/Cron/index.tsx | 62 ++ src/pages/Dashboard/index.tsx | 85 +- src/pages/Settings/index.tsx | 90 +- src/pages/Setup/index.tsx | 55 +- src/pages/Skills/index.tsx | 59 +- src/stores/channels.ts | 33 +- src/stores/chat.ts | 17 +- src/stores/cron.ts | 15 +- src/stores/gateway.ts | 13 +- src/stores/providers.ts | 27 +- src/stores/settings.ts | 17 +- src/stores/skills.ts | 27 +- src/stores/update.ts | 23 +- tests/unit/api-client.test.ts | 153 +++ tests/unit/feedback-state.test.tsx | 30 + 40 files changed, 2596 insertions(+), 194 deletions(-) create mode 100644 refactor.md create mode 100644 src/components/common/FeedbackState.tsx create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/telemetry.ts create mode 100644 tests/unit/api-client.test.ts create mode 100644 tests/unit/feedback-state.test.tsx diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ced2a1b..24ab292 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -54,6 +54,25 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; +import { appUpdater } from './updater'; + +type AppRequest = { + id?: string; + module: string; + action: string; + payload?: unknown; +}; + +type AppResponse = { + id?: string; + ok: boolean; + data?: unknown; + error?: { + code: 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED'; + message: string; + details?: unknown; + }; +}; /** * For custom/ollama providers, derive a unique key for OpenClaw config files @@ -131,6 +150,9 @@ export function registerIpcHandlers( clawHubService: ClawHubService, mainWindow: BrowserWindow ): void { + // Unified request protocol (non-breaking: legacy channels remain available) + registerUnifiedRequestHandlers(gatewayManager); + // Gateway handlers registerGatewayHandlers(gatewayManager, mainWindow); @@ -186,6 +208,701 @@ export function registerIpcHandlers( registerFileHandlers(); } +function mapAppErrorCode(error: unknown): AppResponse['error']['code'] { + const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if (msg.includes('timeout')) return 'TIMEOUT'; + if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION'; + if (msg.includes('gateway')) return 'GATEWAY'; + if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION'; + return 'INTERNAL'; +} + +function isProxyKey(key: keyof AppSettings): boolean { + return ( + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + ); +} + +function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { + const handleProxySettingsChange = async () => { + const settings = await getAllSettings(); + await applyProxySettings(settings); + if (gatewayManager.getStatus().state === 'running') { + await gatewayManager.restart(); + } + }; + + ipcMain.handle('app:request', async (_, request: AppRequest): Promise => { + if (!request || typeof request.module !== 'string' || typeof request.action !== 'string') { + return { + id: request?.id, + ok: false, + error: { code: 'VALIDATION', message: 'Invalid app request format' }, + }; + } + + try { + let data: unknown; + switch (request.module) { + case 'app': { + if (request.action === 'version') data = app.getVersion(); + else if (request.action === 'name') data = app.getName(); + else if (request.action === 'platform') data = process.platform; + else { + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + break; + } + case 'provider': { + if (request.action === 'list') { + data = await getAllProvidersWithKeyInfo(); + break; + } + if (request.action === 'get') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.get payload'); + data = await getProvider(providerId); + break; + } + if (request.action === 'getDefault') { + data = await getDefaultProvider(); + break; + } + if (request.action === 'hasApiKey') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.hasApiKey payload'); + data = await hasApiKey(providerId); + break; + } + if (request.action === 'getApiKey') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.getApiKey payload'); + data = await getApiKey(providerId); + break; + } + if (request.action === 'validateKey') { + const payload = request.payload as + | { providerId?: string; apiKey?: string; options?: { baseUrl?: string } } + | [string, string, { baseUrl?: string }?] + | undefined; + const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; + const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; + const options = Array.isArray(payload) ? payload[2] : payload?.options; + if (!providerId || typeof apiKey !== 'string') { + throw new Error('Invalid provider.validateKey payload'); + } + + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; + const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; + data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl }); + break; + } + if (request.action === 'save') { + const payload = request.payload as + | { config?: ProviderConfig; apiKey?: string } + | [ProviderConfig, string?] + | undefined; + const config = Array.isArray(payload) ? payload[0] : payload?.config; + const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; + if (!config) throw new Error('Invalid provider.save payload'); + + try { + await saveProvider(config); + const ock = getOpenClawProviderKey(config.type, config.id); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(config.id, trimmedKey); + try { + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } catch (err) { + console.warn('Failed to save key to OpenClaw auth-profiles:', err); + } + } + } + + try { + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + + if (api) { + await syncProviderConfigToOpenClaw(ock, config.model, { + baseUrl: config.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + + if (config.type === 'custom' || config.type === 'ollama') { + const resolvedKey = apiKey !== undefined + ? (apiKey.trim() || null) + : await getApiKey(config.id); + if (resolvedKey && config.baseUrl) { + const modelId = config.model; + await updateAgentModelProvider(ock, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + + logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`); + gatewayManager.debouncedRestart(); + } + } catch (err) { + console.warn('Failed to sync openclaw provider config:', err); + } + + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'delete') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.delete payload'); + + try { + const existing = await getProvider(providerId); + await deleteProvider(providerId); + if (existing?.type) { + try { + const ock = getOpenClawProviderKey(existing.type, providerId); + await removeProviderFromOpenClaw(ock); + logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`); + gatewayManager.debouncedRestart(); + } catch (err) { + console.warn('Failed to completely remove provider from OpenClaw:', err); + } + } + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'setApiKey') { + const payload = request.payload as + | { providerId?: string; apiKey?: string } + | [string, string] + | undefined; + const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; + const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; + if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload'); + + try { + await storeApiKey(providerId, apiKey); + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + const ock = getOpenClawProviderKey(providerType, providerId); + try { + await saveProviderKeyToOpenClaw(ock, apiKey); + } catch (err) { + console.warn('Failed to save key to OpenClaw auth-profiles:', err); + } + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'updateWithKey') { + const payload = request.payload as + | { providerId?: string; updates?: Partial; apiKey?: string } + | [string, Partial, string?] + | undefined; + const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; + const updates = Array.isArray(payload) ? payload[1] : payload?.updates; + const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey; + if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload'); + + const existing = await getProvider(providerId); + if (!existing) { + data = { success: false, error: 'Provider not found' }; + break; + } + + const previousKey = await getApiKey(providerId); + const previousOck = getOpenClawProviderKey(existing.type, providerId); + + try { + const nextConfig: ProviderConfig = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + const ock = getOpenClawProviderKey(nextConfig.type, providerId); + await saveProvider(nextConfig); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(providerId, trimmedKey); + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } else { + await deleteApiKey(providerId); + await removeProviderFromOpenClaw(ock); + } + } + + try { + const fallbackModels = await getProviderFallbackModelRefs(nextConfig); + const meta = getProviderConfig(nextConfig.type); + const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; + + if (api) { + await syncProviderConfigToOpenClaw(ock, nextConfig.model, { + baseUrl: nextConfig.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + + if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') { + const resolvedKey = apiKey !== undefined + ? (apiKey.trim() || null) + : await getApiKey(providerId); + if (resolvedKey && nextConfig.baseUrl) { + const modelId = nextConfig.model; + await updateAgentModelProvider(ock, { + baseUrl: nextConfig.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + } + + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId === providerId) { + const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined; + if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } else { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: nextConfig.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } + } + + logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`); + gatewayManager.debouncedRestart(); + } catch (err) { + console.warn('Failed to sync openclaw config after provider update:', err); + } + + data = { success: true }; + } catch (error) { + try { + await saveProvider(existing); + if (previousKey) { + await storeApiKey(providerId, previousKey); + await saveProviderKeyToOpenClaw(previousOck, previousKey); + } else { + await deleteApiKey(providerId); + await removeProviderFromOpenClaw(previousOck); + } + } catch (rollbackError) { + console.warn('Failed to rollback provider updateWithKey:', rollbackError); + } + + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'deleteApiKey') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.deleteApiKey payload'); + try { + await deleteApiKey(providerId); + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + const ock = getOpenClawProviderKey(providerType, providerId); + try { + if (ock) { + await removeProviderFromOpenClaw(ock); + } + } catch (err) { + console.warn('Failed to completely remove provider from OpenClaw:', err); + } + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'setDefault') { + const payload = request.payload as { providerId?: string } | string | undefined; + const providerId = typeof payload === 'string' ? payload : payload?.providerId; + if (!providerId) throw new Error('Invalid provider.setDefault payload'); + + try { + await setDefaultProvider(providerId); + const provider = await getProvider(providerId); + if (provider) { + try { + const ock = getOpenClawProviderKey(provider.type, providerId); + const providerKey = await getApiKey(providerId); + const fallbackModels = await getProviderFallbackModelRefs(provider); + const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; + const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey; + + if (!isOAuthProvider) { + const modelOverride = provider.model + ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) + : undefined; + if (provider.type === 'custom' || provider.type === 'ollama') { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } else { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } + if (providerKey) { + await saveProviderKeyToOpenClaw(ock, providerKey); + } + } else { + const defaultBaseUrl = provider.type === 'minimax-portal' + ? 'https://api.minimax.io/anthropic' + : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); + const api: 'anthropic-messages' | 'openai-completions' = + (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'anthropic-messages' + : 'openai-completions'; + + let baseUrl = provider.baseUrl || defaultBaseUrl; + if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { + baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + + const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'minimax-portal' + : provider.type; + + await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }, fallbackModels); + + try { + const defaultModelId = provider.model?.split('/').pop(); + await updateAgentModelProvider(targetProviderKey, { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], + }); + } catch (err) { + logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); + } + } + + if ( + (provider.type === 'custom' || provider.type === 'ollama') && + providerKey && + provider.baseUrl + ) { + const modelId = provider.model; + await updateAgentModelProvider(ock, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: providerKey, + }); + } + + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`); + gatewayManager.debouncedRestart(); + } + } catch (err) { + console.warn('Failed to set OpenClaw default model:', err); + } + } + + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'update': { + if (request.action === 'status') { + data = appUpdater.getStatus(); + break; + } + if (request.action === 'version') { + data = appUpdater.getCurrentVersion(); + break; + } + if (request.action === 'check') { + try { + await appUpdater.checkForUpdates(); + data = { success: true, status: appUpdater.getStatus() }; + } catch (error) { + data = { success: false, error: String(error), status: appUpdater.getStatus() }; + } + break; + } + if (request.action === 'download') { + try { + await appUpdater.downloadUpdate(); + data = { success: true }; + } catch (error) { + data = { success: false, error: String(error) }; + } + break; + } + if (request.action === 'install') { + appUpdater.quitAndInstall(); + data = { success: true }; + break; + } + if (request.action === 'setChannel') { + const payload = request.payload as { channel?: 'stable' | 'beta' | 'dev' } | 'stable' | 'beta' | 'dev' | undefined; + const channel = typeof payload === 'string' ? payload : payload?.channel; + if (!channel) throw new Error('Invalid update.setChannel payload'); + appUpdater.setChannel(channel); + data = { success: true }; + break; + } + if (request.action === 'setAutoDownload') { + const payload = request.payload as { enable?: boolean } | boolean | undefined; + const enable = typeof payload === 'boolean' ? payload : payload?.enable; + if (typeof enable !== 'boolean') throw new Error('Invalid update.setAutoDownload payload'); + appUpdater.setAutoDownload(enable); + data = { success: true }; + break; + } + if (request.action === 'cancelAutoInstall') { + appUpdater.cancelAutoInstall(); + data = { success: true }; + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'cron': { + if (request.action === 'list') { + const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); + const jobs = (result as { jobs?: GatewayCronJob[] })?.jobs ?? []; + data = jobs.map(transformCronJob); + break; + } + if (request.action === 'create') { + const payload = request.payload as + | { input?: { name: string; message: string; schedule: string; enabled?: boolean } } + | [{ name: string; message: string; schedule: string; enabled?: boolean }] + | { name: string; message: string; schedule: string; enabled?: boolean } + | undefined; + const input = Array.isArray(payload) + ? payload[0] + : ('input' in (payload ?? {}) ? (payload as { input: { name: string; message: string; schedule: string; enabled?: boolean } }).input : payload); + if (!input) throw new Error('Invalid cron.create payload'); + const gatewayInput = { + name: input.name, + schedule: { kind: 'cron', expr: input.schedule }, + payload: { kind: 'agentTurn', message: input.message }, + enabled: input.enabled ?? true, + wakeMode: 'next-heartbeat', + sessionTarget: 'isolated', + delivery: { mode: 'none' }, + }; + const created = await gatewayManager.rpc('cron.add', gatewayInput); + data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created; + break; + } + if (request.action === 'update') { + const payload = request.payload as + | { id?: string; input?: Record } + | [string, Record] + | undefined; + const id = Array.isArray(payload) ? payload[0] : payload?.id; + const input = Array.isArray(payload) ? payload[1] : payload?.input; + if (!id || !input) throw new Error('Invalid cron.update payload'); + const patch = { ...input }; + if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule }; + if (typeof patch.message === 'string') { + patch.payload = { kind: 'agentTurn', message: patch.message }; + delete patch.message; + } + data = await gatewayManager.rpc('cron.update', { id, patch }); + break; + } + if (request.action === 'delete') { + const payload = request.payload as { id?: string } | string | undefined; + const id = typeof payload === 'string' ? payload : payload?.id; + if (!id) throw new Error('Invalid cron.delete payload'); + data = await gatewayManager.rpc('cron.remove', { id }); + break; + } + if (request.action === 'toggle') { + const payload = request.payload as { id?: string; enabled?: boolean } | [string, boolean] | undefined; + const id = Array.isArray(payload) ? payload[0] : payload?.id; + const enabled = Array.isArray(payload) ? payload[1] : payload?.enabled; + if (!id || typeof enabled !== 'boolean') throw new Error('Invalid cron.toggle payload'); + data = await gatewayManager.rpc('cron.update', { id, patch: { enabled } }); + break; + } + if (request.action === 'trigger') { + const payload = request.payload as { id?: string } | string | undefined; + const id = typeof payload === 'string' ? payload : payload?.id; + if (!id) throw new Error('Invalid cron.trigger payload'); + data = await gatewayManager.rpc('cron.run', { id, mode: 'force' }); + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'usage': { + if (request.action === 'recentTokenHistory') { + const payload = request.payload as { limit?: number } | number | undefined; + const limit = typeof payload === 'number' ? payload : payload?.limit; + const safeLimit = typeof limit === 'number' && Number.isFinite(limit) + ? Math.max(Math.floor(limit), 1) + : undefined; + data = await getRecentTokenUsageHistory(safeLimit); + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + case 'settings': { + if (request.action === 'getAll') { + data = await getAllSettings(); + break; + } + if (request.action === 'get') { + const payload = request.payload as { key?: keyof AppSettings } | [keyof AppSettings] | undefined; + const key = Array.isArray(payload) ? payload[0] : payload?.key; + if (!key) throw new Error('Invalid settings.get payload'); + data = await getSetting(key); + break; + } + if (request.action === 'set') { + const payload = request.payload as + | { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] } + | [keyof AppSettings, AppSettings[keyof AppSettings]] + | undefined; + const key = Array.isArray(payload) ? payload[0] : payload?.key; + const value = Array.isArray(payload) ? payload[1] : payload?.value; + if (!key) throw new Error('Invalid settings.set payload'); + await setSetting(key, value as never); + if (isProxyKey(key)) { + await handleProxySettingsChange(); + } + data = { success: true }; + break; + } + if (request.action === 'setMany') { + const patch = (request.payload ?? {}) as Partial; + const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; + for (const [key, value] of entries) { + await setSetting(key, value as never); + } + if (entries.some(([key]) => isProxyKey(key))) { + await handleProxySettingsChange(); + } + data = { success: true }; + break; + } + if (request.action === 'reset') { + await resetSettings(); + const settings = await getAllSettings(); + await handleProxySettingsChange(); + data = { success: true, settings }; + break; + } + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + default: + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, + }, + }; + } + + return { id: request.id, ok: true, data }; + } catch (error) { + return { + id: request.id, + ok: false, + error: { + code: mapAppErrorCode(error), + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }); +} + /** * Skill config IPC handlers * Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC) @@ -488,6 +1205,14 @@ function registerGatewayHandlers( gatewayManager: GatewayManager, mainWindow: BrowserWindow ): void { + type GatewayHttpProxyRequest = { + path?: string; + method?: string; + headers?: Record; + body?: unknown; + timeoutMs?: number; + }; + // Get Gateway status ipcMain.handle('gateway:status', () => { return gatewayManager.getStatus(); @@ -538,6 +1263,72 @@ function registerGatewayHandlers( } }); + // Gateway HTTP proxy + // Renderer must not call gateway HTTP directly (CORS); all HTTP traffic + // should go through this main-process proxy. + ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => { + try { + const status = gatewayManager.getStatus(); + const port = status.port || 18789; + const path = request?.path && request.path.startsWith('/') ? request.path : '/'; + const method = (request?.method || 'GET').toUpperCase(); + const timeoutMs = + typeof request?.timeoutMs === 'number' && request.timeoutMs > 0 + ? request.timeoutMs + : 15000; + + const token = await getSetting('gatewayToken'); + const headers: Record = { + ...(request?.headers ?? {}), + }; + if (!headers.Authorization && !headers.authorization && token) { + headers.Authorization = `Bearer ${token}`; + } + + let body: string | undefined; + if (request?.body !== undefined && request?.body !== null) { + body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body); + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, { + method, + headers, + body, + signal: controller.signal, + }); + clearTimeout(timer); + + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const json = await response.json(); + return { + success: true, + status: response.status, + ok: response.ok, + json, + }; + } + + const text = await response.text(); + return { + success: true, + status: response.status, + ok: response.ok, + text, + }; + } catch (error) { + return { + success: false, + error: String(error), + }; + } + }); + // Chat send with media — reads staged files from disk and builds attachments. // Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments. // All other files are referenced by path in the message text so the model @@ -2264,4 +3055,3 @@ function registerSessionHandlers(): void { } }); } - diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f0e7fc3..8ed4ae1 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -21,6 +21,7 @@ const electronAPI = { 'gateway:stop', 'gateway:restart', 'gateway:rpc', + 'gateway:httpProxy', 'gateway:health', 'gateway:getControlUiUrl', // OpenClaw @@ -41,6 +42,7 @@ const electronAPI = { 'app:platform', 'app:quit', 'app:relaunch', + 'app:request', // Window controls 'window:minimize', 'window:maximize', diff --git a/electron/utils/store.ts b/electron/utils/store.ts index a28fb9a..c3d5005 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -36,6 +36,7 @@ export interface AppSettings { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; + gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; // Update updateChannel: 'stable' | 'beta' | 'dev'; @@ -73,6 +74,7 @@ const defaults: AppSettings = { proxyHttpsServer: '', proxyAllServer: '', proxyBypassRules: ';localhost;127.0.0.1;::1', + gatewayTransportPreference: 'ws-first', // Update updateChannel: 'stable', diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d45d4d..58e9f2a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,4 +40,22 @@ export default [ '@typescript-eslint/no-explicit-any': 'warn', }, }, + { + files: ['src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']", + message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.', + }, + ], + }, + }, + { + files: ['src/lib/api-client.ts'], + rules: { + 'no-restricted-syntax': 'off', + }, + }, ]; diff --git a/refactor.md b/refactor.md new file mode 100644 index 0000000..1bff1aa --- /dev/null +++ b/refactor.md @@ -0,0 +1,68 @@ +# Refactor Summary + +## Scope +This branch captures local refactors focused on frontend UX polish, IPC call consolidation, transport abstraction, and channel page responsiveness. + +## Key Changes + +### 1. Frontend IPC consolidation +- Replaced scattered direct `window.electron.ipcRenderer.invoke(...)` calls with unified `invokeIpc(...)` usage. +- Added lint guard to prevent new direct renderer IPC invokes outside the API layer. +- Introduced a centralized API client with: + - error normalization (`AppError`) + - unified `app:request` support + compatibility fallback + - retry helper for timeout/network errors + +### 2. Transport abstraction (extensible protocol layer) +- Added transport routing abstraction inside `src/lib/api-client.ts`: + - `ipc`, `ws`, `http` + - rule-based channel routing + - transport registration/unregistration + - failure backoff and fallback behavior +- Added default transport initialization in app entry. +- Added gateway-specific transport adapters for WS/HTTP. + +### 3. HTTP path moved to Electron main-process proxy +- Added `gateway:httpProxy` IPC handler in main process to avoid renderer-side CORS issues. +- Preload allowlist updated for `gateway:httpProxy`. +- Gateway HTTP transport now uses IPC proxy instead of browser `fetch` direct-to-gateway. + +### 4. Settings improvements (Developer-focused transport control) +- Added persisted setting `gatewayTransportPreference`. +- Added runtime application of transport preference in app bootstrap. +- Added UI option (Developer section) to choose routing strategy: + - WS First / HTTP First / WS Only / HTTP Only / IPC Only +- Added i18n strings for EN/ZH/JA. + +### 5. Channel page performance optimization +- `fetchChannels` now supports options: + - `probe` (manual refresh can force probe) + - `silent` (background refresh without full-page loading lock) +- Channel status event refresh now debounced (300ms) to reduce refresh storms. +- Initial loading spinner only shown when no existing data. +- Manual refresh uses local spinner state and non-blocking update. + +### 6. UX and component enhancements +- Added shared feedback state component for consistent empty/loading/error states. +- Added telemetry helpers and quick-action/dashboard refinements. +- Setup/settings/providers/chat/skills/cron pages received targeted UX and reliability fixes. + +### 7. IPC main handler compatibility improvements +- Expanded `app:request` coverage for provider/update/settings/cron/usage actions. +- Unsupported app requests now return structured error response instead of throwing, reducing noisy handler exceptions. + +### 8. Tests +- Added unit tests for API client behavior and feedback state rendering. +- Added transport fallback/backoff coverage in API client tests. + +## Files Added +- `src/lib/api-client.ts` +- `src/lib/telemetry.ts` +- `src/components/common/FeedbackState.tsx` +- `tests/unit/api-client.test.ts` +- `tests/unit/feedback-state.test.tsx` +- `refactor.md` + +## Notes +- Navigation order in sidebar is kept aligned with `main` ordering. +- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work. diff --git a/src/App.tsx b/src/App.tsx index 95a787b..c9d7816 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { useSettingsStore } from './stores/settings'; import { useGatewayStore } from './stores/gateway'; +import { applyGatewayTransportPreference } from './lib/api-client'; /** @@ -90,6 +91,7 @@ function App() { const initSettings = useSettingsStore((state) => state.init); const theme = useSettingsStore((state) => state.theme); const language = useSettingsStore((state) => state.language); + const gatewayTransportPreference = useSettingsStore((state) => state.gatewayTransportPreference); const setupComplete = useSettingsStore((state) => state.setupComplete); const initGateway = useGatewayStore((state) => state.init); @@ -149,6 +151,10 @@ function App() { } }, [theme]); + useEffect(() => { + applyGatewayTransportPreference(gatewayTransportPreference); + }, [gatewayTransportPreference]); + return ( diff --git a/src/components/common/FeedbackState.tsx b/src/components/common/FeedbackState.tsx new file mode 100644 index 0000000..c0a105a --- /dev/null +++ b/src/components/common/FeedbackState.tsx @@ -0,0 +1,25 @@ +import { AlertCircle, Inbox, Loader2 } from 'lucide-react'; + +interface FeedbackStateProps { + state: 'loading' | 'empty' | 'error'; + title: string; + description?: string; + action?: React.ReactNode; +} + +export function FeedbackState({ state, title, description, action }: FeedbackStateProps) { + const icon = state === 'loading' + ? + : state === 'error' + ? + : ; + + return ( +
+
{icon}
+

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9337031..19d6f49 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -24,6 +24,7 @@ import { useChatStore } from '@/stores/chat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { invokeIpc } from '@/lib/api-client'; import { useTranslation } from 'react-i18next'; interface NavItemProps { @@ -90,7 +91,7 @@ export function Sidebar() { const openDevConsole = async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { + const result = await invokeIpc('gateway:getControlUiUrl') as { success: boolean; url?: string; error?: string; diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index ba54751..57da0f7 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -6,6 +6,7 @@ import { useState, useEffect } from 'react'; import { Minus, Square, X, Copy } from 'lucide-react'; import logoSvg from '@/assets/logo.svg'; +import { invokeIpc } from '@/lib/api-client'; const isMac = window.electron?.platform === 'darwin'; @@ -23,25 +24,25 @@ function WindowsTitleBar() { useEffect(() => { // Check initial state - window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => { + invokeIpc('window:isMaximized').then((val) => { setMaximized(val as boolean); }); }, []); const handleMinimize = () => { - window.electron.ipcRenderer.invoke('window:minimize'); + invokeIpc('window:minimize'); }; const handleMaximize = () => { - window.electron.ipcRenderer.invoke('window:maximize').then(() => { - window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => { + invokeIpc('window:maximize').then(() => { + invokeIpc('window:isMaximized').then((val) => { setMaximized(val as boolean); }); }); }; const handleClose = () => { - window.electron.ipcRenderer.invoke('window:close'); + invokeIpc('window:close'); }; return ( diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index edb599c..d2bdb5f 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -35,6 +35,7 @@ import { shouldInvertInDark, } from '@/lib/providers'; import { cn } from '@/lib/utils'; +import { invokeIpc } from '@/lib/api-client'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useSettingsStore } from '@/stores/settings'; @@ -704,7 +705,7 @@ function AddProviderDialog({ setOauthError(null); try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType); + await invokeIpc('provider:requestOAuth', selectedType); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -715,7 +716,7 @@ function AddProviderDialog({ setOauthFlowing(false); setOauthData(null); setOauthError(null); - await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); + await invokeIpc('provider:cancelOAuth'); }; // Only custom can be added multiple times. @@ -1013,7 +1014,7 @@ function AddProviderDialog({ +
+ Tip: switch sessions from the sidebar to keep context clean. + {hasFailedAttachments && ( + + )} +
); diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx index 416bcd6..da9b048 100644 --- a/src/pages/Chat/ChatMessage.tsx +++ b/src/pages/Chat/ChatMessage.tsx @@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm'; import { createPortal } from 'react-dom'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { invokeIpc } from '@/lib/api-client'; import type { RawMessage, AttachedFileMeta } from '@/stores/chat'; import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils'; @@ -539,7 +540,7 @@ function ImageLightbox({ const handleShowInFolder = useCallback(() => { if (filePath) { - window.electron.ipcRenderer.invoke('shell:showItemInFolder', filePath); + invokeIpc('shell:showItemInFolder', filePath); } }, [filePath]); diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index e6a55a2..8be9a53 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -114,6 +114,64 @@ function parseCronExpr(cron: string): string { return cron; } +function estimateNextRun(scheduleExpr: string): string | null { + const now = new Date(); + const next = new Date(now.getTime()); + + if (scheduleExpr === '* * * * *') { + next.setSeconds(0, 0); + next.setMinutes(next.getMinutes() + 1); + return next.toLocaleString(); + } + + if (scheduleExpr === '*/5 * * * *') { + const delta = 5 - (next.getMinutes() % 5 || 5); + next.setSeconds(0, 0); + next.setMinutes(next.getMinutes() + delta); + return next.toLocaleString(); + } + + if (scheduleExpr === '*/15 * * * *') { + const delta = 15 - (next.getMinutes() % 15 || 15); + next.setSeconds(0, 0); + next.setMinutes(next.getMinutes() + delta); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 * * * *') { + next.setMinutes(0, 0, 0); + next.setHours(next.getHours() + 1); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') { + const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18; + next.setSeconds(0, 0); + next.setHours(targetHour, 0, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 9 * * 1') { + next.setSeconds(0, 0); + next.setHours(9, 0, 0, 0); + const day = next.getDay(); + const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7; + next.setDate(next.getDate() + daysUntilMonday); + return next.toLocaleString(); + } + + if (scheduleExpr === '0 9 1 * *') { + next.setSeconds(0, 0); + next.setDate(1); + next.setHours(9, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 1); + return next.toLocaleString(); + } + + return null; +} + // Create/Edit Task Dialog interface TaskDialogProps { job?: CronJob; @@ -141,6 +199,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { const [customSchedule, setCustomSchedule] = useState(''); const [useCustom, setUseCustom] = useState(false); const [enabled, setEnabled] = useState(job?.enabled ?? true); + const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule); const handleSubmit = async () => { if (!name.trim()) { @@ -254,6 +313,9 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { > {useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')} +

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

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

{t('noSkills')}

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

{t('recentTokenHistory.empty')}

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

{t('recentTokenHistory.emptyForWindow')}

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

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

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

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

diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 4121b3d..1fe1e5e 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { Channel, ChannelType } from '../types/channel'; +import { invokeIpc } from '@/lib/api-client'; interface AddChannelParams { type: ChannelType; @@ -17,7 +18,7 @@ interface ChannelsState { error: string | null; // Actions - fetchChannels: () => Promise; + fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise; addChannel: (params: AddChannelParams) => Promise; deleteChannel: (channelId: string) => Promise; connectChannel: (channelId: string) => Promise; @@ -33,13 +34,17 @@ export const useChannelsStore = create((set, get) => ({ loading: false, error: null, - fetchChannels: async () => { - set({ loading: true, error: null }); + fetchChannels: async (options) => { + const probe = options?.probe ?? false; + const silent = options?.silent ?? false; + if (!silent) { + set({ loading: true, error: null }); + } try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.status', - { probe: true } + { probe } ) as { success: boolean; result?: { @@ -126,20 +131,20 @@ export const useChannelsStore = create((set, get) => ({ }); } - set({ channels, loading: false }); + set((state) => ({ channels, loading: silent ? state.loading : false })); } else { // Gateway not available - try to show channels from local config - set({ channels: [], loading: false }); + set((state) => ({ channels: [], loading: silent ? state.loading : false })); } } catch { // Gateway not connected, show empty - set({ channels: [], loading: false }); + set((state) => ({ channels: [], loading: silent ? state.loading : false })); } }, addChannel: async (params) => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.add', params @@ -184,13 +189,13 @@ export const useChannelsStore = create((set, get) => ({ try { // Delete the channel configuration from openclaw.json - await window.electron.ipcRenderer.invoke('channel:deleteConfig', channelType); + await invokeIpc('channel:deleteConfig', channelType); } catch (error) { console.error('Failed to delete channel config:', error); } try { - await window.electron.ipcRenderer.invoke( + await invokeIpc( 'gateway:rpc', 'channels.delete', { channelId: channelType } @@ -211,7 +216,7 @@ export const useChannelsStore = create((set, get) => ({ updateChannel(channelId, { status: 'connecting', error: undefined }); try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.connect', { channelId } @@ -231,7 +236,7 @@ export const useChannelsStore = create((set, get) => ({ const { updateChannel } = get(); try { - await window.electron.ipcRenderer.invoke( + await invokeIpc( 'gateway:rpc', 'channels.disconnect', { channelId } @@ -244,7 +249,7 @@ export const useChannelsStore = create((set, get) => ({ }, requestQrCode: async (channelType) => { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'channels.requestQr', { type: channelType } diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 78b9dfd..fce18a0 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -4,6 +4,7 @@ * Communicates with OpenClaw Gateway via gateway:rpc IPC. */ import { create } from 'zustand'; +import { invokeIpc } from '@/lib/api-client'; // ── Types ──────────────────────────────────────────────────────── @@ -596,7 +597,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { if (needPreview.length === 0) return false; try { - const thumbnails = await window.electron.ipcRenderer.invoke( + const thumbnails = await invokeIpc( 'media:getThumbnails', needPreview, ) as Record; @@ -928,7 +929,7 @@ export const useChatStore = create((set, get) => ({ loadSessions: async () => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'sessions.list', {} @@ -1001,7 +1002,7 @@ export const useChatStore = create((set, get) => ({ void Promise.all( sessionsToLabel.map(async (session) => { try { - const r = await window.electron.ipcRenderer.invoke( + const r = await invokeIpc( 'gateway:rpc', 'chat.history', { sessionKey: session.key, limit: 1000 }, @@ -1077,7 +1078,7 @@ export const useChatStore = create((set, get) => ({ // The main process renames .jsonl → .deleted.jsonl so that // sessions.list and token-usage queries both skip it automatically. try { - const result = await window.electron.ipcRenderer.invoke('session:delete', key) as { + const result = await invokeIpc('session:delete', key) as { success: boolean; error?: string; }; @@ -1185,7 +1186,7 @@ export const useChatStore = create((set, get) => ({ if (!quiet) set({ loading: true, error: null }); try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc( 'gateway:rpc', 'chat.history', { sessionKey: currentSessionKey, limit: 200 } @@ -1425,7 +1426,7 @@ export const useChatStore = create((set, get) => ({ const CHAT_SEND_TIMEOUT_MS = 120_000; if (hasMedia) { - result = await window.electron.ipcRenderer.invoke( + result = await invokeIpc( 'chat:sendWithMedia', { sessionKey: currentSessionKey, @@ -1440,7 +1441,7 @@ export const useChatStore = create((set, get) => ({ }, ) as { success: boolean; result?: { runId?: string }; error?: string }; } else { - result = await window.electron.ipcRenderer.invoke( + result = await invokeIpc( 'gateway:rpc', 'chat.send', { @@ -1477,7 +1478,7 @@ export const useChatStore = create((set, get) => ({ set({ streamingTools: [] }); try { - await window.electron.ipcRenderer.invoke( + await invokeIpc( 'gateway:rpc', 'chat.abort', { sessionKey: currentSessionKey }, diff --git a/src/stores/cron.ts b/src/stores/cron.ts index bd4d3b0..8f84578 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; +import { invokeIpc } from '@/lib/api-client'; interface CronState { jobs: CronJob[]; @@ -29,7 +30,7 @@ export const useCronStore = create((set) => ({ set({ loading: true, error: null }); try { - const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[]; + const result = await invokeIpc('cron:list'); set({ jobs: result, loading: false }); } catch (error) { set({ error: String(error), loading: false }); @@ -38,7 +39,7 @@ export const useCronStore = create((set) => ({ createJob: async (input) => { try { - const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob; + const job = await invokeIpc('cron:create', input); set((state) => ({ jobs: [...state.jobs, job] })); return job; } catch (error) { @@ -49,7 +50,7 @@ export const useCronStore = create((set) => ({ updateJob: async (id, input) => { try { - await window.electron.ipcRenderer.invoke('cron:update', id, input); + await invokeIpc('cron:update', id, input); set((state) => ({ jobs: state.jobs.map((job) => job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job @@ -63,7 +64,7 @@ export const useCronStore = create((set) => ({ deleteJob: async (id) => { try { - await window.electron.ipcRenderer.invoke('cron:delete', id); + await invokeIpc('cron:delete', id); set((state) => ({ jobs: state.jobs.filter((job) => job.id !== id), })); @@ -75,7 +76,7 @@ export const useCronStore = create((set) => ({ toggleJob: async (id, enabled) => { try { - await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled); + await invokeIpc('cron:toggle', id, enabled); set((state) => ({ jobs: state.jobs.map((job) => job.id === id ? { ...job, enabled } : job @@ -89,11 +90,11 @@ export const useCronStore = create((set) => ({ triggerJob: async (id) => { try { - const result = await window.electron.ipcRenderer.invoke('cron:trigger', id); + const result = await invokeIpc('cron:trigger', id); console.log('Cron trigger result:', result); // Refresh jobs after trigger to update lastRun/nextRun state try { - const jobs = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[]; + const jobs = await invokeIpc('cron:list'); set({ jobs }); } catch { // Ignore refresh error diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 1ee8c03..53b3e7f 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { GatewayStatus } from '../types/gateway'; +import { invokeIpc } from '@/lib/api-client'; let gatewayInitPromise: Promise | null = null; @@ -49,7 +50,7 @@ export const useGatewayStore = create((set, get) => ({ gatewayInitPromise = (async () => { try { // Get initial status first - const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus; + const status = await invokeIpc('gateway:status') as GatewayStatus; set({ status, isInitialized: true }); // Listen for status changes @@ -197,7 +198,7 @@ export const useGatewayStore = create((set, get) => ({ start: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); - const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string }; + const result = await invokeIpc('gateway:start') as { success: boolean; error?: string }; if (!result.success) { set({ @@ -215,7 +216,7 @@ export const useGatewayStore = create((set, get) => ({ stop: async () => { try { - await window.electron.ipcRenderer.invoke('gateway:stop'); + await invokeIpc('gateway:stop'); set({ status: { ...get().status, state: 'stopped' }, lastError: null }); } catch (error) { console.error('Failed to stop Gateway:', error); @@ -226,7 +227,7 @@ export const useGatewayStore = create((set, get) => ({ restart: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); - const result = await window.electron.ipcRenderer.invoke('gateway:restart') as { success: boolean; error?: string }; + const result = await invokeIpc('gateway:restart') as { success: boolean; error?: string }; if (!result.success) { set({ @@ -244,7 +245,7 @@ export const useGatewayStore = create((set, get) => ({ checkHealth: async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:health') as { + const result = await invokeIpc('gateway:health') as { success: boolean; ok: boolean; error?: string; @@ -267,7 +268,7 @@ export const useGatewayStore = create((set, get) => ({ }, rpc: async (method: string, params?: unknown, timeoutMs?: number): Promise => { - const result = await window.electron.ipcRenderer.invoke('gateway:rpc', method, params, timeoutMs) as { + const result = await invokeIpc('gateway:rpc', method, params, timeoutMs) as { success: boolean; result?: T; error?: string; diff --git a/src/stores/providers.ts b/src/stores/providers.ts index cd5fd6b..3f534ce 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; +import { invokeIpc } from '@/lib/api-client'; // Re-export types for consumers that imported from here export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; @@ -45,8 +46,8 @@ export const useProviderStore = create((set, get) => ({ set({ loading: true, error: null }); try { - const providers = await window.electron.ipcRenderer.invoke('provider:list') as ProviderWithKeyInfo[]; - const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; + const providers = await invokeIpc('provider:list'); + const defaultId = await invokeIpc('provider:getDefault'); set({ providers, @@ -66,7 +67,7 @@ export const useProviderStore = create((set, get) => ({ updatedAt: new Date().toISOString(), }; - const result = await window.electron.ipcRenderer.invoke('provider:save', fullConfig, apiKey) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', fullConfig, apiKey); if (!result.success) { throw new Error(result.error || 'Failed to save provider'); @@ -95,7 +96,7 @@ export const useProviderStore = create((set, get) => ({ updatedAt: new Date().toISOString(), }; - const result = await window.electron.ipcRenderer.invoke('provider:save', updatedConfig, apiKey) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', updatedConfig, apiKey); if (!result.success) { throw new Error(result.error || 'Failed to update provider'); @@ -111,7 +112,7 @@ export const useProviderStore = create((set, get) => ({ deleteProvider: async (providerId) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:delete', providerId) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('provider:delete', providerId); if (!result.success) { throw new Error(result.error || 'Failed to delete provider'); @@ -127,7 +128,7 @@ export const useProviderStore = create((set, get) => ({ setApiKey: async (providerId, apiKey) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:setApiKey', providerId, apiKey) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setApiKey', providerId, apiKey); if (!result.success) { throw new Error(result.error || 'Failed to set API key'); @@ -143,12 +144,12 @@ export const useProviderStore = create((set, get) => ({ updateProviderWithKey: async (providerId, updates, apiKey) => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc<{ success: boolean; error?: string }>( 'provider:updateWithKey', providerId, updates, apiKey - ) as { success: boolean; error?: string }; + ); if (!result.success) { throw new Error(result.error || 'Failed to update provider'); @@ -163,7 +164,7 @@ export const useProviderStore = create((set, get) => ({ deleteApiKey: async (providerId) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:deleteApiKey', providerId) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('provider:deleteApiKey', providerId); if (!result.success) { throw new Error(result.error || 'Failed to delete API key'); @@ -179,7 +180,7 @@ export const useProviderStore = create((set, get) => ({ setDefaultProvider: async (providerId) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:setDefault', providerId) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setDefault', providerId); if (!result.success) { throw new Error(result.error || 'Failed to set default provider'); @@ -194,12 +195,12 @@ export const useProviderStore = create((set, get) => ({ validateApiKey: async (providerId, apiKey, options) => { try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc<{ valid: boolean; error?: string }>( 'provider:validateKey', providerId, apiKey, options - ) as { valid: boolean; error?: string }; + ); return result; } catch (error) { return { valid: false, error: String(error) }; @@ -208,7 +209,7 @@ export const useProviderStore = create((set, get) => ({ getApiKey: async (providerId) => { try { - return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null; + return await invokeIpc('provider:getApiKey', providerId); } catch { return null; } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 2aefc08..e5dfb4d 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -5,9 +5,11 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; +import { invokeIpc } from '@/lib/api-client'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; +type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; interface SettingsState { // General @@ -25,6 +27,7 @@ interface SettingsState { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; + gatewayTransportPreference: GatewayTransportPreference; // Update updateChannel: UpdateChannel; @@ -52,6 +55,7 @@ interface SettingsState { setProxyHttpsServer: (value: string) => void; setProxyAllServer: (value: string) => void; setProxyBypassRules: (value: string) => void; + setGatewayTransportPreference: (value: GatewayTransportPreference) => void; setUpdateChannel: (channel: UpdateChannel) => void; setAutoCheckUpdate: (value: boolean) => void; setAutoDownloadUpdate: (value: boolean) => void; @@ -79,6 +83,7 @@ const defaultSettings = { proxyHttpsServer: '', proxyAllServer: '', proxyBypassRules: ';localhost;127.0.0.1;::1', + gatewayTransportPreference: 'ws-first' as GatewayTransportPreference, updateChannel: 'stable' as UpdateChannel, autoCheckUpdate: true, autoDownloadUpdate: false, @@ -94,7 +99,7 @@ export const useSettingsStore = create()( init: async () => { try { - const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial; + const settings = await invokeIpc>('settings:getAll'); set((state) => ({ ...state, ...settings })); if (settings.language) { i18n.changeLanguage(settings.language); @@ -106,17 +111,21 @@ export const useSettingsStore = create()( }, setTheme: (theme) => set({ theme }), - setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void window.electron.ipcRenderer.invoke('settings:set', 'language', language).catch(() => {}); }, + setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void invokeIpc('settings:set', 'language', language).catch(() => {}); }, setStartMinimized: (startMinimized) => set({ startMinimized }), setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }), - setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, - setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); }, + setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void invokeIpc('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, + setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void invokeIpc('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); }, setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }), setProxyServer: (proxyServer) => set({ proxyServer }), setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }), setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }), setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }), setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }), + setGatewayTransportPreference: (gatewayTransportPreference) => { + set({ gatewayTransportPreference }); + void invokeIpc('settings:set', 'gatewayTransportPreference', gatewayTransportPreference).catch(() => {}); + }, setUpdateChannel: (updateChannel) => set({ updateChannel }), setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }), setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }), diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 2ae75f2..a7d0762 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import type { Skill, MarketplaceSkill } from '../types/skill'; +import { invokeIpc } from '@/lib/api-client'; type GatewaySkillStatus = { skillKey: string; @@ -70,20 +71,20 @@ export const useSkillsStore = create((set, get) => ({ } try { // 1. Fetch from Gateway (running skills) - const gatewayResult = await window.electron.ipcRenderer.invoke( + const gatewayResult = await invokeIpc>( 'gateway:rpc', 'skills.status' - ) as GatewayRpcResponse; + ); // 2. Fetch from ClawHub (installed on disk) - const clawhubResult = await window.electron.ipcRenderer.invoke( + const clawhubResult = await invokeIpc<{ success: boolean; results?: ClawHubListResult[]; error?: string }>( 'clawhub:list' - ) as { success: boolean; results?: ClawHubListResult[]; error?: string }; + ); // 3. Fetch configurations directly from Electron (since Gateway doesn't return them) - const configResult = await window.electron.ipcRenderer.invoke( + const configResult = await invokeIpc }>>( 'skill:getAllConfigs' - ) as Record }>; + ); let combinedSkills: Skill[] = []; const currentSkills = get().skills; @@ -155,7 +156,7 @@ export const useSkillsStore = create((set, get) => ({ searchSkills: async (query: string) => { set({ searching: true, searchError: null }); try { - const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string }; + const result = await invokeIpc<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('clawhub:search', { query }); if (result.success) { set({ searchResults: result.results || [] }); } else { @@ -177,7 +178,7 @@ export const useSkillsStore = create((set, get) => ({ installSkill: async (slug: string, version?: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await window.electron.ipcRenderer.invoke('clawhub:install', { slug, version }) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:install', { slug, version }); if (!result.success) { if (result.error?.includes('Timeout')) { throw new Error('installTimeoutError'); @@ -204,7 +205,7 @@ export const useSkillsStore = create((set, get) => ({ uninstallSkill: async (slug: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await window.electron.ipcRenderer.invoke('clawhub:uninstall', { slug }) as { success: boolean; error?: string }; + const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:uninstall', { slug }); if (!result.success) { throw new Error(result.error || 'Uninstall failed'); } @@ -226,11 +227,11 @@ export const useSkillsStore = create((set, get) => ({ const { updateSkill } = get(); try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc>( 'gateway:rpc', 'skills.update', { skillKey: skillId, enabled: true } - ) as GatewayRpcResponse; + ); if (result.success) { updateSkill(skillId, { enabled: true }); @@ -252,11 +253,11 @@ export const useSkillsStore = create((set, get) => ({ } try { - const result = await window.electron.ipcRenderer.invoke( + const result = await invokeIpc>( 'gateway:rpc', 'skills.update', { skillKey: skillId, enabled: false } - ) as GatewayRpcResponse; + ); if (result.success) { updateSkill(skillId, { enabled: false }); diff --git a/src/stores/update.ts b/src/stores/update.ts index 5949a39..e082255 100644 --- a/src/stores/update.ts +++ b/src/stores/update.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import { useSettingsStore } from './settings'; +import { invokeIpc } from '@/lib/api-client'; export interface UpdateInfo { version: string; @@ -63,7 +64,7 @@ export const useUpdateStore = create((set, get) => ({ // Get current version try { - const version = await window.electron.ipcRenderer.invoke('update:version'); + const version = await invokeIpc('update:version'); set({ currentVersion: version as string }); } catch (error) { console.error('Failed to get version:', error); @@ -71,12 +72,12 @@ export const useUpdateStore = create((set, get) => ({ // Get current status try { - const status = await window.electron.ipcRenderer.invoke('update:status') as { + const status = await invokeIpc<{ status: UpdateStatus; info?: UpdateInfo; progress?: ProgressInfo; error?: string; - }; + }>('update:status'); set({ status: status.status, updateInfo: status.info || null, @@ -117,7 +118,7 @@ export const useUpdateStore = create((set, get) => ({ // Sync auto-download preference to the main process if (autoDownloadUpdate) { - window.electron.ipcRenderer.invoke('update:setAutoDownload', true).catch(() => {}); + invokeIpc('update:setAutoDownload', true).catch(() => {}); } // Auto-check for updates on startup (respects user toggle) @@ -133,7 +134,7 @@ export const useUpdateStore = create((set, get) => ({ try { const result = await Promise.race([ - window.electron.ipcRenderer.invoke('update:check'), + invokeIpc('update:check'), new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000)) ]) as { success: boolean; @@ -172,10 +173,10 @@ export const useUpdateStore = create((set, get) => ({ set({ status: 'downloading', error: null }); try { - const result = await window.electron.ipcRenderer.invoke('update:download') as { + const result = await invokeIpc<{ success: boolean; error?: string; - }; + }>('update:download'); if (!result.success) { set({ status: 'error', error: result.error || 'Failed to download update' }); @@ -186,12 +187,12 @@ export const useUpdateStore = create((set, get) => ({ }, installUpdate: () => { - window.electron.ipcRenderer.invoke('update:install'); + void invokeIpc('update:install'); }, cancelAutoInstall: async () => { try { - await window.electron.ipcRenderer.invoke('update:cancelAutoInstall'); + await invokeIpc('update:cancelAutoInstall'); } catch (error) { console.error('Failed to cancel auto-install:', error); } @@ -199,7 +200,7 @@ export const useUpdateStore = create((set, get) => ({ setChannel: async (channel) => { try { - await window.electron.ipcRenderer.invoke('update:setChannel', channel); + await invokeIpc('update:setChannel', channel); } catch (error) { console.error('Failed to set update channel:', error); } @@ -207,7 +208,7 @@ export const useUpdateStore = create((set, get) => ({ setAutoDownload: async (enable) => { try { - await window.electron.ipcRenderer.invoke('update:setAutoDownload', enable); + await invokeIpc('update:setAutoDownload', enable); } catch (error) { console.error('Failed to set auto-download:', error); } diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts new file mode 100644 index 0000000..124711f --- /dev/null +++ b/tests/unit/api-client.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + invokeIpc, + invokeIpcWithRetry, + AppError, + toUserMessage, + configureApiClient, + registerTransportInvoker, + unregisterTransportInvoker, + clearTransportBackoff, +} from '@/lib/api-client'; + +describe('api-client', () => { + beforeEach(() => { + vi.resetAllMocks(); + configureApiClient({ + enabled: { ws: false, http: false }, + rules: [{ matcher: /.*/, order: ['ipc'] }], + }); + clearTransportBackoff(); + unregisterTransportInvoker('ws'); + unregisterTransportInvoker('http'); + }); + + it('forwards invoke arguments and returns result', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } }); + + const result = await invokeIpc<{ ok: boolean }>('settings:getAll', { a: 1 }); + + expect(result.ok).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'app:request', + expect.objectContaining({ + module: 'settings', + action: 'getAll', + }), + ); + }); + + it('normalizes timeout errors', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockRejectedValueOnce(new Error('Gateway Timeout')); + + await expect(invokeIpc('gateway:status')).rejects.toMatchObject({ code: 'TIMEOUT' }); + }); + + it('retries once for retryable errors', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke + .mockResolvedValueOnce({ ok: false, error: { code: 'TIMEOUT', message: 'network timeout' } }) + .mockResolvedValueOnce({ ok: true, data: { success: true } }); + + const result = await invokeIpcWithRetry<{ success: boolean }>('provider:list', [], 1); + + expect(result.success).toBe(true); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it('returns user-facing message for permission error', () => { + const msg = toUserMessage(new AppError('PERMISSION', 'forbidden')); + expect(msg).toContain('Permission denied'); + }); + + it('falls back to legacy channel when unified route is unsupported', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke + .mockRejectedValueOnce(new Error('APP_REQUEST_UNSUPPORTED:settings.getAll')) + .mockResolvedValueOnce({ foo: 'bar' }); + + const result = await invokeIpc<{ foo: string }>('settings:getAll'); + expect(result.foo).toBe('bar'); + expect(invoke).toHaveBeenNthCalledWith(2, 'settings:getAll'); + }); + + it('sends tuple payload for multi-arg unified requests', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ ok: true, data: { success: true } }); + + const result = await invokeIpc<{ success: boolean }>('settings:set', 'language', 'en'); + + expect(result.success).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'app:request', + expect.objectContaining({ + module: 'settings', + action: 'set', + payload: ['language', 'en'], + }), + ); + }); + + it('falls through ws/http and succeeds via ipc when advanced transports fail', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } }); + + registerTransportInvoker('ws', async () => { + throw new Error('ws unavailable'); + }); + registerTransportInvoker('http', async () => { + throw new Error('http unavailable'); + }); + configureApiClient({ + enabled: { ws: true, http: true }, + rules: [{ matcher: 'gateway:rpc', order: ['ws', 'http', 'ipc'] }], + }); + + const result = await invokeIpc<{ ok: boolean }>('gateway:rpc', 'chat.history', {}); + expect(result.ok).toBe(true); + expect(invoke).toHaveBeenCalledWith('gateway:rpc', 'chat.history', {}); + }); + + it('backs off failed ws transport and skips it on immediate retry', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValue({ ok: true }); + const wsInvoker = vi.fn(async () => { + throw new Error('ws unavailable'); + }); + + registerTransportInvoker('ws', wsInvoker); + configureApiClient({ + enabled: { ws: true, http: false }, + rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }], + }); + + await invokeIpc('gateway:rpc', 'chat.history', {}); + await invokeIpc('gateway:rpc', 'chat.history', {}); + + expect(wsInvoker).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it('retries ws transport after backoff is cleared', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValue({ ok: true }); + const wsInvoker = vi.fn(async () => { + throw new Error('ws unavailable'); + }); + + registerTransportInvoker('ws', wsInvoker); + configureApiClient({ + enabled: { ws: true, http: false }, + rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }], + }); + + await invokeIpc('gateway:rpc', 'chat.history', {}); + clearTransportBackoff('ws'); + await invokeIpc('gateway:rpc', 'chat.history', {}); + + expect(wsInvoker).toHaveBeenCalledTimes(2); + expect(invoke).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/feedback-state.test.tsx b/tests/unit/feedback-state.test.tsx new file mode 100644 index 0000000..158b709 --- /dev/null +++ b/tests/unit/feedback-state.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FeedbackState } from '@/components/common/FeedbackState'; + +describe('FeedbackState', () => { + it('renders loading state content', () => { + render(); + + expect(screen.getByText('Loading data')).toBeInTheDocument(); + expect(screen.getByText('Please wait')).toBeInTheDocument(); + }); + + it('renders action for empty state', () => { + render( + Create one} + />, + ); + + expect(screen.getByRole('button', { name: 'Create one' })).toBeInTheDocument(); + }); + + it('renders error state title', () => { + render(); + + expect(screen.getByText('Request failed')).toBeInTheDocument(); + }); +}); From c03d92e9a2abdcb29edc08ab4e6308383906e60e Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Sun, 8 Mar 2026 00:00:47 +0800 Subject: [PATCH 2/8] Fix/moonshot cn web search domain (#338) --- README.md | 3 + README.zh-CN.md | 3 + electron/gateway/manager.ts | 32 ++++++-- electron/main/ipc-handlers.ts | 114 ++++++++++++++-------------- electron/utils/openclaw-auth.ts | 72 ++++++++++++++++-- electron/utils/provider-keys.ts | 73 ++++++++++++++++++ electron/utils/provider-registry.ts | 7 ++ electron/utils/secure-storage.ts | 5 +- tests/unit/providers.test.ts | 12 +++ tests/unit/sanitize-config.test.ts | 71 +++++++++++++++++ 10 files changed, 318 insertions(+), 74 deletions(-) create mode 100644 electron/utils/provider-keys.ts 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 610d63f..59d99c5 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -16,7 +16,7 @@ import { } from '../utils/paths'; import { getAllSettings, getSetting } from '../utils/store'; import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; -import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; +import { getProviderEnvVars, getKeyableProviderTypes } from '../utils/provider-registry'; import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; import { logger } from '../utils/logger'; import { getUvMirrorEnv } from '../utils/uv-env'; @@ -181,6 +181,14 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict'; })(); `; +function injectMoonshotWebSearchEnv( + env: Record, + apiKey: string +): void { + // OpenClaw web_search(kimi) reads KIMI_API_KEY before provider-specific config. + env.KIMI_API_KEY = apiKey; +} + function ensureGatewayFetchPreload(): string { const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs'); try { writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8'); } catch { /* best-effort */ } @@ -1146,9 +1154,14 @@ export class GatewayManager extends EventEmitter { const defaultProviderType = defaultProvider?.type; const defaultProviderKey = await getApiKey(defaultProviderId); if (defaultProviderType && defaultProviderKey) { - const envVar = getProviderEnvVar(defaultProviderType); - if (envVar) { - providerEnv[envVar] = defaultProviderKey; + const envVars = getProviderEnvVars(defaultProviderType); + if (envVars.length > 0) { + for (const envVar of envVars) { + providerEnv[envVar] = defaultProviderKey; + } + if (defaultProviderType === 'moonshot') { + injectMoonshotWebSearchEnv(providerEnv, defaultProviderKey); + } loadedProviderKeyCount++; } } @@ -1161,9 +1174,14 @@ export class GatewayManager extends EventEmitter { try { const key = await getApiKey(providerType); if (key) { - const envVar = getProviderEnvVar(providerType); - if (envVar) { - providerEnv[envVar] = key; + const envVars = getProviderEnvVars(providerType); + if (envVars.length > 0) { + for (const envVar of envVars) { + providerEnv[envVar] = key; + } + if (providerType === 'moonshot') { + injectMoonshotWebSearchEnv(providerEnv, key); + } loadedProviderKeyCount++; } } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ced2a1b..a690c8d 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -54,25 +54,27 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; +import { + getOpenClawProviderKeyForType, + getOAuthApiKeyEnv, + getOAuthProviderApi, + getOAuthProviderDefaultBaseUrl, + getOAuthProviderTargetKey, + isOAuthProviderType, + normalizeOAuthBaseUrl, + usesOAuthAuthHeader, +} from '../utils/provider-keys'; /** - * For custom/ollama providers, derive a unique key for OpenClaw config files - * so that multiple instances of the same type don't overwrite each other. - * For all other providers the key is simply the provider type. + * Derive OpenClaw provider key used in openclaw.json / models.json. + * Some types need remapping to avoid collisions or enforce CN endpoints. * * @param type - Provider type (e.g. 'custom', 'ollama', 'openrouter') * @param providerId - Unique provider ID from secure-storage (UUID-like) - * @returns A string like 'custom-a1b2c3d4' or 'openrouter' + * @returns A key like 'custom-a1b2c3d4', 'moonshot', or 'openrouter' */ export function getOpenClawProviderKey(type: string, providerId: string): string { - if (type === 'custom' || type === 'ollama') { - const suffix = providerId.replace(/-/g, '').slice(0, 8); - return `${type}-${suffix}`; - } - if (type === 'minimax-portal-cn') { - return 'minimax-portal'; - } - return type; + return getOpenClawProviderKeyForType(type, providerId); } function getProviderModelRef(config: ProviderConfig): string | undefined { @@ -84,7 +86,10 @@ function getProviderModelRef(config: ProviderConfig): string | undefined { : `${providerKey}/${config.model}`; } - return getProviderDefaultModel(config.type); + const defaultModel = getProviderDefaultModel(config.type); + if (!defaultModel) return undefined; + const modelId = defaultModel.includes('/') ? defaultModel.split('/').slice(1).join('/') : defaultModel; + return `${providerKey}/${modelId}`; } async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { @@ -1201,16 +1206,20 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // If this provider is the current default, update the primary model const defaultProviderId = await getDefaultProvider(); if (defaultProviderId === providerId) { - const modelOverride = nextConfig.model - ? `${ock}/${nextConfig.model}` - : undefined; - if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } else { + const modelOverride = getProviderModelRef(nextConfig); + const providerKeyIsAliased = ock !== nextConfig.type; + if (nextConfig.type === 'custom' || nextConfig.type === 'ollama' || providerKeyIsAliased) { + const baseMeta = getProviderConfig(nextConfig.type); await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', + baseUrl: nextConfig.baseUrl || baseMeta?.baseUrl, + api: nextConfig.type === 'custom' || nextConfig.type === 'ollama' + ? 'openai-completions' + : baseMeta?.api, + apiKeyEnv: baseMeta?.apiKeyEnv, + headers: baseMeta?.headers, }, fallbackModels); + } else { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); } } @@ -1288,23 +1297,23 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const providerKey = await getApiKey(providerId); const fallbackModels = await getProviderFallbackModelRefs(provider); - // OAuth providers (qwen-portal, minimax-portal, minimax-portal-cn) might use OAuth OR a direct API key. - // Treat them as OAuth only if they don't have a local API key configured. - const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey; + // OAuth providers might use OAuth OR a direct API key. + // Treat them as OAuth-only if they don't have a local API key configured. + const isOAuthProvider = isOAuthProviderType(provider.type) && !providerKey; if (!isOAuthProvider) { - // Build the full model string: "openclawKey/modelId" - const modelOverride = provider.model - ? (provider.model.startsWith(`${ock}/`) - ? provider.model - : `${ock}/${provider.model}`) - : undefined; - - if (provider.type === 'custom' || provider.type === 'ollama') { + // Build the model reference from provider settings/default mapping. + const modelOverride = getProviderModelRef(provider); + const providerKeyIsAliased = ock !== provider.type; + if (provider.type === 'custom' || provider.type === 'ollama' || providerKeyIsAliased) { + const baseMeta = getProviderConfig(provider.type); await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl, - api: 'openai-completions', + baseUrl: provider.baseUrl || baseMeta?.baseUrl, + api: provider.type === 'custom' || provider.type === 'ollama' + ? 'openai-completions' + : baseMeta?.api, + apiKeyEnv: baseMeta?.apiKeyEnv, + headers: baseMeta?.headers, }, fallbackModels); } else { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); @@ -1315,32 +1324,22 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await saveProviderKeyToOpenClaw(ock, providerKey); } } else { - // OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal) - const defaultBaseUrl = provider.type === 'minimax-portal' - ? 'https://api.minimax.io/anthropic' - : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); - const api: 'anthropic-messages' | 'openai-completions' = - (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'anthropic-messages' - : 'openai-completions'; - - let baseUrl = provider.baseUrl || defaultBaseUrl; - if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { - baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + const defaultBaseUrl = getOAuthProviderDefaultBaseUrl(provider.type); + const api = getOAuthProviderApi(provider.type); + const targetProviderKey = getOAuthProviderTargetKey(provider.type); + const baseUrl = normalizeOAuthBaseUrl(provider.type, provider.baseUrl || defaultBaseUrl); + const oauthApiKeyEnv = targetProviderKey ? getOAuthApiKeyEnv(targetProviderKey) : undefined; + const oauthUsesAuthHeader = targetProviderKey ? usesOAuthAuthHeader(targetProviderKey) : false; + if (!baseUrl || !api || !targetProviderKey || !oauthApiKeyEnv) { + throw new Error(`Invalid OAuth provider config for "${provider.type}"`); } - // To ensure the OpenClaw Gateway's internal token refresher works, - // we must save the CN provider under the "minimax-portal" key in openclaw.json - const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'minimax-portal' - : provider.type; - await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { baseUrl, api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + authHeader: oauthUsesAuthHeader ? true : undefined, // Relies on OpenClaw Gateway native auth-profiles syncing - apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + apiKeyEnv: oauthApiKeyEnv, }, fallbackModels); logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); @@ -1352,8 +1351,8 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await updateAgentModelProvider(targetProviderKey, { baseUrl, api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + authHeader: oauthUsesAuthHeader ? true : undefined, + apiKey: oauthApiKeyEnv, models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], }); } catch (err) { @@ -2264,4 +2263,3 @@ function registerSessionHandlers(): void { } }); } - diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 3beabb5..6538e4c 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -17,6 +17,11 @@ 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'; @@ -207,8 +212,7 @@ export async function saveProviderKeyToOpenClaw( apiKey: string, agentId?: string ): Promise { - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - 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; } @@ -242,8 +246,7 @@ export async function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): Promise { - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - if (OAUTH_PROVIDERS.includes(provider)) { + if (isOAuthProviderType(provider)) { console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); return; } @@ -364,6 +367,7 @@ export async function setOpenClawDefaultModel( fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const model = modelOverride || getProviderDefaultModel(provider); if (!model) { @@ -393,6 +397,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' @@ -429,6 +434,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; @@ -461,6 +469,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. @@ -471,10 +505,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 }); @@ -495,7 +531,7 @@ export async function syncProviderConfigToOpenClaw( } // Ensure extension is enabled for oauth providers to prevent gateway wiping config - if (provider === 'minimax-portal' || provider === 'qwen-portal') { + if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; const pEntries = (plugins.entries || {}) as Record; pEntries[`${provider}-auth`] = { enabled: true }; @@ -516,6 +552,7 @@ export async function setOpenClawDefaultModelWithOverride( fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const model = modelOverride || getProviderDefaultModel(provider); if (!model) { @@ -542,6 +579,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]) { @@ -573,7 +611,7 @@ export async function setOpenClawDefaultModelWithOverride( config.gateway = gateway; // Ensure the extension plugin is marked as enabled in openclaw.json - if (provider === 'minimax-portal' || provider === 'qwen-portal') { + if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; const pEntries = (plugins.entries || {}) as Record; pEntries[`${provider}-auth`] = { enabled: true }; @@ -781,6 +819,28 @@ export async function sanitizeOpenClawConfig(): Promise { } } + // ── 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 1d4904d..4ed73f1 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -154,6 +154,13 @@ export function getProviderEnvVar(type: string): string | undefined { return REGISTRY[type]?.envVar; } +/** Get all environment variable names for a provider type (primary first). */ +export function getProviderEnvVars(type: string): string[] { + const meta = REGISTRY[type]; + if (!meta?.envVar) return []; + return [meta.envVar]; +} + /** Get the default model string for a provider type */ export function getProviderDefaultModel(type: string): string | undefined { return REGISTRY[type]?.defaultModel; diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 3b40847..6452063 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -6,6 +6,7 @@ import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry'; import { getActiveOpenClawProviders } from './openclaw-auth'; +import { getOpenClawProviderKeyForType } from './provider-keys'; // Lazy-load electron-store (ESM module) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -228,9 +229,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/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); + }); }); From 72585589af9e4653e8c7978eca1cd3da5f75ba48 Mon Sep 17 00:00:00 2001 From: ashione Date: Sun, 8 Mar 2026 00:30:26 +0800 Subject: [PATCH 3/8] Stabilize channels UX, reload flow, and i18n consistency --- electron/gateway/manager.ts | 70 +++++++++++++ electron/main/ipc-handlers.ts | 37 ++++--- electron/utils/channel-config.ts | 9 ++ electron/utils/openclaw-auth.ts | 23 +++++ refactor.md | 45 +++++++++ src/i18n/locales/en/channels.json | 8 +- src/i18n/locales/ja/channels.json | 8 +- src/i18n/locales/zh/channels.json | 8 +- src/pages/Channels/index.tsx | 157 ++++++++++++++++++++++-------- 9 files changed, 311 insertions(+), 54 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 610d63f..438c3d1 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -220,6 +220,7 @@ export class GatewayManager extends EventEmitter { }> = new Map(); private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; + private reloadDebounceTimer: NodeJS.Timeout | null = null; private lifecycleEpoch = 0; private deferredRestartPending = false; private restartInFlight: Promise | null = null; @@ -640,6 +641,71 @@ export class GatewayManager extends EventEmitter { }, delayMs); } + /** + * Ask the Gateway process to reload config in-place when possible. + * Falls back to restart on unsupported platforms or signaling failures. + */ + async reload(): Promise { + if (this.isRestartDeferred()) { + this.markDeferredRestart('reload'); + return; + } + + if (!this.process?.pid || this.status.state !== 'running') { + logger.warn('Gateway reload requested while not running; falling back to restart'); + await this.restart(); + return; + } + + if (process.platform === 'win32') { + logger.debug('Windows detected, falling back to Gateway restart for reload'); + await this.restart(); + return; + } + + const connectedForMs = this.status.connectedAt + ? Date.now() - this.status.connectedAt + : Number.POSITIVE_INFINITY; + + // Avoid signaling a process that just came up; it will already read latest config. + if (connectedForMs < 8000) { + logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`); + return; + } + + try { + process.kill(this.process.pid, 'SIGUSR1'); + logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`); + // Some gateway builds do not handle SIGUSR1 as an in-process reload. + // If process state doesn't recover quickly, fall back to restart. + await new Promise((resolve) => setTimeout(resolve, 1500)); + if (this.status.state !== 'running' || !this.process?.pid) { + logger.warn('Gateway did not stay running after reload signal, falling back to restart'); + await this.restart(); + } + } catch (error) { + logger.warn('Gateway reload signal failed, falling back to restart:', error); + await this.restart(); + } + } + + /** + * Debounced reload — coalesces multiple rapid config-change events into one + * in-process reload when possible. + */ + debouncedReload(delayMs = 1200): void { + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + } + logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`); + this.reloadDebounceTimer = setTimeout(() => { + this.reloadDebounceTimer = null; + void this.reload().catch((err) => { + logger.warn('Debounced Gateway reload failed:', err); + }); + }, delayMs); + } + /** * Clear all active timers */ @@ -660,6 +726,10 @@ export class GatewayManager extends EventEmitter { clearTimeout(this.restartDebounceTimer); this.restartDebounceTimer = null; } + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + this.reloadDebounceTimer = null; + } } /** diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 24ab292..61e6327 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -160,7 +160,7 @@ export function registerIpcHandlers( registerClawHubHandlers(clawHubService); // OpenClaw handlers - registerOpenClawHandlers(); + registerOpenClawHandlers(gatewayManager); // Provider handlers registerProviderHandlers(gatewayManager); @@ -1486,7 +1486,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'); @@ -1599,9 +1599,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, @@ -1609,12 +1612,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); @@ -1648,6 +1651,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); @@ -1670,6 +1679,12 @@ function registerOpenClawHandlers(): void { ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { try { await setChannelEnabled(channelType, enabled); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to set channel enabled:', error); diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 1732e41..f9a335f 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 3beabb5..d52f8ad 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -131,6 +131,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); } @@ -781,6 +790,20 @@ export async function sanitizeOpenClawConfig(): Promise { } } + // ── commands section ─────────────────────────────────────────── + // Required for SIGUSR1 in-process reload authorization. + const commands = ( + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {} + ) as Record; + if (commands.restart !== true) { + commands.restart = true; + config.commands = commands; + modified = true; + console.log('[sanitize] Enabling commands.restart for graceful reload support'); + } + if (modified) { await writeOpenClawJson(config); console.log('[sanitize] openclaw.json sanitized successfully'); diff --git a/refactor.md b/refactor.md index 1bff1aa..32543f0 100644 --- a/refactor.md +++ b/refactor.md @@ -66,3 +66,48 @@ This branch captures local refactors focused on frontend UX polish, IPC call con ## 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. Configure-but-disable support +- Added enable toggle in channel setup dialog (`Enable Channel`). +- Save flow now persists `enabled` with configuration payload. +- Existing config load now reads `enabled` state and pre-fills toggle accordingly. diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index c6630ac..932660d 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -11,6 +11,10 @@ "gatewayWarning": "Gateway service is not running. Channels cannot connect.", "available": "Available Channels", "availableDesc": "Connect a new channel", + "configured": "Configured Channels", + "configuredDesc": "Manage channels that are already configured", + "configuredBadge": "Configured", + "deleteConfirm": "Are you sure you want to delete this channel?", "showAll": "Show All", "pluginBadge": "Plugin", "toast": { @@ -37,6 +41,8 @@ "viewDocs": "View Documentation", "channelName": "Channel Name", "channelNamePlaceholder": "My {{name}}", + "enableChannel": "Enable Channel", + "enableChannelDesc": "When off, config is saved but the channel stays disabled", "credentialsVerified": "Credentials Verified", "validationFailed": "Validation Failed", "warnings": "Warnings", @@ -293,4 +299,4 @@ } }, "viewDocs": "View Documentation" -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index 35938b8..3fa3ef7 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -11,6 +11,10 @@ "gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。", "available": "利用可能なチャンネル", "availableDesc": "新しいチャンネルを接続", + "configured": "設定済みチャンネル", + "configuredDesc": "すでに設定済みのチャンネルを管理", + "configuredBadge": "設定済み", + "deleteConfirm": "このチャンネルを削除してもよろしいですか?", "showAll": "すべて表示", "pluginBadge": "プラグイン", "toast": { @@ -37,6 +41,8 @@ "viewDocs": "ドキュメントを表示", "channelName": "チャンネル名", "channelNamePlaceholder": "マイ {{name}}", + "enableChannel": "チャンネルを有効化", + "enableChannelDesc": "オフの場合、設定のみ保存しチャンネルは起動しません", "credentialsVerified": "認証情報が確認されました", "validationFailed": "検証に失敗しました", "warnings": "警告", @@ -293,4 +299,4 @@ } }, "viewDocs": "ドキュメントを表示" -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 95f1848..e0a68e9 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -11,6 +11,10 @@ "gatewayWarning": "网关服务未运行,频道无法连接。", "available": "可用频道", "availableDesc": "连接一个新的频道", + "configured": "已配置频道", + "configuredDesc": "管理已完成配置的频道", + "configuredBadge": "已配置", + "deleteConfirm": "确定要删除此频道吗?", "showAll": "显示全部", "pluginBadge": "插件", "toast": { @@ -37,6 +41,8 @@ "viewDocs": "查看文档", "channelName": "频道名称", "channelNamePlaceholder": "我的 {{name}}", + "enableChannel": "启用频道", + "enableChannelDesc": "关闭后会保存配置,但不会启动该频道", "credentialsVerified": "凭证已验证", "validationFailed": "验证失败", "warnings": "警告", @@ -293,4 +299,4 @@ } }, "viewDocs": "查看文档" -} \ No newline at end of file +} diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 5c9e13f..de67193 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -2,7 +2,7 @@ * Channels Page * Manage messaging channel connections with configuration UI */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Plus, Radio, @@ -25,6 +25,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; @@ -55,9 +56,13 @@ export function Channels() { const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [configuredTypes, setConfiguredTypes] = useState([]); + const [channelSnapshot, setChannelSnapshot] = useState([]); + const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState([]); const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); const [refreshing, setRefreshing] = useState(false); + const [showGatewayWarning, setShowGatewayWarning] = useState(false); const refreshDebounceRef = useRef | null>(null); + const lastGatewayStateRef = useRef(gatewayStatus.state); // Fetch channels on mount useEffect(() => { @@ -104,11 +109,60 @@ export function Channels() { }; }, [fetchChannels, fetchConfiguredTypes]); + useEffect(() => { + if (gatewayStatus.state === 'running') { + setChannelSnapshot(channels); + setConfiguredTypesSnapshot(configuredTypes); + } + }, [gatewayStatus.state, channels, configuredTypes]); + + useEffect(() => { + const previousState = lastGatewayStateRef.current; + const currentState = gatewayStatus.state; + const justReconnected = + currentState === 'running' && + previousState !== 'running'; + lastGatewayStateRef.current = currentState; + + if (!justReconnected) return; + void fetchChannels({ probe: false, silent: true }); + void fetchConfiguredTypes(); + }, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]); + + // Delay warning to avoid flicker during expected short reload/restart windows. + useEffect(() => { + const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error'; + const timer = setTimeout(() => { + setShowGatewayWarning(shouldWarn); + }, shouldWarn ? 1800 : 0); + return () => clearTimeout(timer); + }, [gatewayStatus.state]); + // Get channel types to display const displayedChannelTypes = getPrimaryChannels(); + const isGatewayTransitioning = + gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting'; + const channelsForView = + isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels; + const configuredTypesForView = + isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes; + + // Single source of truth for configured status across cards, stats and badges. + const configuredTypeSet = useMemo(() => { + const set = new Set(configuredTypesForView); + if (set.size === 0 && channelsForView.length > 0) { + channelsForView.forEach((channel) => set.add(channel.type)); + } + return set; + }, [configuredTypesForView, channelsForView]); + + const configuredChannels = useMemo( + () => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)), + [channelsForView, configuredTypeSet] + ); // Connected/disconnected channel counts - const connectedCount = channels.filter((c) => c.status === 'connected').length; + const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length; if (loading && channels.length === 0) { return ( @@ -161,7 +215,7 @@ export function Channels() {
-

{channels.length}

+

{configuredChannels.length}

{t('stats.total')}

@@ -187,7 +241,7 @@ export function Channels() {
-

{channels.length - connectedCount}

+

{configuredChannels.length - connectedCount}

{t('stats.disconnected')}

@@ -196,7 +250,7 @@ export function Channels() { {/* Gateway Warning */} - {gatewayStatus.state !== 'running' && ( + {showGatewayWarning && ( @@ -217,7 +271,7 @@ export function Channels() { )} {/* Configured Channels */} - {channels.length > 0 && ( + {configuredChannels.length > 0 && ( {t('configured')} @@ -225,7 +279,7 @@ export function Channels() {
- {channels.map((channel) => ( + {configuredChannels.map((channel) => ( {displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; - const isConfigured = configuredTypes.includes(type); + const isConfigured = configuredTypeSet.has(type); return (
+
+
+

{t('dialog.enableChannel')}

+

{t('dialog.enableChannelDesc')}

+
+ +
+ {/* Configuration fields */} {meta?.configFields.map((field) => ( Date: Sun, 8 Mar 2026 00:42:18 +0800 Subject: [PATCH 4/8] Revert channel enable/disable UI and stabilize channels view --- src/pages/Channels/index.tsx | 50 +++++++----------------------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index de67193..0c007eb 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -25,7 +25,6 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; @@ -320,7 +319,7 @@ export function Channels() { {meta.icon}

{meta.name}

- {meta.description} + {t(meta.description)}

{isConfigured && ( @@ -440,7 +439,6 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded const { t } = useTranslation('channels'); const [configValues, setConfigValues] = useState>({}); const [channelName, setChannelName] = useState(''); - const [enabled, setEnabled] = useState(true); const [connecting, setConnecting] = useState(false); const [showSecrets, setShowSecrets] = useState>({}); const [qrCode, setQrCode] = useState(null); @@ -462,7 +460,6 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded setConfigValues({}); setChannelName(''); setIsExistingConfig(false); - setEnabled(true); setChannelName(''); setIsExistingConfig(false); // Ensure we clean up any pending QR session if switching away @@ -475,45 +472,24 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded (async () => { try { - const [result, configResult] = await Promise.all([ - invokeIpc( - 'channel:getFormValues', - selectedType - ) as Promise<{ success: boolean; values?: Record }>, - invokeIpc( - 'channel:getConfig', - selectedType - ) as Promise<{ success: boolean; config?: Record }>, - ]); + const result = await invokeIpc( + 'channel:getFormValues', + selectedType + ) as { success: boolean; values?: Record }; if (cancelled) return; if (result.success && result.values && Object.keys(result.values).length > 0) { setConfigValues(result.values); - } else { - setConfigValues({}); - } - - const existingConfig = configResult.success ? configResult.config : undefined; - if (existingConfig && typeof existingConfig.enabled === 'boolean') { - setEnabled(existingConfig.enabled); - } else { - setEnabled(true); - } - - if ( - (result.success && result.values && Object.keys(result.values).length > 0) || - Boolean(existingConfig) - ) { setIsExistingConfig(true); } else { + setConfigValues({}); setIsExistingConfig(false); } } catch { if (!cancelled) { setConfigValues({}); setIsExistingConfig(false); - setEnabled(true); } } finally { if (!cancelled) setLoadingConfig(false); @@ -547,7 +523,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded const saveResult = await invokeIpc( 'channel:saveConfig', 'whatsapp', - { enabled } + { enabled: true } ) as { success?: boolean; error?: string }; if (!saveResult?.success) { console.error('Failed to save WhatsApp config:', saveResult?.error); @@ -581,7 +557,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded // Cancel when unmounting or switching types invokeIpc('channel:cancelWhatsAppQr').catch(() => { }); }; - }, [selectedType, channelName, enabled, onChannelAdded, t]); + }, [selectedType, channelName, onChannelAdded, t]); const handleValidate = async () => { if (!selectedType) return; @@ -690,7 +666,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded } // Step 2: Save channel configuration via IPC - const config: Record = { ...configValues, enabled }; + const config: Record = { ...configValues }; const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as { success?: boolean; error?: string; @@ -875,14 +851,6 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded /> -
-
-

{t('dialog.enableChannel')}

-

{t('dialog.enableChannelDesc')}

-
- -
- {/* Configuration fields */} {meta?.configFields.map((field) => ( Date: Sun, 8 Mar 2026 00:53:19 +0800 Subject: [PATCH 5/8] Improve cron i18n coverage and reduce websocket stderr noise --- electron/gateway/manager.ts | 24 +++++++++- src/i18n/locales/en/cron.json | 14 +++++- src/i18n/locales/ja/cron.json | 14 +++++- src/i18n/locales/zh/cron.json | 14 +++++- src/pages/Cron/index.tsx | 84 ++++++++++++++++------------------- 5 files changed, 99 insertions(+), 51 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 438c3d1..7761779 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -224,6 +224,7 @@ export class GatewayManager extends EventEmitter { private lifecycleEpoch = 0; private deferredRestartPending = false; private restartInFlight: Promise | null = null; + private externalShutdownSupported: boolean | null = null; constructor(config?: Partial) { super(); @@ -252,6 +253,11 @@ export class GatewayManager extends EventEmitter { return sanitized; } + private isUnsupportedShutdownError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /unknown method:\s*shutdown/i.test(message); + } + private formatExit(code: number | null, signal: NodeJS.Signals | null): string { if (code !== null) return `code=${code}`; if (signal) return `signal=${signal}`; @@ -265,6 +271,14 @@ export class GatewayManager extends EventEmitter { // Known noisy lines that are not actionable for Gateway lifecycle debugging. if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg }; if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg }; + // During renderer refresh / transport switching, loopback websocket probes can time out + // while the gateway is reloading. This is expected and not actionable. + if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) { + return { level: 'debug', normalized: msg }; + } + if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) { + return { level: 'debug', normalized: msg }; + } // Downgrade frequent non-fatal noise. if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg }; @@ -529,11 +543,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); + } } } diff --git a/src/i18n/locales/en/cron.json b/src/i18n/locales/en/cron.json index 210135a..01c58f2 100644 --- a/src/i18n/locales/en/cron.json +++ b/src/i18n/locales/en/cron.json @@ -59,6 +59,7 @@ "paused": "Task paused", "deleted": "Task deleted", "triggered": "Task triggered successfully", + "failedTrigger": "Failed to trigger task: {{error}}", "failedUpdate": "Failed to update task", "failedDelete": "Failed to delete task", "nameRequired": "Please enter a task name", @@ -66,5 +67,16 @@ "channelRequired": "Please select a channel", "discordIdRequired": "Please enter a Discord Channel ID", "scheduleRequired": "Please select or enter a schedule" + }, + "schedule": { + "everySeconds": "Every {{count}}s", + "everyMinutes": "Every {{count}} minutes", + "everyHours": "Every {{count}} hours", + "everyDays": "Every {{count}} days", + "onceAt": "Once at {{time}}", + "weeklyAt": "Weekly on {{day}} at {{time}}", + "monthlyAtDay": "Monthly on day {{day}} at {{time}}", + "dailyAt": "Daily at {{time}}", + "unknown": "Unknown" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/cron.json b/src/i18n/locales/ja/cron.json index bc5cd8c..401d2ed 100644 --- a/src/i18n/locales/ja/cron.json +++ b/src/i18n/locales/ja/cron.json @@ -59,6 +59,7 @@ "paused": "タスクを停止しました", "deleted": "タスクを削除しました", "triggered": "タスクを正常にトリガーしました", + "failedTrigger": "タスクの実行に失敗しました: {{error}}", "failedUpdate": "タスクの更新に失敗しました", "failedDelete": "タスクの削除に失敗しました", "nameRequired": "タスク名を入力してください", @@ -66,5 +67,16 @@ "channelRequired": "チャンネルを選択してください", "discordIdRequired": "DiscordチャンネルIDを入力してください", "scheduleRequired": "スケジュールを選択または入力してください" + }, + "schedule": { + "everySeconds": "{{count}}秒ごと", + "everyMinutes": "{{count}}分ごと", + "everyHours": "{{count}}時間ごと", + "everyDays": "{{count}}日ごと", + "onceAt": "{{time}} に1回実行", + "weeklyAt": "毎週 {{day}} {{time}}", + "monthlyAtDay": "毎月 {{day}}日 {{time}}", + "dailyAt": "毎日 {{time}}", + "unknown": "不明" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/cron.json b/src/i18n/locales/zh/cron.json index efbd5b7..571f7fe 100644 --- a/src/i18n/locales/zh/cron.json +++ b/src/i18n/locales/zh/cron.json @@ -59,6 +59,7 @@ "paused": "任务已暂停", "deleted": "任务已删除", "triggered": "任务已成功触发", + "failedTrigger": "触发任务失败: {{error}}", "failedUpdate": "更新任务失败", "failedDelete": "删除任务失败", "nameRequired": "请输入任务名称", @@ -66,5 +67,16 @@ "channelRequired": "请选择频道", "discordIdRequired": "请输入 Discord 频道 ID", "scheduleRequired": "请选择或输入调度计划" + }, + "schedule": { + "everySeconds": "每 {{count}} 秒", + "everyMinutes": "每 {{count}} 分钟", + "everyHours": "每 {{count}} 小时", + "everyDays": "每 {{count}} 天", + "onceAt": "执行一次,时间:{{time}}", + "weeklyAt": "每周 {{day}} {{time}}", + "monthlyAtDay": "每月 {{day}} 日 {{time}}", + "dailyAt": "每天 {{time}}", + "unknown": "未知" } -} \ No newline at end of file +} diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 8be9a53..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,34 +82,33 @@ 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; @@ -285,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)} ))} @@ -332,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { {/* Actions */}
@@ -485,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
@@ -705,10 +697,10 @@ export function Cron() { { if (jobToDelete) { From 11a0bcb5ba7cd5cb07b1239219e8fb263efc00fe Mon Sep 17 00:00:00 2001 From: ashione Date: Sun, 8 Mar 2026 00:58:38 +0800 Subject: [PATCH 6/8] Align refactor notes with origin/main diff and recent fixes --- refactor.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/refactor.md b/refactor.md index 32543f0..9f74241 100644 --- a/refactor.md +++ b/refactor.md @@ -107,7 +107,28 @@ This branch captures local refactors focused on frontend UX polish, IPC call con - avoids UI flashing to zero counts / empty configured state - Added automatic refresh once Gateway transitions back to `running`. -### 14. Configure-but-disable support -- Added enable toggle in channel setup dialog (`Enable Channel`). -- Save flow now persists `enabled` with configuration payload. -- Existing config load now reads `enabled` state and pre-fills toggle accordingly. +### 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. From a10b1afa8b1859d1bdc26bbac626e2342c4620cc Mon Sep 17 00:00:00 2001 From: ashione Date: Sun, 8 Mar 2026 01:15:35 +0800 Subject: [PATCH 7/8] Group chat history by time buckets and fix sidebar i18n --- refactor.md | 9 +++ src/components/layout/Sidebar.tsx | 128 ++++++++++++++++++++---------- src/i18n/locales/en/chat.json | 10 ++- src/i18n/locales/ja/chat.json | 10 ++- src/i18n/locales/zh/chat.json | 10 ++- 5 files changed, 123 insertions(+), 44 deletions(-) diff --git a/refactor.md b/refactor.md index 9f74241..6230f0e 100644 --- a/refactor.md +++ b/refactor.md @@ -132,3 +132,12 @@ This branch captures local refactors focused on frontend UX polish, IPC call con ### 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/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 19d6f49..f857ccd 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -27,6 +27,14 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { invokeIpc } from '@/lib/api-client'; import { useTranslation } from 'react-i18next'; +type SessionBucketKey = + | 'today' + | 'yesterday' + | 'withinWeek' + | 'withinTwoWeeks' + | 'withinMonth' + | 'older'; + interface NavItemProps { to: string; icon: React.ReactNode; @@ -67,6 +75,23 @@ 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'; +} + export function Sidebar() { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); @@ -83,9 +108,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 +128,28 @@ export function Sidebar() { } }; - const { t } = useTranslation(); + const { t } = useTranslation(['common', 'chat']); const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); + const nowMs = Date.now(); + 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 +196,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/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index af9d58a..8d82e0d 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -14,5 +14,13 @@ "refresh": "Refresh chat", "showThinking": "Show thinking", "hideThinking": "Hide thinking" + }, + "historyBuckets": { + "today": "Today", + "yesterday": "Yesterday", + "withinWeek": "Within 1 Week", + "withinTwoWeeks": "Within 2 Weeks", + "withinMonth": "Within 1 Month", + "older": "Older than 1 Month" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 6904fa7..bdfd543 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -14,5 +14,13 @@ "refresh": "チャットを更新", "showThinking": "思考を表示", "hideThinking": "思考を非表示" + }, + "historyBuckets": { + "today": "今日", + "yesterday": "昨日", + "withinWeek": "1週間以内", + "withinTwoWeeks": "2週間以内", + "withinMonth": "1か月以内", + "older": "1か月より前" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index 21a7024..84031e1 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -14,5 +14,13 @@ "refresh": "刷新聊天", "showThinking": "显示思考过程", "hideThinking": "隐藏思考过程" + }, + "historyBuckets": { + "today": "今天", + "yesterday": "昨天", + "withinWeek": "一周内", + "withinTwoWeeks": "两周内", + "withinMonth": "一个月内", + "older": "一个月之前" } -} \ No newline at end of file +} From 3817c388a8ad9b2bf23f53779e281264d194e7fc Mon Sep 17 00:00:00 2001 From: ashione Date: Sun, 8 Mar 2026 01:18:07 +0800 Subject: [PATCH 8/8] Fix Sidebar render purity for history bucket grouping --- src/components/layout/Sidebar.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f857ccd..e31c4fa 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, @@ -92,6 +92,8 @@ function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey { return 'older'; } +const INITIAL_NOW_MS = Date.now(); + export function Sidebar() { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); @@ -130,7 +132,14 @@ export function Sidebar() { const { t } = useTranslation(['common', 'chat']); const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); - const nowMs = Date.now(); + 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: [] },