refactor IPC (#341)

This commit is contained in:
Lingxuan Zuo
2026-03-08 11:54:49 +08:00
committed by GitHub
Unverified
parent c03d92e9a2
commit 3d804a9f5e
52 changed files with 3121 additions and 336 deletions

View File

@@ -228,9 +228,11 @@ export class GatewayManager extends EventEmitter {
}> = new Map();
private deviceIdentity: DeviceIdentity | null = null;
private restartDebounceTimer: NodeJS.Timeout | null = null;
private reloadDebounceTimer: NodeJS.Timeout | null = null;
private lifecycleEpoch = 0;
private deferredRestartPending = false;
private restartInFlight: Promise<void> | null = null;
private externalShutdownSupported: boolean | null = null;
constructor(config?: Partial<ReconnectConfig>) {
super();
@@ -259,6 +261,11 @@ export class GatewayManager extends EventEmitter {
return sanitized;
}
private isUnsupportedShutdownError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /unknown method:\s*shutdown/i.test(message);
}
private formatExit(code: number | null, signal: NodeJS.Signals | null): string {
if (code !== null) return `code=${code}`;
if (signal) return `signal=${signal}`;
@@ -272,6 +279,14 @@ export class GatewayManager extends EventEmitter {
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg };
if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg };
// During renderer refresh / transport switching, loopback websocket probes can time out
// while the gateway is reloading. This is expected and not actionable.
if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) {
return { level: 'debug', normalized: msg };
}
if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) {
return { level: 'debug', normalized: msg };
}
// Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
@@ -536,11 +551,17 @@ export class GatewayManager extends EventEmitter {
// If this manager is attached to an external gateway process, ask it to shut down
// over protocol before closing the socket.
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) {
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN && this.externalShutdownSupported !== false) {
try {
await this.rpc('shutdown', undefined, 5000);
this.externalShutdownSupported = true;
} catch (error) {
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
if (this.isUnsupportedShutdownError(error)) {
this.externalShutdownSupported = false;
logger.info('External Gateway does not support "shutdown"; skipping shutdown RPC for future stops');
} else {
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
}
}
}
@@ -648,6 +669,71 @@ export class GatewayManager extends EventEmitter {
}, delayMs);
}
/**
* Ask the Gateway process to reload config in-place when possible.
* Falls back to restart on unsupported platforms or signaling failures.
*/
async reload(): Promise<void> {
if (this.isRestartDeferred()) {
this.markDeferredRestart('reload');
return;
}
if (!this.process?.pid || this.status.state !== 'running') {
logger.warn('Gateway reload requested while not running; falling back to restart');
await this.restart();
return;
}
if (process.platform === 'win32') {
logger.debug('Windows detected, falling back to Gateway restart for reload');
await this.restart();
return;
}
const connectedForMs = this.status.connectedAt
? Date.now() - this.status.connectedAt
: Number.POSITIVE_INFINITY;
// Avoid signaling a process that just came up; it will already read latest config.
if (connectedForMs < 8000) {
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
return;
}
try {
process.kill(this.process.pid, 'SIGUSR1');
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
// If process state doesn't recover quickly, fall back to restart.
await new Promise((resolve) => setTimeout(resolve, 1500));
if (this.status.state !== 'running' || !this.process?.pid) {
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
await this.restart();
}
} catch (error) {
logger.warn('Gateway reload signal failed, falling back to restart:', error);
await this.restart();
}
}
/**
* Debounced reload — coalesces multiple rapid config-change events into one
* in-process reload when possible.
*/
debouncedReload(delayMs = 1200): void {
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
}
logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`);
this.reloadDebounceTimer = setTimeout(() => {
this.reloadDebounceTimer = null;
void this.reload().catch((err) => {
logger.warn('Debounced Gateway reload failed:', err);
});
}, delayMs);
}
/**
* Clear all active timers
*/
@@ -668,6 +754,10 @@ export class GatewayManager extends EventEmitter {
clearTimeout(this.restartDebounceTimer);
this.restartDebounceTimer = null;
}
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
this.reloadDebounceTimer = null;
}
}
/**

View File

@@ -54,6 +54,25 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
import { applyProxySettings } from './proxy';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import { appUpdater } from './updater';
type AppRequest = {
id?: string;
module: string;
action: string;
payload?: unknown;
};
type AppResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code: 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
message: string;
details?: unknown;
};
};
import {
getOpenClawProviderKeyForType,
getOAuthApiKeyEnv,
@@ -136,6 +155,9 @@ export function registerIpcHandlers(
clawHubService: ClawHubService,
mainWindow: BrowserWindow
): void {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
// Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow);
@@ -143,7 +165,7 @@ export function registerIpcHandlers(
registerClawHubHandlers(clawHubService);
// OpenClaw handlers
registerOpenClawHandlers();
registerOpenClawHandlers(gatewayManager);
// Provider handlers
registerProviderHandlers(gatewayManager);
@@ -191,6 +213,701 @@ export function registerIpcHandlers(
registerFileHandlers();
}
function mapAppErrorCode(error: unknown): AppResponse['error']['code'] {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('timeout')) return 'TIMEOUT';
if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION';
if (msg.includes('gateway')) return 'GATEWAY';
if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION';
return 'INTERNAL';
}
function isProxyKey(key: keyof AppSettings): boolean {
return (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
);
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const handleProxySettingsChange = async () => {
const settings = await getAllSettings();
await applyProxySettings(settings);
if (gatewayManager.getStatus().state === 'running') {
await gatewayManager.restart();
}
};
ipcMain.handle('app:request', async (_, request: AppRequest): Promise<AppResponse> => {
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<ProviderConfig>; apiKey?: string }
| [string, Partial<ProviderConfig>, 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, unknown> }
| [string, Record<string, unknown>]
| 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<AppSettings>;
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value as never);
}
if (entries.some(([key]) => isProxyKey(key))) {
await handleProxySettingsChange();
}
data = { success: true };
break;
}
if (request.action === 'reset') {
await resetSettings();
const settings = await getAllSettings();
await handleProxySettingsChange();
data = { success: true, settings };
break;
}
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
default:
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
return { id: request.id, ok: true, data };
} catch (error) {
return {
id: request.id,
ok: false,
error: {
code: mapAppErrorCode(error),
message: error instanceof Error ? error.message : String(error),
},
};
}
});
}
/**
* Skill config IPC handlers
* Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC)
@@ -493,6 +1210,14 @@ function registerGatewayHandlers(
gatewayManager: GatewayManager,
mainWindow: BrowserWindow
): void {
type GatewayHttpProxyRequest = {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
timeoutMs?: number;
};
// Get Gateway status
ipcMain.handle('gateway:status', () => {
return gatewayManager.getStatus();
@@ -543,6 +1268,72 @@ function registerGatewayHandlers(
}
});
// Gateway HTTP proxy
// Renderer must not call gateway HTTP directly (CORS); all HTTP traffic
// should go through this main-process proxy.
ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => {
try {
const status = gatewayManager.getStatus();
const port = status.port || 18789;
const path = request?.path && request.path.startsWith('/') ? request.path : '/';
const method = (request?.method || 'GET').toUpperCase();
const timeoutMs =
typeof request?.timeoutMs === 'number' && request.timeoutMs > 0
? request.timeoutMs
: 15000;
const token = await getSetting('gatewayToken');
const headers: Record<string, string> = {
...(request?.headers ?? {}),
};
if (!headers.Authorization && !headers.authorization && token) {
headers.Authorization = `Bearer ${token}`;
}
let body: string | undefined;
if (request?.body !== undefined && request?.body !== null) {
body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json';
}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
method,
headers,
body,
signal: controller.signal,
});
clearTimeout(timer);
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
const json = await response.json();
return {
success: true,
status: response.status,
ok: response.ok,
json,
};
}
const text = await response.text();
return {
success: true,
status: response.status,
ok: response.ok,
text,
};
} catch (error) {
return {
success: false,
error: String(error),
};
}
});
// Chat send with media — reads staged files from disk and builds attachments.
// Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments.
// All other files are referenced by path in the message text so the model
@@ -700,7 +1491,7 @@ function registerGatewayHandlers(
* OpenClaw-related IPC handlers
* For checking package status and channel configuration
*/
function registerOpenClawHandlers(): void {
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
@@ -813,9 +1604,12 @@ function registerOpenClawHandlers(): void {
};
}
await saveChannelConfig(channelType, config);
logger.info(
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally`
);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
}
return {
success: true,
pluginInstalled: installResult.installed,
@@ -823,12 +1617,12 @@ function registerOpenClawHandlers(): void {
};
}
await saveChannelConfig(channelType, config);
// Do not force stop/start here. Recent Gateway builds detect channel config
// changes and perform an internal service restart; forcing another restart
// from Electron can race with reconnect and kill the newly spawned process.
logger.info(
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload`
);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
}
return { success: true };
} catch (error) {
console.error('Failed to save channel config:', error);
@@ -862,6 +1656,12 @@ function registerOpenClawHandlers(): void {
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
try {
await deleteChannelConfig(channelType);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`);
}
return { success: true };
} catch (error) {
console.error('Failed to delete channel config:', error);
@@ -884,6 +1684,12 @@ function registerOpenClawHandlers(): void {
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
try {
await setChannelEnabled(channelType, enabled);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`);
}
return { success: true };
} catch (error) {
console.error('Failed to set channel enabled:', error);

View File

@@ -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',

View File

@@ -39,6 +39,7 @@ export interface PluginsConfig {
export interface OpenClawConfig {
channels?: Record<string, ChannelConfigData>;
plugins?: PluginsConfig;
commands?: Record<string, unknown>;
[key: string]: unknown;
}
@@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
await ensureConfigDir();
try {
// Enable graceful in-process reload authorization for SIGUSR1 flows.
const commands =
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {};
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);

View File

@@ -136,6 +136,15 @@ async function readOpenClawJson(): Promise<Record<string, unknown>> {
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
commands.restart = true;
config.commands = commands;
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
}
@@ -819,6 +828,20 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
// ── commands section ───────────────────────────────────────────
// Required for SIGUSR1 in-process reload authorization.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (commands.restart !== true) {
commands.restart = true;
config.commands = commands;
modified = true;
console.log('[sanitize] Enabling commands.restart for graceful reload support');
}
// ── tools.web.search.kimi ─────────────────────────────────────
// OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over
// environment/auth-profiles. A stale inline key can cause persistent 401s.

View File

@@ -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: '<local>;localhost;127.0.0.1;::1',
gatewayTransportPreference: 'ws-first',
// Update
updateChannel: 'stable',

View File

@@ -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',
},
},
];

143
refactor.md Normal file
View File

@@ -0,0 +1,143 @@
# Refactor Summary
## Scope
This branch captures local refactors focused on frontend UX polish, IPC call consolidation, transport abstraction, and channel page responsiveness.
## Key Changes
### 1. Frontend IPC consolidation
- Replaced scattered direct `window.electron.ipcRenderer.invoke(...)` calls with unified `invokeIpc(...)` usage.
- Added lint guard to prevent new direct renderer IPC invokes outside the API layer.
- Introduced a centralized API client with:
- error normalization (`AppError`)
- unified `app:request` support + compatibility fallback
- retry helper for timeout/network errors
### 2. Transport abstraction (extensible protocol layer)
- Added transport routing abstraction inside `src/lib/api-client.ts`:
- `ipc`, `ws`, `http`
- rule-based channel routing
- transport registration/unregistration
- failure backoff and fallback behavior
- Added default transport initialization in app entry.
- Added gateway-specific transport adapters for WS/HTTP.
### 3. HTTP path moved to Electron main-process proxy
- Added `gateway:httpProxy` IPC handler in main process to avoid renderer-side CORS issues.
- Preload allowlist updated for `gateway:httpProxy`.
- Gateway HTTP transport now uses IPC proxy instead of browser `fetch` direct-to-gateway.
### 4. Settings improvements (Developer-focused transport control)
- Added persisted setting `gatewayTransportPreference`.
- Added runtime application of transport preference in app bootstrap.
- Added UI option (Developer section) to choose routing strategy:
- WS First / HTTP First / WS Only / HTTP Only / IPC Only
- Added i18n strings for EN/ZH/JA.
### 5. Channel page performance optimization
- `fetchChannels` now supports options:
- `probe` (manual refresh can force probe)
- `silent` (background refresh without full-page loading lock)
- Channel status event refresh now debounced (300ms) to reduce refresh storms.
- Initial loading spinner only shown when no existing data.
- Manual refresh uses local spinner state and non-blocking update.
### 6. UX and component enhancements
- Added shared feedback state component for consistent empty/loading/error states.
- Added telemetry helpers and quick-action/dashboard refinements.
- Setup/settings/providers/chat/skills/cron pages received targeted UX and reliability fixes.
### 7. IPC main handler compatibility improvements
- Expanded `app:request` coverage for provider/update/settings/cron/usage actions.
- Unsupported app requests now return structured error response instead of throwing, reducing noisy handler exceptions.
### 8. Tests
- Added unit tests for API client behavior and feedback state rendering.
- Added transport fallback/backoff coverage in API client tests.
## Files Added
- `src/lib/api-client.ts`
- `src/lib/telemetry.ts`
- `src/components/common/FeedbackState.tsx`
- `tests/unit/api-client.test.ts`
- `tests/unit/feedback-state.test.tsx`
- `refactor.md`
## Notes
- Navigation order in sidebar is kept aligned with `main` ordering.
- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work.
## Incremental Updates (2026-03-08)
### 9. Channel i18n fixes
- Added missing `channels` locale keys in EN/ZH/JA to prevent raw key fallback:
- `configured`, `configuredDesc`, `configuredBadge`, `deleteConfirm`
- Fixed confirm dialog namespace usage on Channels page:
- `common:actions.confirm`, `common:actions.delete`, `common:actions.cancel`
### 10. Channel save/delete behavior aligned to reload-first strategy
- Added Gateway reload capability in `GatewayManager`:
- `reload()` (SIGUSR1 on macOS/Linux, restart fallback on failure/unsupported platforms)
- `debouncedReload()` for coalesced config-change reloads
- Wired channel config operations to reload pipeline:
- `channel:saveConfig`
- `channel:deleteConfig`
- `channel:setEnabled`
- Removed redundant renderer-side forced restart call after WhatsApp configuration.
### 11. OpenClaw config compatibility for graceful reload
- Ensured `commands.restart = true` is persisted in OpenClaw config write paths:
- `electron/utils/channel-config.ts`
- `electron/utils/openclaw-auth.ts`
- Added sanitize fallback that auto-enables `commands.restart` before Gateway start.
### 12. Channels page data consistency fixes
- Unified configured state derivation so the following sections share one source:
- stats cards
- configured channels list
- available channel configured badge
- Fixed post-delete refresh by explicitly refetching both:
- configured channel types
- channel status list
### 13. Channels UX resilience during Gateway restart/reconnect
- Added delayed gateway warning display to reduce transient false alarms.
- Added "running snapshot" rendering strategy:
- keep previous channels/configured view during `starting/reconnecting` when live response is temporarily empty
- avoids UI flashing to zero counts / empty configured state
- Added automatic refresh once Gateway transitions back to `running`.
### 14. Channel enable/disable UX rollback
- Rolled back renderer-side channel enable/disable controls due to multi-channel state mixing risk.
- Removed channel-card toggle entry point and setup-dialog enable switch.
- Restored stable channel configuration flow (save/delete + refresh consistency).
### 15. Cron i18n completion and consistency
- Replaced remaining hardcoded Cron UI strings with i18n keys:
- dialog actions (`Cancel`, `Saving...`)
- card actions (`Edit`, `Delete`)
- trigger failure message
- confirm dialog namespace usage (`common:actions.*`)
- Refactored cron schedule display parser to return localized strings instead of hardcoded English.
- Added new locale keys in EN/ZH/JA:
- `toast.failedTrigger`
- `schedule.everySeconds/everyMinutes/everyHours/everyDays/onceAt/weeklyAt/monthlyAtDay/dailyAt/unknown`
### 16. Gateway log noise reduction
- Added stderr classification downgrade for expected loopback websocket transient logs:
- `[ws] handshake timeout ... remote=127.0.0.1`
- `[ws] closed before connect ... remote=127.0.0.1`
- These lines now log at debug level instead of warn during reload/reconnect windows.
### 17. External gateway shutdown compatibility
- Added capability cache for externally managed Gateway shutdown RPC.
- If `shutdown` is unsupported (`unknown method: shutdown`), mark it unsupported and skip future shutdown RPC attempts to avoid repeated warnings.
### 18. Chat history sidebar grouping (ChatGPT-style buckets)
- Updated chat session history display in sidebar to time buckets:
- Today / Yesterday / Within 1 Week / Within 2 Weeks / Within 1 Month / Older than 1 Month
- Added `historyBuckets` locale keys in EN/ZH/JA (`chat` namespace).
- Fixed i18n namespace usage for bucket labels in sidebar:
- explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback.
- Removed forced uppercase rendering for bucket headers to preserve localized casing.
- Grouping now applies to all sessions (including `:main`) for consistent bucket visibility and behavior.

View File

@@ -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 (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>

View File

@@ -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'
? <Loader2 className="h-8 w-8 animate-spin text-primary" />
: state === 'error'
? <AlertCircle className="h-8 w-8 text-destructive" />
: <Inbox className="h-8 w-8 text-muted-foreground" />;
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="mb-3">{icon}</div>
<p className="font-medium">{title}</p>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-3">{action}</div>}
</div>
);
}

View File

@@ -3,7 +3,7 @@
* Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar.
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Home,
@@ -24,8 +24,17 @@ import { useChatStore } from '@/stores/chat';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { invokeIpc } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
interface NavItemProps {
to: string;
icon: React.ReactNode;
@@ -66,6 +75,25 @@ function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) {
);
}
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
const INITIAL_NOW_MS = Date.now();
export function Sidebar() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
@@ -82,15 +110,12 @@ export function Sidebar() {
const navigate = useNavigate();
const isOnChat = useLocation().pathname === '/';
const mainSessions = sessions.filter((s) => s.key.endsWith(':main'));
const otherSessions = sessions.filter((s) => !s.key.endsWith(':main'));
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
sessionLabels[key] ?? label ?? displayName ?? key;
const openDevConsole = async () => {
try {
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
const result = await invokeIpc('gateway:getControlUiUrl') as {
success: boolean;
url?: string;
error?: string;
@@ -105,8 +130,35 @@ export function Sidebar() {
}
};
const { t } = useTranslation();
const { t } = useTranslation(['common', 'chat']);
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 60 * 1000);
return () => window.clearInterval(timer);
}, []);
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
{ key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] },
{ key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] },
{ key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] },
{ key: 'older', label: t('chat:historyBuckets.older'), sessions: [] },
];
const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record<
SessionBucketKey,
(typeof sessionBuckets)[number]
>;
for (const session of [...sessions].sort((a, b) =>
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
)) {
const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs);
sessionBucketMap[bucketKey].sessions.push(session);
}
const navItems = [
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
@@ -153,43 +205,47 @@ export function Sidebar() {
{/* Session list — below Settings, only when expanded */}
{!sidebarCollapsed && sessions.length > 0 && (
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
{[...mainSessions, ...[...otherSessions].sort((a, b) =>
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
)].map((s) => (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors',
!s.key.endsWith(':main') && 'pr-7',
'hover:bg-accent hover:text-accent-foreground',
isOnChat && currentSessionKey === s.key
? 'bg-accent/60 text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
{getSessionLabel(s.key, s.displayName, s.label)}
</button>
{!s.key.endsWith(':main') && (
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
<div key={bucket.key} className="pt-1">
<div className="px-3 py-1 text-[11px] font-medium text-muted-foreground/80">
{bucket.label}
</div>
{bucket.sessions.map((s) => (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors pr-7',
'hover:bg-accent hover:text-accent-foreground',
isOnChat && currentSessionKey === s.key
? 'bg-accent/60 text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
{getSessionLabel(s.key, s.displayName, s.label)}
</button>
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null
))}
</div>
)}

View File

@@ -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 (

View File

@@ -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({
<Button
variant="secondary"
className="w-full"
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
onClick={() => invokeIpc('shell:openExternal', oauthData.verificationUri)}
>
<ExternalLink className="h-4 w-4 mr-2" />
{t('aiProviders.oauth.openLoginPage')}

View File

@@ -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"
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -12,8 +12,10 @@
"quickActions": {
"title": "Quick Actions",
"description": "Common tasks and shortcuts",
"addProvider": "Add Provider",
"addChannel": "Add Channel",
"browseSkills": "Browse Skills",
"createCron": "Create Cron",
"installSkill": "Install Skill",
"openChat": "Open Chat",
"settings": "Settings",
"devConsole": "Dev Console"

View File

@@ -113,6 +113,8 @@
"proxyHttpsServerHelp": "Advanced override for HTTPS requests. Leave blank to use Proxy Server.",
"proxyAllServer": "ALL_PROXY / SOCKS",
"proxyAllServerHelp": "Advanced fallback for SOCKS-capable clients and protocols such as Telegram. Leave blank to use Proxy Server.",
"showAdvancedProxy": "Show advanced proxy fields",
"hideAdvancedProxy": "Hide advanced proxy fields",
"proxyBypass": "Bypass Rules",
"proxyBypassHelp": "Semicolon, comma, or newline separated hosts that should connect directly.",
"proxyRestartNote": "Saving reapplies Electron networking and restarts the Gateway immediately.",
@@ -153,6 +155,25 @@
"advanced": {
"title": "Advanced",
"description": "Power-user options",
"transport": {
"label": "Gateway Transport Preference",
"desc": "Choose how renderer requests gateway RPC: WebSocket, HTTP proxy, or IPC fallback.",
"saved": "Gateway transport preference saved",
"options": {
"wsFirst": "WS First",
"httpFirst": "HTTP First",
"wsOnly": "WS Only",
"httpOnly": "HTTP Only",
"ipcOnly": "IPC Only"
},
"descriptions": {
"wsFirst": "WS -> HTTP -> IPC",
"httpFirst": "HTTP -> WS -> IPC",
"wsOnly": "WS -> IPC",
"httpOnly": "HTTP -> IPC",
"ipcOnly": "IPC only"
}
},
"devMode": "Developer Mode",
"devModeDesc": "Show developer tools and shortcuts"
},

View File

@@ -16,6 +16,10 @@
"search": "Search skills...",
"searchMarketplace": "Search marketplace...",
"searchButton": "Search",
"actions": {
"enableVisible": "Enable Visible",
"disableVisible": "Disable Visible"
},
"noSkills": "No skills found",
"noSkillsSearch": "Try a different search term",
"noSkillsAvailable": "No skills available",
@@ -63,7 +67,12 @@
"searchRateLimitError": "Search rate limit exceeded. You can also search on ClawHub.ai, download the ZIP, and extract it to \"{{path}}\"",
"installRateLimitError": "Installation rate limit exceeded. You can also download the ZIP from ClawHub.ai and extract it to \"{{path}}\"",
"fetchTimeoutError": "Fetching skills timed out, please check your network connection.",
"fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later."
"fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later.",
"noBatchEnableTargets": "All visible skills are already enabled.",
"noBatchDisableTargets": "All visible skills are already disabled.",
"batchEnabled": "{{count}} skills enabled.",
"batchDisabled": "{{count}} skills disabled.",
"batchPartial": "Updated {{success}} / {{total}} skills. Some items failed."
},
"marketplace": {
"title": "Marketplace",
@@ -74,4 +83,4 @@
"emptyPrompt": "Search for new skills to expand your capabilities.",
"searchError": "ClawHub search failed. Check your connection or installation."
}
}
}

View File

@@ -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": "ドキュメントを表示"
}
}

View File

@@ -14,5 +14,13 @@
"refresh": "チャットを更新",
"showThinking": "思考を表示",
"hideThinking": "思考を非表示"
},
"historyBuckets": {
"today": "今日",
"yesterday": "昨日",
"withinWeek": "1週間以内",
"withinTwoWeeks": "2週間以内",
"withinMonth": "1か月以内",
"older": "1か月より前"
}
}
}

View File

@@ -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": "不明"
}
}
}

View File

@@ -12,8 +12,10 @@
"quickActions": {
"title": "クイックアクション",
"description": "よく使うタスクとショートカット",
"addProvider": "プロバイダー追加",
"addChannel": "チャンネル追加",
"browseSkills": "スキルを探す",
"createCron": "定期タスク作成",
"installSkill": "スキルをインストール",
"openChat": "チャットを開く",
"settings": "設定",
"devConsole": "開発者コンソール"

View File

@@ -112,6 +112,8 @@
"proxyHttpsServerHelp": "HTTPS リクエスト用の高度な上書き設定です。空欄の場合はプロキシサーバーを使用します。",
"proxyAllServer": "ALL_PROXY / SOCKS",
"proxyAllServerHelp": "SOCKS 対応クライアントや Telegram など向けの高度なフォールバックです。空欄の場合はプロキシサーバーを使用します。",
"showAdvancedProxy": "高度なプロキシ項目を表示",
"hideAdvancedProxy": "高度なプロキシ項目を非表示",
"proxyBypass": "バイパスルール",
"proxyBypassHelp": "直接接続するホストをセミコロン、カンマ、または改行で区切って指定します。",
"proxyRestartNote": "保存すると Electron のネットワーク設定を再適用し、Gateway をすぐ再起動します。",
@@ -152,6 +154,25 @@
"advanced": {
"title": "詳細設定",
"description": "上級ユーザー向けオプション",
"transport": {
"label": "Gateway 転送優先度",
"desc": "レンダラープロセスから Gateway RPC を呼ぶ際の優先プロトコルを選択します。",
"saved": "Gateway 転送優先度を保存しました",
"options": {
"wsFirst": "WS 優先",
"httpFirst": "HTTP 優先",
"wsOnly": "WS のみ",
"httpOnly": "HTTP のみ",
"ipcOnly": "IPC のみ"
},
"descriptions": {
"wsFirst": "WS -> HTTP -> IPC",
"httpFirst": "HTTP -> WS -> IPC",
"wsOnly": "WS -> IPC",
"httpOnly": "HTTP -> IPC",
"ipcOnly": "IPC のみ"
}
},
"devMode": "開発者モード",
"devModeDesc": "開発者ツールとショートカットを表示"
},

View File

@@ -16,6 +16,10 @@
"search": "スキルを検索...",
"searchMarketplace": "マーケットプレイスを検索...",
"searchButton": "検索",
"actions": {
"enableVisible": "表示中を一括有効化",
"disableVisible": "表示中を一括無効化"
},
"noSkills": "スキルが見つかりません",
"noSkillsSearch": "別の検索語をお試しください",
"noSkillsAvailable": "利用可能なスキルがありません",
@@ -63,7 +67,12 @@
"searchRateLimitError": "検索リクエストの制限を超過しました。ClawHub.aiで検索してZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
"installRateLimitError": "インストールリクエストの制限を超過しました。ClawHub.aiからZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
"fetchTimeoutError": "スキルリストの取得がタイムアウトしました。ネットワークを確認してください。",
"fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。"
"fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。",
"noBatchEnableTargets": "表示中のスキルはすべて有効です。",
"noBatchDisableTargets": "表示中のスキルはすべて無効です。",
"batchEnabled": "{{count}} 件のスキルを有効化しました。",
"batchDisabled": "{{count}} 件のスキルを無効化しました。",
"batchPartial": "{{success}} / {{total}} 件を更新しました。一部失敗しています。"
},
"marketplace": {
"title": "マーケットプレイス",
@@ -74,4 +83,4 @@
"emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。",
"searchError": "ClawHub検索に失敗しました。接続またはインストールを確認してください。"
}
}
}

View File

@@ -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": "查看文档"
}
}

View File

@@ -14,5 +14,13 @@
"refresh": "刷新聊天",
"showThinking": "显示思考过程",
"hideThinking": "隐藏思考过程"
},
"historyBuckets": {
"today": "今天",
"yesterday": "昨天",
"withinWeek": "一周内",
"withinTwoWeeks": "两周内",
"withinMonth": "一个月内",
"older": "一个月之前"
}
}
}

View File

@@ -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": "未知"
}
}
}

View File

@@ -12,8 +12,10 @@
"quickActions": {
"title": "快捷操作",
"description": "常用任务和快捷方式",
"addProvider": "添加模型供应商",
"addChannel": "添加频道",
"browseSkills": "浏览技能",
"createCron": "创建定时任务",
"installSkill": "安装技能",
"openChat": "打开聊天",
"settings": "设置",
"devConsole": "开发者控制台"

View File

@@ -113,6 +113,8 @@
"proxyHttpsServerHelp": "HTTPS 请求的高级覆盖项。留空时使用“代理服务器”。",
"proxyAllServer": "ALL_PROXY / SOCKS",
"proxyAllServerHelp": "支持 SOCKS 的客户端和 Telegram 等协议的高级兜底代理。留空时使用“代理服务器”。",
"showAdvancedProxy": "显示高级代理字段",
"hideAdvancedProxy": "隐藏高级代理字段",
"proxyBypass": "绕过规则",
"proxyBypassHelp": "使用分号、逗号或换行分隔需要直连的主机。",
"proxyRestartNote": "保存后会立即重新应用 Electron 网络代理,并自动重启 Gateway。",
@@ -153,6 +155,25 @@
"advanced": {
"title": "高级",
"description": "高级选项",
"transport": {
"label": "网关传输策略",
"desc": "选择渲染进程访问网关 RPC 的优先协议WebSocket、HTTP 代理或 IPC 回退。",
"saved": "网关传输策略已保存",
"options": {
"wsFirst": "WS 优先",
"httpFirst": "HTTP 优先",
"wsOnly": "仅 WS",
"httpOnly": "仅 HTTP",
"ipcOnly": "仅 IPC"
},
"descriptions": {
"wsFirst": "WS -> HTTP -> IPC",
"httpFirst": "HTTP -> WS -> IPC",
"wsOnly": "WS -> IPC",
"httpOnly": "HTTP -> IPC",
"ipcOnly": "仅 IPC"
}
},
"devMode": "开发者模式",
"devModeDesc": "显示开发者工具和快捷方式"
},

View File

@@ -16,6 +16,10 @@
"search": "搜索技能...",
"searchMarketplace": "搜索市场...",
"searchButton": "搜索",
"actions": {
"enableVisible": "批量启用可见项",
"disableVisible": "批量禁用可见项"
},
"noSkills": "未找到技能",
"noSkillsSearch": "尝试不同的搜索词",
"noSkillsAvailable": "暂无可用技能",
@@ -63,7 +67,12 @@
"searchRateLimitError": "搜索请求过于频繁。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{{path}}\"",
"installRateLimitError": "安装请求过于频繁。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{{path}}\"",
"fetchTimeoutError": "获取技能列表超时,请检查网络。",
"fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。"
"fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。",
"noBatchEnableTargets": "当前可见技能都已启用。",
"noBatchDisableTargets": "当前可见技能都已禁用。",
"batchEnabled": "已启用 {{count}} 个技能。",
"batchDisabled": "已禁用 {{count}} 个技能。",
"batchPartial": "已更新 {{success}} / {{total}} 个技能,部分操作失败。"
},
"marketplace": {
"title": "市场",
@@ -74,4 +83,4 @@
"emptyPrompt": "搜索新技能以扩展您的能力。",
"searchError": "ClawHub 搜索失败。请检查您的连接或安装。"
}
}
}

916
src/lib/api-client.ts Normal file
View File

@@ -0,0 +1,916 @@
export type AppErrorCode =
| 'TIMEOUT'
| 'RATE_LIMIT'
| 'PERMISSION'
| 'NETWORK'
| 'CONFIG'
| 'GATEWAY'
| 'UNKNOWN';
export type TransportKind = 'ipc' | 'ws' | 'http';
export type GatewayTransportPreference =
| 'ws-first'
| 'http-first'
| 'ws-only'
| 'http-only'
| 'ipc-only';
type TransportInvoker = <T>(channel: string, args: unknown[]) => Promise<T>;
type TransportRequest = { channel: string; args: unknown[] };
type NormalizedTransportResponse = {
ok: boolean;
data?: unknown;
error?: unknown;
};
type UnifiedRequest = {
id: string;
module: string;
action: string;
payload?: unknown;
};
type UnifiedResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
type TransportRule = {
matcher: string | RegExp;
order: TransportKind[];
};
export type ApiClientTransportConfig = {
enabled: Record<Exclude<TransportKind, 'ipc'>, boolean>;
rules: TransportRule[];
};
const UNIFIED_CHANNELS = new Set<string>([
'app:version',
'app:name',
'app:platform',
'settings:getAll',
'settings:get',
'settings:set',
'settings:setMany',
'settings:reset',
'provider:list',
'provider:get',
'provider:getDefault',
'provider:hasApiKey',
'provider:getApiKey',
'provider:validateKey',
'provider:save',
'provider:delete',
'provider:setApiKey',
'provider:updateWithKey',
'provider:deleteApiKey',
'provider:setDefault',
'update:status',
'update:version',
'update:check',
'update:download',
'update:install',
'update:setChannel',
'update:setAutoDownload',
'update:cancelAutoInstall',
'cron:list',
'cron:create',
'cron:update',
'cron:delete',
'cron:toggle',
'cron:trigger',
'usage:recentTokenHistory',
]);
const customInvokers = new Map<Exclude<TransportKind, 'ipc'>, TransportInvoker>();
let transportConfig: ApiClientTransportConfig = {
enabled: {
ws: false,
http: false,
},
rules: [
{ matcher: /^gateway:rpc$/, order: ['ws', 'ipc'] },
{ matcher: /^gateway:/, order: ['ipc'] },
{ matcher: /.*/, order: ['ipc'] },
],
};
type GatewayStatusLike = {
port?: unknown;
};
type HttpTransportOptions = {
endpointResolver: () => Promise<string> | string;
headers?: HeadersInit;
timeoutMs?: number;
fetchImpl?: typeof fetch;
buildRequest?: (request: TransportRequest) => {
url?: string;
method?: string;
headers?: HeadersInit;
body?: BodyInit | null;
};
parseResponse?: (response: Response) => Promise<NormalizedTransportResponse>;
};
type WsTransportOptions = {
urlResolver: () => Promise<string> | string;
timeoutMs?: number;
websocketFactory?: (url: string) => WebSocket;
buildMessage?: (requestId: string, request: TransportRequest) => unknown;
parseMessage?: (payload: unknown) => { id?: string; ok: boolean; data?: unknown; error?: unknown } | null;
};
type GatewayWsTransportOptions = {
urlResolver?: () => Promise<string> | string;
tokenResolver?: () => Promise<string | null> | string | null;
timeoutMs?: number;
websocketFactory?: (url: string) => WebSocket;
};
let cachedGatewayPort: { port: number; expiresAt: number } | null = null;
const transportBackoffUntil: Partial<Record<Exclude<TransportKind, 'ipc'>, number>> = {};
async function resolveGatewayPort(): Promise<number> {
const now = Date.now();
if (cachedGatewayPort && cachedGatewayPort.expiresAt > now) {
return cachedGatewayPort.port;
}
const status = await invokeViaIpc<GatewayStatusLike>('gateway:status', []);
const port = typeof status?.port === 'number' && status.port > 0 ? status.port : 18789;
cachedGatewayPort = { port, expiresAt: now + 5000 };
return port;
}
export async function resolveDefaultGatewayHttpBaseUrl(): Promise<string> {
const port = await resolveGatewayPort();
return `http://127.0.0.1:${port}`;
}
export async function resolveDefaultGatewayWsUrl(): Promise<string> {
const port = await resolveGatewayPort();
return `ws://127.0.0.1:${port}/ws`;
}
class TransportUnsupportedError extends Error {
transport: TransportKind;
constructor(transport: TransportKind, message: string) {
super(message);
this.transport = transport;
}
}
export class AppError extends Error {
code: AppErrorCode;
cause?: unknown;
constructor(code: AppErrorCode, message: string, cause?: unknown) {
super(message);
this.code = code;
this.cause = cause;
}
}
function mapUnifiedErrorCode(code?: string): AppErrorCode {
switch (code) {
case 'TIMEOUT':
return 'TIMEOUT';
case 'PERMISSION':
return 'PERMISSION';
case 'GATEWAY':
return 'GATEWAY';
case 'VALIDATION':
return 'CONFIG';
case 'UNSUPPORTED':
return 'UNKNOWN';
default:
return 'UNKNOWN';
}
}
function normalizeError(err: unknown): AppError {
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
if (lower.includes('timeout')) {
return new AppError('TIMEOUT', message, err);
}
if (lower.includes('rate limit')) {
return new AppError('RATE_LIMIT', message, err);
}
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) {
return new AppError('PERMISSION', message, err);
}
if (lower.includes('network') || lower.includes('fetch')) {
return new AppError('NETWORK', message, err);
}
if (lower.includes('gateway')) {
return new AppError('GATEWAY', message, err);
}
if (lower.includes('config') || lower.includes('invalid')) {
return new AppError('CONFIG', message, err);
}
return new AppError('UNKNOWN', message, err);
}
function isRuleMatch(matcher: string | RegExp, channel: string): boolean {
if (typeof matcher === 'string') {
if (matcher.endsWith('*')) {
return channel.startsWith(matcher.slice(0, -1));
}
return matcher === channel;
}
return matcher.test(channel);
}
function resolveTransportOrder(channel: string): TransportKind[] {
const now = Date.now();
const matchedRule = transportConfig.rules.find((rule) => isRuleMatch(rule.matcher, channel));
const order = matchedRule?.order ?? ['ipc'];
return order.filter((kind) => {
if (kind === 'ipc') return true;
const backoffUntil = transportBackoffUntil[kind];
if (typeof backoffUntil === 'number' && backoffUntil > now) {
return false;
}
return transportConfig.enabled[kind];
});
}
function markTransportFailure(kind: TransportKind): void {
if (kind === 'ipc') return;
transportBackoffUntil[kind] = Date.now() + 5000;
}
export function clearTransportBackoff(kind?: Exclude<TransportKind, 'ipc'>): void {
if (kind) {
delete transportBackoffUntil[kind];
return;
}
delete transportBackoffUntil.ws;
delete transportBackoffUntil.http;
}
function gatewayRulesForPreference(preference: GatewayTransportPreference): TransportRule[] {
switch (preference) {
case 'http-first':
return [
{ matcher: /^gateway:rpc$/, order: ['http', 'ws', 'ipc'] },
{ matcher: /^gateway:/, order: ['ipc'] },
{ matcher: /.*/, order: ['ipc'] },
];
case 'ws-only':
return [
{ matcher: /^gateway:rpc$/, order: ['ws', 'ipc'] },
{ matcher: /^gateway:/, order: ['ipc'] },
{ matcher: /.*/, order: ['ipc'] },
];
case 'http-only':
return [
{ matcher: /^gateway:rpc$/, order: ['http', 'ipc'] },
{ matcher: /^gateway:/, order: ['ipc'] },
{ matcher: /.*/, order: ['ipc'] },
];
case 'ipc-only':
return [
{ matcher: /^gateway:rpc$/, order: ['ipc'] },
{ matcher: /^gateway:/, order: ['ipc'] },
{ matcher: /.*/, order: ['ipc'] },
];
case 'ws-first':
default:
return [
{ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] },
{ matcher: /^gateway:/, order: ['ipc'] },
{ matcher: /.*/, order: ['ipc'] },
];
}
}
export function applyGatewayTransportPreference(preference: GatewayTransportPreference): void {
const enableWs = preference === 'ws-first' || preference === 'http-first' || preference === 'ws-only';
const enableHttp = preference === 'ws-first' || preference === 'http-first' || preference === 'http-only';
clearTransportBackoff();
configureApiClient({
enabled: {
ws: enableWs,
http: enableHttp,
},
rules: gatewayRulesForPreference(preference),
});
}
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
const splitIndex = channel.indexOf(':');
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
module: channel.slice(0, splitIndex),
action: channel.slice(splitIndex + 1),
payload: args.length <= 1 ? args[0] : args,
};
}
async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
if (channel !== 'app:request' && UNIFIED_CHANNELS.has(channel)) {
const request = toUnifiedRequest(channel, args);
try {
const response = await window.electron.ipcRenderer.invoke('app:request', request) as UnifiedResponse;
if (!response?.ok) {
const message = response?.error?.message || 'Unified IPC request failed';
if (message.includes('APP_REQUEST_UNSUPPORTED:')) {
throw new Error(message);
}
throw new AppError(mapUnifiedErrorCode(response?.error?.code), message, response?.error);
}
return response.data as T;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) {
// Fallback to legacy channel handlers.
} else {
throw normalizeError(err);
}
}
}
try {
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
} catch (err) {
throw normalizeError(err);
}
}
async function invokeViaTransport<T>(kind: TransportKind, channel: string, args: unknown[]): Promise<T> {
if (kind === 'ipc') {
return invokeViaIpc<T>(channel, args);
}
const invoker = customInvokers.get(kind);
if (!invoker) {
throw new TransportUnsupportedError(kind, `${kind.toUpperCase()} transport invoker is not registered`);
}
return invoker<T>(channel, args);
}
export function configureApiClient(next: Partial<ApiClientTransportConfig>): void {
transportConfig = {
enabled: {
...transportConfig.enabled,
...(next.enabled ?? {}),
},
rules: next.rules ?? transportConfig.rules,
};
}
export function getApiClientConfig(): ApiClientTransportConfig {
return {
enabled: { ...transportConfig.enabled },
rules: [...transportConfig.rules],
};
}
export function registerTransportInvoker(kind: Exclude<TransportKind, 'ipc'>, invoker: TransportInvoker): void {
customInvokers.set(kind, invoker);
}
export function unregisterTransportInvoker(kind: Exclude<TransportKind, 'ipc'>): void {
customInvokers.delete(kind);
}
export function createHttpTransportInvoker(options: HttpTransportOptions): TransportInvoker {
const timeoutMs = options.timeoutMs ?? 15000;
const fetchImpl = options.fetchImpl ?? fetch;
return async <T>(channel: string, args: unknown[]): Promise<T> => {
const baseUrl = await Promise.resolve(options.endpointResolver());
if (!baseUrl) {
throw new Error('HTTP transport endpoint is empty');
}
const request = { channel, args };
const built = options.buildRequest?.(request);
const url = built?.url ?? `${baseUrl.replace(/\/$/, '')}/rpc`;
const method = built?.method ?? 'POST';
const headers = {
'Content-Type': 'application/json',
...(options.headers ?? {}),
...(built?.headers ?? {}),
};
const body = built?.body ?? JSON.stringify(request);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
method,
headers,
body,
signal: controller.signal,
});
const parsed = options.parseResponse
? await options.parseResponse(response)
: await response.json() as NormalizedTransportResponse;
if (!parsed?.ok) {
throw new Error(String(parsed?.error ?? 'HTTP transport request failed'));
}
return parsed.data as T;
} finally {
clearTimeout(timer);
}
};
}
export function createWsTransportInvoker(options: WsTransportOptions): TransportInvoker {
const timeoutMs = options.timeoutMs ?? 15000;
const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url));
let socket: WebSocket | null = null;
let connectPromise: Promise<WebSocket> | null = null;
const pending = new Map<string, { resolve: (value: unknown) => void; reject: (reason?: unknown) => void; timer: ReturnType<typeof setTimeout> }>();
const clearPending = (error: Error) => {
for (const [id, item] of pending.entries()) {
clearTimeout(item.timer);
item.reject(error);
pending.delete(id);
}
};
const ensureConnection = async (): Promise<WebSocket> => {
if (socket && socket.readyState === WebSocket.OPEN) {
return socket;
}
if (connectPromise) {
return connectPromise;
}
connectPromise = (async () => {
const url = await Promise.resolve(options.urlResolver());
if (!url) {
throw new Error('WS transport URL is empty');
}
const ws = websocketFactory(url);
return await new Promise<WebSocket>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener('open', onOpen);
ws.removeEventListener('error', onError);
};
const onOpen = () => {
cleanup();
resolve(ws);
};
const onError = (event: Event) => {
cleanup();
reject(new Error(`WS transport connection failed: ${String(event.type)}`));
};
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
});
})();
try {
socket = await connectPromise;
socket.addEventListener('message', (event) => {
try {
const raw = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
const parsed = options.parseMessage
? options.parseMessage(raw)
: (raw as { id?: string; ok: boolean; data?: unknown; error?: unknown });
if (!parsed?.id) return;
const item = pending.get(parsed.id);
if (!item) return;
clearTimeout(item.timer);
pending.delete(parsed.id);
if (parsed.ok) {
item.resolve(parsed.data);
} else {
item.reject(new Error(String(parsed.error ?? 'WS transport request failed')));
}
} catch {
// ignore malformed event payloads
}
});
socket.addEventListener('close', () => {
socket = null;
clearPending(new Error('WS transport closed'));
});
socket.addEventListener('error', () => {
socket = null;
});
return socket;
} finally {
connectPromise = null;
}
};
return async <T>(channel: string, args: unknown[]): Promise<T> => {
const ws = await ensureConnection();
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const message = options.buildMessage
? options.buildMessage(requestId, { channel, args })
: { id: requestId, channel, args };
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error('WS transport timeout'));
}, timeoutMs);
pending.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
});
try {
ws.send(JSON.stringify(message));
} catch (err) {
clearTimeout(timer);
pending.delete(requestId);
reject(err);
}
});
};
}
export function createGatewayHttpTransportInvoker(
_endpointResolver: () => Promise<string> | string = resolveDefaultGatewayHttpBaseUrl,
): TransportInvoker {
return async <T>(channel: string, args: unknown[]): Promise<T> => {
if (channel !== 'gateway:rpc') {
throw new Error(`HTTP gateway transport does not support channel: ${channel}`);
}
const [method, params, timeoutOverride] = args;
if (typeof method !== 'string') {
throw new Error('gateway:rpc requires method string');
}
const timeoutMs =
typeof timeoutOverride === 'number' && timeoutOverride > 0
? timeoutOverride
: 15000;
const response = await invokeViaIpc<{
success: boolean;
status?: number;
ok?: boolean;
json?: unknown;
text?: string;
error?: string;
}>('gateway:httpProxy', [{
path: '/rpc',
method: 'POST',
timeoutMs,
body: {
type: 'req',
method,
params,
},
}]);
if (!response?.success) {
throw new Error(response?.error || 'Gateway HTTP proxy failed');
}
const payload = response?.json as Record<string, unknown> | undefined;
if (!payload || typeof payload !== 'object') {
throw new Error(response?.text || `Gateway HTTP returned non-JSON (status=${response?.status ?? 'unknown'})`);
}
if (payload.type === 'res') {
if (payload.ok === false || payload.error) {
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
}
return (payload.payload ?? payload) as T;
}
if ('ok' in payload) {
if (!payload.ok) {
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
}
return (payload.data ?? payload) as T;
}
return payload as T;
};
}
export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptions = {}): TransportInvoker {
const timeoutMs = options.timeoutMs ?? 15000;
const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url));
const resolveUrl = options.urlResolver ?? resolveDefaultGatewayWsUrl;
const resolveToken = options.tokenResolver ?? (() => invokeViaIpc<string | null>('settings:get', ['gatewayToken']));
let socket: WebSocket | null = null;
let connectPromise: Promise<WebSocket> | null = null;
let handshakeDone = false;
let connectRequestId: string | null = null;
const pending = new Map<string, {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
timer: ReturnType<typeof setTimeout>;
}>();
const clearPending = (error: Error) => {
for (const [id, item] of pending.entries()) {
clearTimeout(item.timer);
item.reject(error);
pending.delete(id);
}
};
const sendConnect = async (_challengeNonce: string) => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
throw new Error('Gateway WS not open during connect handshake');
}
const token = await Promise.resolve(resolveToken());
connectRequestId = `connect-${Date.now()}`;
socket.send(JSON.stringify({
type: 'req',
id: connectRequestId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'clawx-ui',
displayName: 'ClawX UI',
version: '0.1.0',
platform: window.electron?.platform ?? 'unknown',
mode: 'ui',
},
auth: {
token: token ?? null,
},
caps: [],
role: 'operator',
scopes: ['operator.admin'],
},
}));
};
const ensureConnection = async (): Promise<WebSocket> => {
if (socket && socket.readyState === WebSocket.OPEN && handshakeDone) {
return socket;
}
if (connectPromise) {
return connectPromise;
}
connectPromise = (async () => {
const url = await Promise.resolve(resolveUrl());
if (!url) {
throw new Error('Gateway WS URL is empty');
}
const ws = websocketFactory(url);
socket = ws;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Gateway WS connect timeout'));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timer);
ws.removeEventListener('open', onOpen);
ws.removeEventListener('error', onError);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error('Gateway WS open failed'));
};
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
});
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Gateway WS handshake timeout'));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timer);
ws.removeEventListener('message', onHandshakeMessage);
};
const onHandshakeMessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(String(event.data)) as Record<string, unknown>;
if (msg.type === 'event' && msg.event === 'connect.challenge') {
const payload = (msg.payload ?? {}) as Record<string, unknown>;
const nonce = typeof payload.nonce === 'string' ? payload.nonce : '';
if (!nonce) {
cleanup();
reject(new Error('Gateway WS challenge nonce missing'));
return;
}
void sendConnect(nonce).catch((err) => {
cleanup();
reject(err);
});
return;
}
if (msg.type === 'res' && typeof msg.id === 'string' && msg.id === connectRequestId) {
const ok = msg.ok !== false && !msg.error;
if (!ok) {
cleanup();
reject(new Error(`Gateway WS connect failed: ${String(msg.error ?? 'unknown')}`));
return;
}
handshakeDone = true;
cleanup();
resolve();
}
} catch {
// ignore parse errors during handshake
}
};
ws.addEventListener('message', onHandshakeMessage);
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(String(event.data)) as Record<string, unknown>;
if (msg.type !== 'res' || typeof msg.id !== 'string') return;
const item = pending.get(msg.id);
if (!item) return;
clearTimeout(item.timer);
pending.delete(msg.id);
const ok = msg.ok !== false && !msg.error;
if (!ok) {
item.reject(new Error(String(msg.error ?? 'Gateway WS request failed')));
return;
}
item.resolve(msg.payload ?? msg);
} catch {
// ignore malformed payload
}
});
ws.addEventListener('close', () => {
socket = null;
handshakeDone = false;
connectRequestId = null;
clearPending(new Error('Gateway WS closed'));
});
ws.addEventListener('error', () => {
socket = null;
handshakeDone = false;
});
return ws;
})();
try {
return await connectPromise;
} finally {
connectPromise = null;
}
};
return async <T>(channel: string, args: unknown[]): Promise<T> => {
if (channel !== 'gateway:rpc') {
throw new Error(`Gateway WS transport does not support channel: ${channel}`);
}
const [method, params, timeoutOverride] = args;
if (typeof method !== 'string') {
throw new Error('gateway:rpc requires method string');
}
const requestTimeoutMs =
typeof timeoutOverride === 'number' && timeoutOverride > 0
? timeoutOverride
: timeoutMs;
const ws = await ensureConnection();
const requestId = crypto.randomUUID();
ws.send(JSON.stringify({
type: 'req',
id: requestId,
method,
params,
}));
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error(`Gateway WS timeout: ${method}`));
}, requestTimeoutMs);
pending.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
});
});
};
}
let defaultTransportsInitialized = false;
export function initializeDefaultTransports(): void {
if (defaultTransportsInitialized) return;
registerTransportInvoker('ws', createGatewayWsTransportInvoker());
registerTransportInvoker('http', createGatewayHttpTransportInvoker());
applyGatewayTransportPreference('ws-first');
defaultTransportsInitialized = true;
}
export function toUserMessage(error: unknown): string {
const appError = error instanceof AppError ? error : normalizeError(error);
switch (appError.code) {
case 'TIMEOUT':
return 'Request timed out. Please retry.';
case 'RATE_LIMIT':
return 'Too many requests. Please wait and try again.';
case 'PERMISSION':
return 'Permission denied. Check your configuration and retry.';
case 'NETWORK':
return 'Network error. Please verify connectivity and retry.';
case 'CONFIG':
return 'Configuration is invalid. Please review settings.';
case 'GATEWAY':
return 'Gateway is unavailable. Start or restart the gateway and retry.';
default:
return appError.message || 'Unexpected error occurred.';
}
}
export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise<T> {
const order = resolveTransportOrder(channel);
let lastError: unknown;
for (const kind of order) {
try {
return await invokeViaTransport<T>(kind, channel, args);
} catch (err) {
if (err instanceof TransportUnsupportedError) {
markTransportFailure(kind);
lastError = err;
continue;
}
lastError = err;
// For non-IPC transports, fail open to the next transport.
if (kind !== 'ipc') {
markTransportFailure(kind);
continue;
}
throw err;
}
}
throw normalizeError(lastError);
}
export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<T> {
return invokeApi<T>(channel, ...args);
}
export async function invokeIpcWithRetry<T>(
channel: string,
args: unknown[] = [],
retries = 1,
retryable: AppErrorCode[] = ['TIMEOUT', 'NETWORK'],
): Promise<T> {
let lastError: unknown;
for (let i = 0; i <= retries; i += 1) {
try {
return await invokeApi<T>(channel, ...args);
} catch (err) {
lastError = err;
if (!(err instanceof AppError) || !retryable.includes(err.code) || i === retries) {
throw err;
}
}
}
throw normalizeError(lastError);
}

29
src/lib/telemetry.ts Normal file
View File

@@ -0,0 +1,29 @@
type TelemetryPayload = Record<string, unknown>;
const counters = new Map<string, number>();
function safeStringify(payload: TelemetryPayload): string {
try {
return JSON.stringify(payload);
} catch {
return '{}';
}
}
export function trackUiEvent(event: string, payload: TelemetryPayload = {}): void {
const count = (counters.get(event) ?? 0) + 1;
counters.set(event, count);
const logPayload = {
...payload,
count,
ts: new Date().toISOString(),
};
// Local-only telemetry for UX diagnostics.
console.info(`[ui-metric] ${event} ${safeStringify(logPayload)}`);
}
export function getUiCounter(event: string): number {
return counters.get(event) ?? 0;
}

View File

@@ -7,6 +7,9 @@ import { HashRouter } from 'react-router-dom';
import App from './App';
import './i18n';
import './styles/globals.css';
import { initializeDefaultTransports } from './lib/api-client';
initializeDefaultTransports();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -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,
@@ -45,6 +45,7 @@ import {
} from '@/types/channel';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { invokeIpc } from '@/lib/api-client';
export function Channels() {
const { t } = useTranslation('channels');
@@ -54,17 +55,23 @@ export function Channels() {
const [showAddDialog, setShowAddDialog] = useState(false);
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastGatewayStateRef = useRef(gatewayStatus.state);
// Fetch channels on mount
useEffect(() => {
fetchChannels();
void fetchChannels({ probe: false });
}, [fetchChannels]);
// Fetch configured channel types from config file
const fetchConfiguredTypes = useCallback(async () => {
try {
const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as {
const result = await invokeIpc('channel:listConfigured') as {
success: boolean;
channels?: string[];
};
@@ -77,29 +84,86 @@ export function Channels() {
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchConfiguredTypes();
}, [fetchConfiguredTypes]);
useEffect(() => {
const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => {
fetchChannels();
fetchConfiguredTypes();
if (refreshDebounceRef.current) {
clearTimeout(refreshDebounceRef.current);
}
refreshDebounceRef.current = setTimeout(() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, 300);
});
return () => {
if (refreshDebounceRef.current) {
clearTimeout(refreshDebounceRef.current);
refreshDebounceRef.current = null;
}
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [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<string>(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) {
if (loading && channels.length === 0) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner size="lg" />
@@ -118,8 +182,20 @@ export function Channels() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="h-4 w-4 mr-2" />
<Button
variant="outline"
onClick={async () => {
try {
setRefreshing(true);
await fetchChannels({ probe: true, silent: true });
await fetchConfiguredTypes();
} finally {
setRefreshing(false);
}
}}
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2${refreshing ? ' animate-spin' : ''}`} />
{t('refresh')}
</Button>
<Button onClick={() => setShowAddDialog(true)}>
@@ -138,7 +214,7 @@ export function Channels() {
<Radio className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{channels.length}</p>
<p className="text-2xl font-bold">{configuredChannels.length}</p>
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
</div>
</div>
@@ -164,7 +240,7 @@ export function Channels() {
<PowerOff className="h-6 w-6 text-slate-600" />
</div>
<div>
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
</div>
</div>
@@ -173,7 +249,7 @@ export function Channels() {
</div>
{/* Gateway Warning */}
{gatewayStatus.state !== 'running' && (
{showGatewayWarning && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-500" />
@@ -194,7 +270,7 @@ export function Channels() {
)}
{/* Configured Channels */}
{channels.length > 0 && (
{configuredChannels.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('configured')}</CardTitle>
@@ -202,7 +278,7 @@ export function Channels() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
{configuredChannels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
@@ -230,7 +306,7 @@ export function Channels() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{displayedChannelTypes.map((type) => {
const meta = CHANNEL_META[type];
const isConfigured = configuredTypes.includes(type);
const isConfigured = configuredTypeSet.has(type);
return (
<button
key={type}
@@ -243,7 +319,7 @@ export function Channels() {
<span className="text-3xl">{meta.icon}</span>
<p className="font-medium mt-2">{meta.name}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{meta.description}
{t(meta.description)}
</p>
{isConfigured && (
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
@@ -272,8 +348,12 @@ export function Channels() {
setSelectedChannelType(null);
}}
onChannelAdded={() => {
fetchChannels();
fetchConfiguredTypes();
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
setTimeout(() => {
void fetchChannels({ probe: false, silent: true });
void fetchConfiguredTypes();
}, 2200);
setShowAddDialog(false);
setSelectedChannelType(null);
}}
@@ -282,14 +362,16 @@ export function Channels() {
<ConfirmDialog
open={!!channelToDelete}
title={t('common.confirm', 'Confirm')}
title={t('common:actions.confirm', 'Confirm')}
message={t('deleteConfirm')}
confirmLabel={t('common.delete', 'Delete')}
cancelLabel={t('common.cancel', 'Cancel')}
confirmLabel={t('common:actions.delete', 'Delete')}
cancelLabel={t('common:actions.cancel', 'Cancel')}
variant="destructive"
onConfirm={async () => {
if (channelToDelete) {
await deleteChannel(channelToDelete.id);
await fetchConfiguredTypes();
await fetchChannels({ probe: false, silent: true });
setChannelToDelete(null);
}
}}
@@ -355,7 +437,6 @@ interface AddChannelDialogProps {
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
const { t } = useTranslation('channels');
const { addChannel } = useChannelsStore();
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState('');
const [connecting, setConnecting] = useState(false);
@@ -382,7 +463,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setChannelName('');
setIsExistingConfig(false);
// Ensure we clean up any pending QR session if switching away
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
return;
}
@@ -391,7 +472,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
(async () => {
try {
const result = await window.electron.ipcRenderer.invoke(
const result = await invokeIpc(
'channel:getFormValues',
selectedType
) as { success: boolean; values?: Record<string, string> };
@@ -439,7 +520,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
toast.success(t('toast.whatsappConnected'));
const accountId = data?.accountId || channelName.trim() || 'default';
try {
const saveResult = await window.electron.ipcRenderer.invoke(
const saveResult = await invokeIpc(
'channel:saveConfig',
'whatsapp',
{ enabled: true }
@@ -452,15 +533,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
} catch (error) {
console.error('Failed to save WhatsApp config:', error);
}
// Register the channel locally so it shows up immediately
addChannel({
type: 'whatsapp',
name: channelName || 'WhatsApp',
}).then(() => {
// Restart gateway to pick up the new session
window.electron.ipcRenderer.invoke('gateway:restart').catch(console.error);
onChannelAdded();
});
// channel:saveConfig triggers main-process reload/restart handling.
// UI state refresh is handled by parent onChannelAdded().
onChannelAdded();
};
const onError = (...args: unknown[]) => {
@@ -480,9 +555,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
if (typeof removeSuccessListener === 'function') removeSuccessListener();
if (typeof removeErrorListener === 'function') removeErrorListener();
// Cancel when unmounting or switching types
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
};
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
}, [selectedType, channelName, onChannelAdded, t]);
const handleValidate = async () => {
if (!selectedType) return;
@@ -491,7 +566,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setValidationResult(null);
try {
const result = await window.electron.ipcRenderer.invoke(
const result = await invokeIpc(
'channel:validateCredentials',
selectedType,
configValues
@@ -538,14 +613,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// For QR-based channels, request QR code
if (meta.connectionType === 'qr') {
const accountId = channelName.trim() || 'default';
await window.electron.ipcRenderer.invoke('channel:requestWhatsAppQr', accountId);
await invokeIpc('channel:requestWhatsAppQr', accountId);
// The QR code will be set via event listener
return;
}
// Step 1: Validate credentials against the actual service API
if (meta.connectionType === 'token') {
const validationResponse = await window.electron.ipcRenderer.invoke(
const validationResponse = await invokeIpc(
'channel:validateCredentials',
selectedType,
configValues
@@ -592,7 +667,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// Step 2: Save channel configuration via IPC
const config: Record<string, unknown> = { ...configValues };
const saveResult = await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedType, config) as {
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
success?: boolean;
error?: string;
warning?: string;
@@ -605,20 +680,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
toast.warning(saveResult.warning);
}
// Step 3: Add a local channel entry for the UI
await addChannel({
type: selectedType,
name: channelName || CHANNEL_NAMES[selectedType],
token: configValues[meta.configFields[0]?.key] || undefined,
});
// Step 3: Do not call channels.add from renderer; this races with
// gateway reload/restart windows and can create stale local entries.
toast.success(t('toast.channelSaved', { name: meta.name }));
// Gateway restart is now handled server-side via debouncedRestart()
// inside the channel:saveConfig IPC handler, so we don't need to
// trigger it explicitly here. This avoids cascading restarts when
// multiple config changes happen in quick succession (e.g. during
// the setup wizard).
// Gateway reload/restart is handled in the main-process save handler.
// Renderer should only persist config and refresh local UI state.
toast.success(t('toast.channelConnecting', { name: meta.name }));
// Brief delay so user can see the success state before dialog closes

View File

@@ -10,6 +10,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { invokeIpc } from '@/lib/api-client';
// ── Types ────────────────────────────────────────────────────────
@@ -100,7 +101,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
const pickFiles = useCallback(async () => {
try {
const result = await window.electron.ipcRenderer.invoke('dialog:open', {
const result = await invokeIpc('dialog:open', {
properties: ['openFile', 'multiSelections'],
}) as { canceled: boolean; filePaths?: string[] };
if (result.canceled || !result.filePaths?.length) return;
@@ -125,7 +126,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
// Stage all files via IPC
console.log('[pickFiles] Staging files:', result.filePaths);
const staged = await window.electron.ipcRenderer.invoke(
const staged = await invokeIpc(
'file:stage',
result.filePaths,
) as Array<{
@@ -192,7 +193,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
console.log(`[stageBuffer] Reading file: ${file.name} (${file.type}, ${file.size} bytes)`);
const base64 = await readFileAsBase64(file);
console.log(`[stageBuffer] Base64 length: ${base64?.length ?? 'null'}`);
const staged = await window.electron.ipcRenderer.invoke('file:stageBuffer', {
const staged = await invokeIpc('file:stageBuffer', {
base64,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
@@ -226,6 +227,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
}, []);
const allReady = attachments.length === 0 || attachments.every(a => a.status === 'ready');
const hasFailedAttachments = attachments.some((a) => a.status === 'error');
const canSend = (input.trim() || attachments.length > 0) && allReady && !disabled && !sending;
const canStop = sending && !disabled && !!onStop;
@@ -391,6 +393,22 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
)}
</Button>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Tip: switch sessions from the sidebar to keep context clean.</span>
{hasFailedAttachments && (
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => {
setAttachments((prev) => prev.filter((att) => att.status !== 'error'));
void pickFiles();
}}
>
Retry failed attachments
</Button>
)}
</div>
</div>
</div>
);

View File

@@ -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]);

View File

@@ -37,17 +37,18 @@ import { toast } from 'sonner';
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
// Common cron schedule presets
const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [
{ label: 'Every minute', value: '* * * * *', type: 'interval' },
{ label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' },
{ label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' },
{ label: 'Every hour', value: '0 * * * *', type: 'interval' },
{ label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' },
{ label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' },
{ label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' },
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [
{ key: 'everyMinute', value: '* * * * *', type: 'interval' },
{ key: 'every5Min', value: '*/5 * * * *', type: 'interval' },
{ key: 'every15Min', value: '*/15 * * * *', type: 'interval' },
{ key: 'everyHour', value: '0 * * * *', type: 'interval' },
{ key: 'daily9am', value: '0 9 * * *', type: 'daily' },
{ key: 'daily6pm', value: '0 18 * * *', type: 'daily' },
{ key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' },
{ key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' },
];
// Parse cron schedule to human-readable format
@@ -55,25 +56,25 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] =
// { kind: "cron", expr: "...", tz?: "..." }
// { kind: "every", everyMs: number }
// { kind: "at", at: "..." }
function parseCronSchedule(schedule: unknown): string {
function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string {
// Handle Gateway CronSchedule object format
if (schedule && typeof schedule === 'object') {
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
if (s.kind === 'cron' && typeof s.expr === 'string') {
return parseCronExpr(s.expr);
return parseCronExpr(s.expr, t);
}
if (s.kind === 'every' && typeof s.everyMs === 'number') {
const ms = s.everyMs;
if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`;
if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`;
if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`;
return `Every ${Math.round(ms / 86_400_000)} days`;
if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) });
if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) });
if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) });
return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) });
}
if (s.kind === 'at' && typeof s.at === 'string') {
try {
return `Once at ${new Date(s.at).toLocaleString()}`;
return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() });
} catch {
return `Once at ${s.at}`;
return t('schedule.onceAt', { time: s.at });
}
}
return String(schedule);
@@ -81,39 +82,96 @@ function parseCronSchedule(schedule: unknown): string {
// Handle plain cron string
if (typeof schedule === 'string') {
return parseCronExpr(schedule);
return parseCronExpr(schedule, t);
}
return String(schedule ?? 'Unknown');
return String(schedule ?? t('schedule.unknown'));
}
// Parse a plain cron expression string to human-readable text
function parseCronExpr(cron: string): string {
function parseCronExpr(cron: string, t: TFunction<'cron'>): string {
const preset = schedulePresets.find((p) => p.value === cron);
if (preset) return preset.label;
if (preset) return t(`presets.${preset.key}` as const);
const parts = cron.split(' ');
if (parts.length !== 5) return cron;
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
if (minute === '*' && hour === '*') return 'Every minute';
if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`;
if (hour === '*' && minute === '0') return 'Every hour';
if (minute === '*' && hour === '*') return t('presets.everyMinute');
if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) });
if (hour === '*' && minute === '0') return t('presets.everyHour');
if (dayOfWeek !== '*' && dayOfMonth === '*') {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`;
return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` });
}
if (dayOfMonth !== '*') {
return `Monthly on day ${dayOfMonth} at ${hour}:${minute.padStart(2, '0')}`;
return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` });
}
if (hour !== '*') {
return `Daily at ${hour}:${minute.padStart(2, '0')}`;
return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` });
}
return cron;
}
function estimateNextRun(scheduleExpr: string): string | null {
const now = new Date();
const next = new Date(now.getTime());
if (scheduleExpr === '* * * * *') {
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + 1);
return next.toLocaleString();
}
if (scheduleExpr === '*/5 * * * *') {
const delta = 5 - (next.getMinutes() % 5 || 5);
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + delta);
return next.toLocaleString();
}
if (scheduleExpr === '*/15 * * * *') {
const delta = 15 - (next.getMinutes() % 15 || 15);
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + delta);
return next.toLocaleString();
}
if (scheduleExpr === '0 * * * *') {
next.setMinutes(0, 0, 0);
next.setHours(next.getHours() + 1);
return next.toLocaleString();
}
if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') {
const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18;
next.setSeconds(0, 0);
next.setHours(targetHour, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next.toLocaleString();
}
if (scheduleExpr === '0 9 * * 1') {
next.setSeconds(0, 0);
next.setHours(9, 0, 0, 0);
const day = next.getDay();
const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7;
next.setDate(next.getDate() + daysUntilMonday);
return next.toLocaleString();
}
if (scheduleExpr === '0 9 1 * *') {
next.setSeconds(0, 0);
next.setDate(1);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setMonth(next.getMonth() + 1);
return next.toLocaleString();
}
return null;
}
// Create/Edit Task Dialog
interface TaskDialogProps {
job?: CronJob;
@@ -141,6 +199,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const [customSchedule, setCustomSchedule] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [enabled, setEnabled] = useState(job?.enabled ?? true);
const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule);
const handleSubmit = async () => {
if (!name.trim()) {
@@ -226,15 +285,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
className="justify-start"
>
<Timer className="h-4 w-4 mr-2" />
{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)}
</Button>
))}
</div>
@@ -254,6 +305,9 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
>
{useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')}
</Button>
<p className="text-xs text-muted-foreground">
{schedulePreview ? `${t('card.next')}: ${schedulePreview}` : t('dialog.cronPlaceholder')}
</p>
</div>
{/* Enabled */}
@@ -270,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onClose}>
Cancel
{t('common:actions.cancel', 'Cancel')}
</Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
{t('common:status.saving', 'Saving...')}
</>
) : (
<>
@@ -312,7 +366,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
toast.success(t('toast.triggered'));
} catch (error) {
console.error('Failed to trigger cron job:', error);
toast.error(`Failed to trigger task: ${error instanceof Error ? error.message : String(error)}`);
toast.error(t('toast.failedTrigger', { error: error instanceof Error ? error.message : String(error) }));
} finally {
setTriggering(false);
}
@@ -345,7 +399,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
<CardTitle className="text-lg">{job.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Timer className="h-3 w-3" />
{parseCronSchedule(job.schedule)}
{parseCronSchedule(job.schedule, t)}
</CardDescription>
</div>
</div>
@@ -423,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
</Button>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4" />
<span className="ml-1">Edit</span>
<span className="ml-1">{t('common:actions.edit', 'Edit')}</span>
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
<span className="ml-1 text-destructive">Delete</span>
<span className="ml-1 text-destructive">{t('common:actions.delete', 'Delete')}</span>
</Button>
</div>
</CardContent>
@@ -643,10 +697,10 @@ export function Cron() {
<ConfirmDialog
open={!!jobToDelete}
title={t('common.confirm', 'Confirm')}
title={t('common:actions.confirm', 'Confirm')}
message={t('card.deleteConfirm')}
confirmLabel={t('common.delete', 'Delete')}
cancelLabel={t('common.cancel', 'Cancel')}
confirmLabel={t('common:actions.delete', 'Delete')}
cancelLabel={t('common:actions.cancel', 'Cancel')}
variant="destructive"
onConfirm={async () => {
if (jobToDelete) {

View File

@@ -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<UsageHistoryEntry[]>('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() {
<CardDescription>{t('quickActions.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/channels">
<Link to="/settings" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'add_provider' })}>
<Wrench className="h-5 w-5" />
<span>{t('quickActions.addProvider')}</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/channels" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'add_channel' })}>
<Plus className="h-5 w-5" />
<span>{t('quickActions.addChannel')}</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/skills">
<Puzzle className="h-5 w-5" />
<span>{t('quickActions.browseSkills')}</span>
<Link to="/cron" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'create_cron' })}>
<Clock className="h-5 w-5" />
<span>{t('quickActions.createCron')}</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/">
<Link to="/skills" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'install_skill' })}>
<Puzzle className="h-5 w-5" />
<span>{t('quickActions.installSkill')}</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'open_chat' })}>
<MessageSquare className="h-5 w-5" />
<span>{t('quickActions.openChat')}</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/settings">
<Link to="/settings" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'open_settings' })}>
<Settings className="h-5 w-5" />
<span>{t('quickActions.settings')}</span>
</Link>
@@ -244,13 +261,15 @@ export function Dashboard() {
</CardHeader>
<CardContent>
{channels.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('noChannels')}</p>
<Button variant="link" asChild className="mt-2">
<Link to="/channels">{t('addFirst')}</Link>
</Button>
</div>
<FeedbackState
state="empty"
title={t('noChannels')}
action={(
<Button variant="link" asChild className="mt-2">
<Link to="/channels">{t('addFirst')}</Link>
</Button>
)}
/>
) : (
<div className="space-y-3">
{channels.slice(0, 5).map((channel) => (
@@ -286,13 +305,15 @@ export function Dashboard() {
</CardHeader>
<CardContent>
{skills.filter((s) => s.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('noSkills')}</p>
<Button variant="link" asChild className="mt-2">
<Link to="/skills">{t('enableSome')}</Link>
</Button>
</div>
<FeedbackState
state="empty"
title={t('noSkills')}
action={(
<Button variant="link" asChild className="mt-2">
<Link to="/skills">{t('enableSome')}</Link>
</Button>
)}
/>
) : (
<div className="flex flex-wrap gap-2">
{skills
@@ -322,17 +343,11 @@ export function Dashboard() {
</CardHeader>
<CardContent>
{usageLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('recentTokenHistory.loading')}</div>
<FeedbackState state="loading" title={t('recentTokenHistory.loading')} />
) : visibleUsageHistory.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('recentTokenHistory.empty')}</p>
</div>
<FeedbackState state="empty" title={t('recentTokenHistory.empty')} />
) : filteredUsageHistory.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('recentTokenHistory.emptyForWindow')}</p>
</div>
<FeedbackState state="empty" title={t('recentTokenHistory.emptyForWindow')} />
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">

View File

@@ -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<string>('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<string>('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() {
</div>
{devModeUnlocked && (
<>
<div className="rounded-md border border-border/60 p-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => setShowAdvancedProxy((prev) => !prev)}
>
{showAdvancedProxy ? (
<ChevronDown className="h-4 w-4 mr-2" />
) : (
<ChevronRight className="h-4 w-4 mr-2" />
)}
{showAdvancedProxy ? t('gateway.hideAdvancedProxy') : t('gateway.showAdvancedProxy')}
</Button>
{showAdvancedProxy && (
<div className="mt-3 space-y-4">
<div className="space-y-2">
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
<Input
@@ -477,7 +511,9 @@ export function Settings() {
{t('gateway.proxyAllServerHelp')}
</p>
</div>
</>
</div>
)}
</div>
)}
<div className="space-y-2">
@@ -585,6 +621,34 @@ export function Settings() {
<CardDescription>{t('developer.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<Label>{t('advanced.transport.label')}</Label>
<p className="text-sm text-muted-foreground">
{t('advanced.transport.desc')}
</p>
</div>
<div className="grid gap-2">
{transportOptions.map((option) => (
<Button
key={option.value}
type="button"
variant={gatewayTransportPreference === option.value ? 'default' : 'outline'}
className="justify-between"
onClick={() => {
setGatewayTransportPreference(option.value);
toast.success(t('advanced.transport.saved'));
}}
>
<span>{t(option.labelKey)}</span>
<span className="text-xs opacity-80">{t(option.descKey)}</span>
</Button>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('developer.console')}</Label>
<p className="text-sm text-muted-foreground">

View File

@@ -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<string>(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({
<Button
variant="secondary"
className="w-full"
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
onClick={() => invokeIpc('shell:openExternal', oauthData.verificationUri)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Login Page
@@ -1363,7 +1376,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
setOverallProgress(10);
// Step 2: Call the backend to install uv and setup Python
const result = await window.electron.ipcRenderer.invoke('uv:install-all') as {
const result = await invokeIpc('uv:install-all') as {
success: boolean;
error?: string
};

View File

@@ -38,6 +38,8 @@ import { useSkillsStore } from '@/stores/skills';
import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import { trackUiEvent } from '@/lib/telemetry';
import { toast } from 'sonner';
import type { Skill, MarketplaceSkill } from '@/types/skill';
import { useTranslation } from 'react-i18next';
@@ -84,14 +86,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
const handleOpenClawhub = async () => {
if (skill.slug) {
await window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
await invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
}
};
const handleOpenEditor = async () => {
if (skill.slug) {
try {
const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string };
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:openSkillReadme', skill.slug);
if (result.success) {
toast.success(t('toast.openedEditor'));
} else {
@@ -134,7 +136,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
}, {} as Record<string, string>);
// Use direct file access instead of Gateway RPC for reliability
const result = await window.electron.ipcRenderer.invoke(
const result = await invokeIpc<{ success: boolean; error?: string }>(
'skill:updateConfig',
{
skillKey: skill.id,
@@ -381,7 +383,7 @@ function MarketplaceSkillCard({
onUninstall
}: MarketplaceSkillCardProps) {
const handleCardClick = () => {
window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
void invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
};
return (
@@ -600,6 +602,35 @@ export function Skills() {
marketplace: skills.filter(s => !s.isBundled).length,
};
const bulkToggleVisible = useCallback(async (enable: boolean) => {
const candidates = filteredSkills.filter((skill) => !skill.isCore && skill.enabled !== enable);
if (candidates.length === 0) {
toast.info(enable ? t('toast.noBatchEnableTargets') : t('toast.noBatchDisableTargets'));
return;
}
let succeeded = 0;
for (const skill of candidates) {
try {
if (enable) {
await enableSkill(skill.id);
} else {
await disableSkill(skill.id);
}
succeeded += 1;
} catch {
// Continue to next skill and report final summary.
}
}
trackUiEvent('skills.batch_toggle', { enable, total: candidates.length, succeeded });
if (succeeded === candidates.length) {
toast.success(enable ? t('toast.batchEnabled', { count: succeeded }) : t('toast.batchDisabled', { count: succeeded }));
return;
}
toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length }));
}, [disableSkill, enableSkill, filteredSkills, t]);
// Handle toggle
const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
try {
@@ -619,11 +650,11 @@ export function Skills() {
const handleOpenSkillsFolder = useCallback(async () => {
try {
const skillsDir = await window.electron.ipcRenderer.invoke('openclaw:getSkillsDir') as string;
const skillsDir = await invokeIpc<string>('openclaw:getSkillsDir');
if (!skillsDir) {
throw new Error('Skills directory not available');
}
const result = await window.electron.ipcRenderer.invoke('shell:openPath', skillsDir) as string;
const result = await invokeIpc<string>('shell:openPath', skillsDir);
if (result) {
// shell.openPath returns an error string if the path doesn't exist
if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) {
@@ -640,7 +671,7 @@ export function Skills() {
const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills');
useEffect(() => {
window.electron.ipcRenderer.invoke('openclaw:getSkillsDir')
invokeIpc<string>('openclaw:getSkillsDir')
.then((dir) => setSkillsDirPath(dir as string))
.catch(console.error);
}, []);
@@ -804,6 +835,20 @@ export function Skills() {
<Globe className="h-3 w-3" />
{t('filter.marketplace', { count: sourceStats.marketplace })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void bulkToggleVisible(true); }}
>
{t('actions.enableVisible')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void bulkToggleVisible(false); }}
>
{t('actions.disableVisible')}
</Button>
</div>
</div>

View File

@@ -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<void>;
fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise<void>;
addChannel: (params: AddChannelParams) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
connectChannel: (channelId: string) => Promise<void>;
@@ -33,13 +34,17 @@ export const useChannelsStore = create<ChannelsState>((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<ChannelsState>((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<ChannelsState>((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<ChannelsState>((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<ChannelsState>((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<ChannelsState>((set, get) => ({
},
requestQrCode: async (channelType) => {
const result = await window.electron.ipcRenderer.invoke(
const result = await invokeIpc(
'gateway:rpc',
'channels.requestQr',
{ type: channelType }

View File

@@ -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<boolean> {
if (needPreview.length === 0) return false;
try {
const thumbnails = await window.electron.ipcRenderer.invoke(
const thumbnails = await invokeIpc(
'media:getThumbnails',
needPreview,
) as Record<string, { preview: string | null; fileSize: number }>;
@@ -928,7 +929,7 @@ export const useChatStore = create<ChatState>((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<ChatState>((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<ChatState>((set, get) => ({
// The main process renames <suffix>.jsonl → <suffix>.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<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((set, get) => ({
set({ streamingTools: [] });
try {
await window.electron.ipcRenderer.invoke(
await invokeIpc(
'gateway:rpc',
'chat.abort',
{ sessionKey: currentSessionKey },

View File

@@ -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<CronState>((set) => ({
set({ loading: true, error: null });
try {
const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
const result = await invokeIpc<CronJob[]>('cron:list');
set({ jobs: result, loading: false });
} catch (error) {
set({ error: String(error), loading: false });
@@ -38,7 +39,7 @@ export const useCronStore = create<CronState>((set) => ({
createJob: async (input) => {
try {
const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob;
const job = await invokeIpc<CronJob>('cron:create', input);
set((state) => ({ jobs: [...state.jobs, job] }));
return job;
} catch (error) {
@@ -49,7 +50,7 @@ export const useCronStore = create<CronState>((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<CronState>((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<CronState>((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<CronState>((set) => ({
triggerJob: async (id) => {
try {
const result = await window.electron.ipcRenderer.invoke('cron:trigger', id);
const result = await invokeIpc<unknown>('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<CronJob[]>('cron:list');
set({ jobs });
} catch {
// Ignore refresh error

View File

@@ -4,6 +4,7 @@
*/
import { create } from 'zustand';
import type { GatewayStatus } from '../types/gateway';
import { invokeIpc } from '@/lib/api-client';
let gatewayInitPromise: Promise<void> | null = null;
@@ -49,7 +50,7 @@ export const useGatewayStore = create<GatewayState>((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<GatewayState>((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<GatewayState>((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<GatewayState>((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<GatewayState>((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<GatewayState>((set, get) => ({
},
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
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;

View File

@@ -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<ProviderState>((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<ProviderWithKeyInfo[]>('provider:list');
const defaultId = await invokeIpc<string | null>('provider:getDefault');
set({
providers,
@@ -66,7 +67,7 @@ export const useProviderStore = create<ProviderState>((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<ProviderState>((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<ProviderState>((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<ProviderState>((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<ProviderState>((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<ProviderState>((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<ProviderState>((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<ProviderState>((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<ProviderState>((set, get) => ({
getApiKey: async (providerId) => {
try {
return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null;
return await invokeIpc<string | null>('provider:getApiKey', providerId);
} catch {
return null;
}

View File

@@ -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: '<local>;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<SettingsState>()(
init: async () => {
try {
const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial<typeof defaultSettings>;
const settings = await invokeIpc<Partial<typeof defaultSettings>>('settings:getAll');
set((state) => ({ ...state, ...settings }));
if (settings.language) {
i18n.changeLanguage(settings.language);
@@ -106,17 +111,21 @@ export const useSettingsStore = create<SettingsState>()(
},
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 }),

View File

@@ -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<SkillsState>((set, get) => ({
}
try {
// 1. Fetch from Gateway (running skills)
const gatewayResult = await window.electron.ipcRenderer.invoke(
const gatewayResult = await invokeIpc<GatewayRpcResponse<GatewaySkillsStatusResult>>(
'gateway:rpc',
'skills.status'
) as GatewayRpcResponse<GatewaySkillsStatusResult>;
);
// 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<Record<string, { apiKey?: string; env?: Record<string, string> }>>(
'skill:getAllConfigs'
) as Record<string, { apiKey?: string; env?: Record<string, string> }>;
);
let combinedSkills: Skill[] = [];
const currentSkills = get().skills;
@@ -155,7 +156,7 @@ export const useSkillsStore = create<SkillsState>((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<SkillsState>((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<SkillsState>((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<SkillsState>((set, get) => ({
const { updateSkill } = get();
try {
const result = await window.electron.ipcRenderer.invoke(
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
'gateway:rpc',
'skills.update',
{ skillKey: skillId, enabled: true }
) as GatewayRpcResponse<unknown>;
);
if (result.success) {
updateSkill(skillId, { enabled: true });
@@ -252,11 +253,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}
try {
const result = await window.electron.ipcRenderer.invoke(
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
'gateway:rpc',
'skills.update',
{ skillKey: skillId, enabled: false }
) as GatewayRpcResponse<unknown>;
);
if (result.success) {
updateSkill(skillId, { enabled: false });

View File

@@ -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<UpdateState>((set, get) => ({
// Get current version
try {
const version = await window.electron.ipcRenderer.invoke('update:version');
const version = await invokeIpc<string>('update:version');
set({ currentVersion: version as string });
} catch (error) {
console.error('Failed to get version:', error);
@@ -71,12 +72,12 @@ export const useUpdateStore = create<UpdateState>((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<UpdateState>((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<UpdateState>((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<UpdateState>((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<UpdateState>((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<UpdateState>((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<UpdateState>((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);
}

View File

@@ -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);
});
});

View File

@@ -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(<FeedbackState state="loading" title="Loading data" description="Please wait" />);
expect(screen.getByText('Loading data')).toBeInTheDocument();
expect(screen.getByText('Please wait')).toBeInTheDocument();
});
it('renders action for empty state', () => {
render(
<FeedbackState
state="empty"
title="Nothing here"
action={<button type="button">Create one</button>}
/>,
);
expect(screen.getByRole('button', { name: 'Create one' })).toBeInTheDocument();
});
it('renders error state title', () => {
render(<FeedbackState state="error" title="Request failed" />);
expect(screen.getByText('Request failed')).toBeInTheDocument();
});
});