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(); }> = new Map();
private deviceIdentity: DeviceIdentity | null = null; private deviceIdentity: DeviceIdentity | null = null;
private restartDebounceTimer: NodeJS.Timeout | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null;
private reloadDebounceTimer: NodeJS.Timeout | null = null;
private lifecycleEpoch = 0; private lifecycleEpoch = 0;
private deferredRestartPending = false; private deferredRestartPending = false;
private restartInFlight: Promise<void> | null = null; private restartInFlight: Promise<void> | null = null;
private externalShutdownSupported: boolean | null = null;
constructor(config?: Partial<ReconnectConfig>) { constructor(config?: Partial<ReconnectConfig>) {
super(); super();
@@ -259,6 +261,11 @@ export class GatewayManager extends EventEmitter {
return sanitized; 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 { private formatExit(code: number | null, signal: NodeJS.Signals | null): string {
if (code !== null) return `code=${code}`; if (code !== null) return `code=${code}`;
if (signal) return `signal=${signal}`; 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. // 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('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 }; 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. // Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg }; 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 // If this manager is attached to an external gateway process, ask it to shut down
// over protocol before closing the socket. // 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 { try {
await this.rpc('shutdown', undefined, 5000); await this.rpc('shutdown', undefined, 5000);
this.externalShutdownSupported = true;
} catch (error) { } 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); }, 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 * Clear all active timers
*/ */
@@ -668,6 +754,10 @@ export class GatewayManager extends EventEmitter {
clearTimeout(this.restartDebounceTimer); clearTimeout(this.restartDebounceTimer);
this.restartDebounceTimer = null; 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 { applyProxySettings } from './proxy';
import { proxyAwareFetch } from '../utils/proxy-fetch'; import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getRecentTokenUsageHistory } from '../utils/token-usage'; 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 { import {
getOpenClawProviderKeyForType, getOpenClawProviderKeyForType,
getOAuthApiKeyEnv, getOAuthApiKeyEnv,
@@ -136,6 +155,9 @@ export function registerIpcHandlers(
clawHubService: ClawHubService, clawHubService: ClawHubService,
mainWindow: BrowserWindow mainWindow: BrowserWindow
): void { ): void {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
// Gateway handlers // Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow); registerGatewayHandlers(gatewayManager, mainWindow);
@@ -143,7 +165,7 @@ export function registerIpcHandlers(
registerClawHubHandlers(clawHubService); registerClawHubHandlers(clawHubService);
// OpenClaw handlers // OpenClaw handlers
registerOpenClawHandlers(); registerOpenClawHandlers(gatewayManager);
// Provider handlers // Provider handlers
registerProviderHandlers(gatewayManager); registerProviderHandlers(gatewayManager);
@@ -191,6 +213,701 @@ export function registerIpcHandlers(
registerFileHandlers(); 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 * Skill config IPC handlers
* Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC) * Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC)
@@ -493,6 +1210,14 @@ function registerGatewayHandlers(
gatewayManager: GatewayManager, gatewayManager: GatewayManager,
mainWindow: BrowserWindow mainWindow: BrowserWindow
): void { ): void {
type GatewayHttpProxyRequest = {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
timeoutMs?: number;
};
// Get Gateway status // Get Gateway status
ipcMain.handle('gateway:status', () => { ipcMain.handle('gateway:status', () => {
return gatewayManager.getStatus(); 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. // Chat send with media — reads staged files from disk and builds attachments.
// Raster images (png/jpg/gif/webp) are inlined as base64 vision 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 // All other files are referenced by path in the message text so the model
@@ -700,7 +1491,7 @@ function registerGatewayHandlers(
* OpenClaw-related IPC handlers * OpenClaw-related IPC handlers
* For checking package status and channel configuration * For checking package status and channel configuration
*/ */
function registerOpenClawHandlers(): void { function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json'); const targetManifest = join(targetDir, 'openclaw.plugin.json');
@@ -813,9 +1604,12 @@ function registerOpenClawHandlers(): void {
}; };
} }
await saveChannelConfig(channelType, config); await saveChannelConfig(channelType, config);
logger.info( if (gatewayManager.getStatus().state !== 'stopped') {
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally` 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 { return {
success: true, success: true,
pluginInstalled: installResult.installed, pluginInstalled: installResult.installed,
@@ -823,12 +1617,12 @@ function registerOpenClawHandlers(): void {
}; };
} }
await saveChannelConfig(channelType, config); await saveChannelConfig(channelType, config);
// Do not force stop/start here. Recent Gateway builds detect channel config if (gatewayManager.getStatus().state !== 'stopped') {
// changes and perform an internal service restart; forcing another restart logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
// from Electron can race with reconnect and kill the newly spawned process. gatewayManager.debouncedReload();
logger.info( } else {
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload` logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
); }
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Failed to save channel config:', error); console.error('Failed to save channel config:', error);
@@ -862,6 +1656,12 @@ function registerOpenClawHandlers(): void {
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
try { try {
await deleteChannelConfig(channelType); 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 }; return { success: true };
} catch (error) { } catch (error) {
console.error('Failed to delete channel config:', 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) => { ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
try { try {
await setChannelEnabled(channelType, enabled); 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 }; return { success: true };
} catch (error) { } catch (error) {
console.error('Failed to set channel enabled:', error); console.error('Failed to set channel enabled:', error);

View File

@@ -21,6 +21,7 @@ const electronAPI = {
'gateway:stop', 'gateway:stop',
'gateway:restart', 'gateway:restart',
'gateway:rpc', 'gateway:rpc',
'gateway:httpProxy',
'gateway:health', 'gateway:health',
'gateway:getControlUiUrl', 'gateway:getControlUiUrl',
// OpenClaw // OpenClaw
@@ -41,6 +42,7 @@ const electronAPI = {
'app:platform', 'app:platform',
'app:quit', 'app:quit',
'app:relaunch', 'app:relaunch',
'app:request',
// Window controls // Window controls
'window:minimize', 'window:minimize',
'window:maximize', 'window:maximize',

View File

@@ -39,6 +39,7 @@ export interface PluginsConfig {
export interface OpenClawConfig { export interface OpenClawConfig {
channels?: Record<string, ChannelConfigData>; channels?: Record<string, ChannelConfigData>;
plugins?: PluginsConfig; plugins?: PluginsConfig;
commands?: Record<string, unknown>;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
await ensureConfigDir(); await ensureConfigDir();
try { 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'); await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) { } catch (error) {
logger.error('Failed to write OpenClaw config', 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> { 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); 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 ───────────────────────────────────── // ── tools.web.search.kimi ─────────────────────────────────────
// OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over // OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over
// environment/auth-profiles. A stale inline key can cause persistent 401s. // environment/auth-profiles. A stale inline key can cause persistent 401s.

View File

@@ -36,6 +36,7 @@ export interface AppSettings {
proxyHttpsServer: string; proxyHttpsServer: string;
proxyAllServer: string; proxyAllServer: string;
proxyBypassRules: string; proxyBypassRules: string;
gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
// Update // Update
updateChannel: 'stable' | 'beta' | 'dev'; updateChannel: 'stable' | 'beta' | 'dev';
@@ -73,6 +74,7 @@ const defaults: AppSettings = {
proxyHttpsServer: '', proxyHttpsServer: '',
proxyAllServer: '', proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1', proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
gatewayTransportPreference: 'ws-first',
// Update // Update
updateChannel: 'stable', updateChannel: 'stable',

View File

@@ -40,4 +40,22 @@ export default [
'@typescript-eslint/no-explicit-any': 'warn', '@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 { Setup } from './pages/Setup';
import { useSettingsStore } from './stores/settings'; import { useSettingsStore } from './stores/settings';
import { useGatewayStore } from './stores/gateway'; import { useGatewayStore } from './stores/gateway';
import { applyGatewayTransportPreference } from './lib/api-client';
/** /**
@@ -90,6 +91,7 @@ function App() {
const initSettings = useSettingsStore((state) => state.init); const initSettings = useSettingsStore((state) => state.init);
const theme = useSettingsStore((state) => state.theme); const theme = useSettingsStore((state) => state.theme);
const language = useSettingsStore((state) => state.language); const language = useSettingsStore((state) => state.language);
const gatewayTransportPreference = useSettingsStore((state) => state.gatewayTransportPreference);
const setupComplete = useSettingsStore((state) => state.setupComplete); const setupComplete = useSettingsStore((state) => state.setupComplete);
const initGateway = useGatewayStore((state) => state.init); const initGateway = useGatewayStore((state) => state.init);
@@ -149,6 +151,10 @@ function App() {
} }
}, [theme]); }, [theme]);
useEffect(() => {
applyGatewayTransportPreference(gatewayTransportPreference);
}, [gatewayTransportPreference]);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<TooltipProvider delayDuration={300}> <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. * Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar. * 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 { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
Home, Home,
@@ -24,8 +24,17 @@ import { useChatStore } from '@/stores/chat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { invokeIpc } from '@/lib/api-client';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
interface NavItemProps { interface NavItemProps {
to: string; to: string;
icon: React.ReactNode; 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() { export function Sidebar() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
@@ -82,15 +110,12 @@ export function Sidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const isOnChat = useLocation().pathname === '/'; 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) => const getSessionLabel = (key: string, displayName?: string, label?: string) =>
sessionLabels[key] ?? label ?? displayName ?? key; sessionLabels[key] ?? label ?? displayName ?? key;
const openDevConsole = async () => { const openDevConsole = async () => {
try { try {
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { const result = await invokeIpc('gateway:getControlUiUrl') as {
success: boolean; success: boolean;
url?: string; url?: string;
error?: 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 [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 = [ const navItems = [
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') }, { 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 */} {/* Session list — below Settings, only when expanded */}
{!sidebarCollapsed && sessions.length > 0 && ( {!sidebarCollapsed && sessions.length > 0 && (
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5"> <div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
{[...mainSessions, ...[...otherSessions].sort((a, b) => {sessionBuckets.map((bucket) => (
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0) bucket.sessions.length > 0 ? (
)].map((s) => ( <div key={bucket.key} className="pt-1">
<div key={s.key} className="group relative flex items-center"> <div className="px-3 py-1 text-[11px] font-medium text-muted-foreground/80">
<button {bucket.label}
onClick={() => { switchSession(s.key); navigate('/'); }} </div>
className={cn( {bucket.sessions.map((s) => (
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors', <div key={s.key} className="group relative flex items-center">
!s.key.endsWith(':main') && 'pr-7', <button
'hover:bg-accent hover:text-accent-foreground', onClick={() => { switchSession(s.key); navigate('/'); }}
isOnChat && currentSessionKey === s.key className={cn(
? 'bg-accent/60 text-accent-foreground font-medium' 'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors pr-7',
: 'text-muted-foreground', 'hover:bg-accent hover:text-accent-foreground',
)} isOnChat && currentSessionKey === s.key
> ? 'bg-accent/60 text-accent-foreground font-medium'
{getSessionLabel(s.key, s.displayName, s.label)} : 'text-muted-foreground',
</button> )}
{!s.key.endsWith(':main') && ( >
<button {getSessionLabel(s.key, s.displayName, s.label)}
aria-label="Delete session" </button>
onClick={(e) => { <button
e.stopPropagation(); aria-label="Delete session"
setSessionToDelete({ onClick={(e) => {
key: s.key, e.stopPropagation();
label: getSessionLabel(s.key, s.displayName, s.label), 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', className={cn(
'text-muted-foreground hover:text-destructive hover:bg-destructive/10', '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> >
)} <Trash2 className="h-3.5 w-3.5" />
</div> </button>
</div>
))}
</div>
) : null
))} ))}
</div> </div>
)} )}

View File

@@ -6,6 +6,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Minus, Square, X, Copy } from 'lucide-react'; import { Minus, Square, X, Copy } from 'lucide-react';
import logoSvg from '@/assets/logo.svg'; import logoSvg from '@/assets/logo.svg';
import { invokeIpc } from '@/lib/api-client';
const isMac = window.electron?.platform === 'darwin'; const isMac = window.electron?.platform === 'darwin';
@@ -23,25 +24,25 @@ function WindowsTitleBar() {
useEffect(() => { useEffect(() => {
// Check initial state // Check initial state
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => { invokeIpc('window:isMaximized').then((val) => {
setMaximized(val as boolean); setMaximized(val as boolean);
}); });
}, []); }, []);
const handleMinimize = () => { const handleMinimize = () => {
window.electron.ipcRenderer.invoke('window:minimize'); invokeIpc('window:minimize');
}; };
const handleMaximize = () => { const handleMaximize = () => {
window.electron.ipcRenderer.invoke('window:maximize').then(() => { invokeIpc('window:maximize').then(() => {
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => { invokeIpc('window:isMaximized').then((val) => {
setMaximized(val as boolean); setMaximized(val as boolean);
}); });
}); });
}; };
const handleClose = () => { const handleClose = () => {
window.electron.ipcRenderer.invoke('window:close'); invokeIpc('window:close');
}; };
return ( return (

View File

@@ -35,6 +35,7 @@ import {
shouldInvertInDark, shouldInvertInDark,
} from '@/lib/providers'; } from '@/lib/providers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
@@ -704,7 +705,7 @@ function AddProviderDialog({
setOauthError(null); setOauthError(null);
try { try {
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType); await invokeIpc('provider:requestOAuth', selectedType);
} catch (e) { } catch (e) {
setOauthError(String(e)); setOauthError(String(e));
setOauthFlowing(false); setOauthFlowing(false);
@@ -715,7 +716,7 @@ function AddProviderDialog({
setOauthFlowing(false); setOauthFlowing(false);
setOauthData(null); setOauthData(null);
setOauthError(null); setOauthError(null);
await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); await invokeIpc('provider:cancelOAuth');
}; };
// Only custom can be added multiple times. // Only custom can be added multiple times.
@@ -1013,7 +1014,7 @@ function AddProviderDialog({
<Button <Button
variant="secondary" variant="secondary"
className="w-full" 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" /> <ExternalLink className="h-4 w-4 mr-2" />
{t('aiProviders.oauth.openLoginPage')} {t('aiProviders.oauth.openLoginPage')}

View File

@@ -11,6 +11,10 @@
"gatewayWarning": "Gateway service is not running. Channels cannot connect.", "gatewayWarning": "Gateway service is not running. Channels cannot connect.",
"available": "Available Channels", "available": "Available Channels",
"availableDesc": "Connect a new channel", "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", "showAll": "Show All",
"pluginBadge": "Plugin", "pluginBadge": "Plugin",
"toast": { "toast": {
@@ -37,6 +41,8 @@
"viewDocs": "View Documentation", "viewDocs": "View Documentation",
"channelName": "Channel Name", "channelName": "Channel Name",
"channelNamePlaceholder": "My {{name}}", "channelNamePlaceholder": "My {{name}}",
"enableChannel": "Enable Channel",
"enableChannelDesc": "When off, config is saved but the channel stays disabled",
"credentialsVerified": "Credentials Verified", "credentialsVerified": "Credentials Verified",
"validationFailed": "Validation Failed", "validationFailed": "Validation Failed",
"warnings": "Warnings", "warnings": "Warnings",

View File

@@ -14,5 +14,13 @@
"refresh": "Refresh chat", "refresh": "Refresh chat",
"showThinking": "Show thinking", "showThinking": "Show thinking",
"hideThinking": "Hide 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", "paused": "Task paused",
"deleted": "Task deleted", "deleted": "Task deleted",
"triggered": "Task triggered successfully", "triggered": "Task triggered successfully",
"failedTrigger": "Failed to trigger task: {{error}}",
"failedUpdate": "Failed to update task", "failedUpdate": "Failed to update task",
"failedDelete": "Failed to delete task", "failedDelete": "Failed to delete task",
"nameRequired": "Please enter a task name", "nameRequired": "Please enter a task name",
@@ -66,5 +67,16 @@
"channelRequired": "Please select a channel", "channelRequired": "Please select a channel",
"discordIdRequired": "Please enter a Discord Channel ID", "discordIdRequired": "Please enter a Discord Channel ID",
"scheduleRequired": "Please select or enter a schedule" "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": { "quickActions": {
"title": "Quick Actions", "title": "Quick Actions",
"description": "Common tasks and shortcuts", "description": "Common tasks and shortcuts",
"addProvider": "Add Provider",
"addChannel": "Add Channel", "addChannel": "Add Channel",
"browseSkills": "Browse Skills", "createCron": "Create Cron",
"installSkill": "Install Skill",
"openChat": "Open Chat", "openChat": "Open Chat",
"settings": "Settings", "settings": "Settings",
"devConsole": "Dev Console" "devConsole": "Dev Console"

View File

@@ -113,6 +113,8 @@
"proxyHttpsServerHelp": "Advanced override for HTTPS requests. Leave blank to use Proxy Server.", "proxyHttpsServerHelp": "Advanced override for HTTPS requests. Leave blank to use Proxy Server.",
"proxyAllServer": "ALL_PROXY / SOCKS", "proxyAllServer": "ALL_PROXY / SOCKS",
"proxyAllServerHelp": "Advanced fallback for SOCKS-capable clients and protocols such as Telegram. Leave blank to use Proxy Server.", "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", "proxyBypass": "Bypass Rules",
"proxyBypassHelp": "Semicolon, comma, or newline separated hosts that should connect directly.", "proxyBypassHelp": "Semicolon, comma, or newline separated hosts that should connect directly.",
"proxyRestartNote": "Saving reapplies Electron networking and restarts the Gateway immediately.", "proxyRestartNote": "Saving reapplies Electron networking and restarts the Gateway immediately.",
@@ -153,6 +155,25 @@
"advanced": { "advanced": {
"title": "Advanced", "title": "Advanced",
"description": "Power-user options", "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", "devMode": "Developer Mode",
"devModeDesc": "Show developer tools and shortcuts" "devModeDesc": "Show developer tools and shortcuts"
}, },

View File

@@ -16,6 +16,10 @@
"search": "Search skills...", "search": "Search skills...",
"searchMarketplace": "Search marketplace...", "searchMarketplace": "Search marketplace...",
"searchButton": "Search", "searchButton": "Search",
"actions": {
"enableVisible": "Enable Visible",
"disableVisible": "Disable Visible"
},
"noSkills": "No skills found", "noSkills": "No skills found",
"noSkillsSearch": "Try a different search term", "noSkillsSearch": "Try a different search term",
"noSkillsAvailable": "No skills available", "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}}\"", "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}}\"", "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.", "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": { "marketplace": {
"title": "Marketplace", "title": "Marketplace",

View File

@@ -11,6 +11,10 @@
"gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。", "gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。",
"available": "利用可能なチャンネル", "available": "利用可能なチャンネル",
"availableDesc": "新しいチャンネルを接続", "availableDesc": "新しいチャンネルを接続",
"configured": "設定済みチャンネル",
"configuredDesc": "すでに設定済みのチャンネルを管理",
"configuredBadge": "設定済み",
"deleteConfirm": "このチャンネルを削除してもよろしいですか?",
"showAll": "すべて表示", "showAll": "すべて表示",
"pluginBadge": "プラグイン", "pluginBadge": "プラグイン",
"toast": { "toast": {
@@ -37,6 +41,8 @@
"viewDocs": "ドキュメントを表示", "viewDocs": "ドキュメントを表示",
"channelName": "チャンネル名", "channelName": "チャンネル名",
"channelNamePlaceholder": "マイ {{name}}", "channelNamePlaceholder": "マイ {{name}}",
"enableChannel": "チャンネルを有効化",
"enableChannelDesc": "オフの場合、設定のみ保存しチャンネルは起動しません",
"credentialsVerified": "認証情報が確認されました", "credentialsVerified": "認証情報が確認されました",
"validationFailed": "検証に失敗しました", "validationFailed": "検証に失敗しました",
"warnings": "警告", "warnings": "警告",

View File

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

View File

@@ -59,6 +59,7 @@
"paused": "タスクを停止しました", "paused": "タスクを停止しました",
"deleted": "タスクを削除しました", "deleted": "タスクを削除しました",
"triggered": "タスクを正常にトリガーしました", "triggered": "タスクを正常にトリガーしました",
"failedTrigger": "タスクの実行に失敗しました: {{error}}",
"failedUpdate": "タスクの更新に失敗しました", "failedUpdate": "タスクの更新に失敗しました",
"failedDelete": "タスクの削除に失敗しました", "failedDelete": "タスクの削除に失敗しました",
"nameRequired": "タスク名を入力してください", "nameRequired": "タスク名を入力してください",
@@ -66,5 +67,16 @@
"channelRequired": "チャンネルを選択してください", "channelRequired": "チャンネルを選択してください",
"discordIdRequired": "DiscordチャンネルIDを入力してください", "discordIdRequired": "DiscordチャンネルIDを入力してください",
"scheduleRequired": "スケジュールを選択または入力してください" "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": { "quickActions": {
"title": "クイックアクション", "title": "クイックアクション",
"description": "よく使うタスクとショートカット", "description": "よく使うタスクとショートカット",
"addProvider": "プロバイダー追加",
"addChannel": "チャンネル追加", "addChannel": "チャンネル追加",
"browseSkills": "スキルを探す", "createCron": "定期タスク作成",
"installSkill": "スキルをインストール",
"openChat": "チャットを開く", "openChat": "チャットを開く",
"settings": "設定", "settings": "設定",
"devConsole": "開発者コンソール" "devConsole": "開発者コンソール"

View File

@@ -112,6 +112,8 @@
"proxyHttpsServerHelp": "HTTPS リクエスト用の高度な上書き設定です。空欄の場合はプロキシサーバーを使用します。", "proxyHttpsServerHelp": "HTTPS リクエスト用の高度な上書き設定です。空欄の場合はプロキシサーバーを使用します。",
"proxyAllServer": "ALL_PROXY / SOCKS", "proxyAllServer": "ALL_PROXY / SOCKS",
"proxyAllServerHelp": "SOCKS 対応クライアントや Telegram など向けの高度なフォールバックです。空欄の場合はプロキシサーバーを使用します。", "proxyAllServerHelp": "SOCKS 対応クライアントや Telegram など向けの高度なフォールバックです。空欄の場合はプロキシサーバーを使用します。",
"showAdvancedProxy": "高度なプロキシ項目を表示",
"hideAdvancedProxy": "高度なプロキシ項目を非表示",
"proxyBypass": "バイパスルール", "proxyBypass": "バイパスルール",
"proxyBypassHelp": "直接接続するホストをセミコロン、カンマ、または改行で区切って指定します。", "proxyBypassHelp": "直接接続するホストをセミコロン、カンマ、または改行で区切って指定します。",
"proxyRestartNote": "保存すると Electron のネットワーク設定を再適用し、Gateway をすぐ再起動します。", "proxyRestartNote": "保存すると Electron のネットワーク設定を再適用し、Gateway をすぐ再起動します。",
@@ -152,6 +154,25 @@
"advanced": { "advanced": {
"title": "詳細設定", "title": "詳細設定",
"description": "上級ユーザー向けオプション", "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": "開発者モード", "devMode": "開発者モード",
"devModeDesc": "開発者ツールとショートカットを表示" "devModeDesc": "開発者ツールとショートカットを表示"
}, },

View File

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

View File

@@ -11,6 +11,10 @@
"gatewayWarning": "网关服务未运行,频道无法连接。", "gatewayWarning": "网关服务未运行,频道无法连接。",
"available": "可用频道", "available": "可用频道",
"availableDesc": "连接一个新的频道", "availableDesc": "连接一个新的频道",
"configured": "已配置频道",
"configuredDesc": "管理已完成配置的频道",
"configuredBadge": "已配置",
"deleteConfirm": "确定要删除此频道吗?",
"showAll": "显示全部", "showAll": "显示全部",
"pluginBadge": "插件", "pluginBadge": "插件",
"toast": { "toast": {
@@ -37,6 +41,8 @@
"viewDocs": "查看文档", "viewDocs": "查看文档",
"channelName": "频道名称", "channelName": "频道名称",
"channelNamePlaceholder": "我的 {{name}}", "channelNamePlaceholder": "我的 {{name}}",
"enableChannel": "启用频道",
"enableChannelDesc": "关闭后会保存配置,但不会启动该频道",
"credentialsVerified": "凭证已验证", "credentialsVerified": "凭证已验证",
"validationFailed": "验证失败", "validationFailed": "验证失败",
"warnings": "警告", "warnings": "警告",

View File

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

View File

@@ -59,6 +59,7 @@
"paused": "任务已暂停", "paused": "任务已暂停",
"deleted": "任务已删除", "deleted": "任务已删除",
"triggered": "任务已成功触发", "triggered": "任务已成功触发",
"failedTrigger": "触发任务失败: {{error}}",
"failedUpdate": "更新任务失败", "failedUpdate": "更新任务失败",
"failedDelete": "删除任务失败", "failedDelete": "删除任务失败",
"nameRequired": "请输入任务名称", "nameRequired": "请输入任务名称",
@@ -66,5 +67,16 @@
"channelRequired": "请选择频道", "channelRequired": "请选择频道",
"discordIdRequired": "请输入 Discord 频道 ID", "discordIdRequired": "请输入 Discord 频道 ID",
"scheduleRequired": "请选择或输入调度计划" "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": { "quickActions": {
"title": "快捷操作", "title": "快捷操作",
"description": "常用任务和快捷方式", "description": "常用任务和快捷方式",
"addProvider": "添加模型供应商",
"addChannel": "添加频道", "addChannel": "添加频道",
"browseSkills": "浏览技能", "createCron": "创建定时任务",
"installSkill": "安装技能",
"openChat": "打开聊天", "openChat": "打开聊天",
"settings": "设置", "settings": "设置",
"devConsole": "开发者控制台" "devConsole": "开发者控制台"

View File

@@ -113,6 +113,8 @@
"proxyHttpsServerHelp": "HTTPS 请求的高级覆盖项。留空时使用“代理服务器”。", "proxyHttpsServerHelp": "HTTPS 请求的高级覆盖项。留空时使用“代理服务器”。",
"proxyAllServer": "ALL_PROXY / SOCKS", "proxyAllServer": "ALL_PROXY / SOCKS",
"proxyAllServerHelp": "支持 SOCKS 的客户端和 Telegram 等协议的高级兜底代理。留空时使用“代理服务器”。", "proxyAllServerHelp": "支持 SOCKS 的客户端和 Telegram 等协议的高级兜底代理。留空时使用“代理服务器”。",
"showAdvancedProxy": "显示高级代理字段",
"hideAdvancedProxy": "隐藏高级代理字段",
"proxyBypass": "绕过规则", "proxyBypass": "绕过规则",
"proxyBypassHelp": "使用分号、逗号或换行分隔需要直连的主机。", "proxyBypassHelp": "使用分号、逗号或换行分隔需要直连的主机。",
"proxyRestartNote": "保存后会立即重新应用 Electron 网络代理,并自动重启 Gateway。", "proxyRestartNote": "保存后会立即重新应用 Electron 网络代理,并自动重启 Gateway。",
@@ -153,6 +155,25 @@
"advanced": { "advanced": {
"title": "高级", "title": "高级",
"description": "高级选项", "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": "开发者模式", "devMode": "开发者模式",
"devModeDesc": "显示开发者工具和快捷方式" "devModeDesc": "显示开发者工具和快捷方式"
}, },

View File

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

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 App from './App';
import './i18n'; import './i18n';
import './styles/globals.css'; import './styles/globals.css';
import { initializeDefaultTransports } from './lib/api-client';
initializeDefaultTransports();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

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

View File

@@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import type { RawMessage, AttachedFileMeta } from '@/stores/chat'; import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils'; import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils';
@@ -539,7 +540,7 @@ function ImageLightbox({
const handleShowInFolder = useCallback(() => { const handleShowInFolder = useCallback(() => {
if (filePath) { if (filePath) {
window.electron.ipcRenderer.invoke('shell:showItemInFolder', filePath); invokeIpc('shell:showItemInFolder', filePath);
} }
}, [filePath]); }, [filePath]);

View File

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

View File

@@ -12,9 +12,9 @@ import {
Settings, Settings,
Plus, Plus,
Terminal, Terminal,
Coins,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 { useSkillsStore } from '@/stores/skills';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { StatusBadge } from '@/components/common/StatusBadge'; 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'; import { useTranslation } from 'react-i18next';
type UsageHistoryEntry = { type UsageHistoryEntry = {
@@ -60,12 +63,13 @@ export function Dashboard() {
// Fetch data only when gateway is running // Fetch data only when gateway is running
useEffect(() => { useEffect(() => {
trackUiEvent('dashboard.page_viewed');
if (isGatewayRunning) { if (isGatewayRunning) {
fetchChannels(); fetchChannels();
fetchSkills(); fetchSkills();
window.electron.ipcRenderer.invoke('usage:recentTokenHistory') invokeIpc<UsageHistoryEntry[]>('usage:recentTokenHistory')
.then((entries) => { .then((entries) => {
setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []); setUsageHistory(Array.isArray(entries) ? entries : []);
setUsagePage(1); setUsagePage(1);
}) })
.catch(() => { .catch(() => {
@@ -107,12 +111,13 @@ export function Dashboard() {
const openDevConsole = async () => { const openDevConsole = async () => {
try { try {
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { const result = await invokeIpc<{
success: boolean; success: boolean;
url?: string; url?: string;
error?: string; error?: string;
}; }>('gateway:getControlUiUrl');
if (result.success && result.url) { if (result.success && result.url) {
trackUiEvent('dashboard.quick_action', { action: 'dev_console' });
window.electron.openExternal(result.url); window.electron.openExternal(result.url);
} else { } else {
console.error('Failed to get Dev Console URL:', result.error); console.error('Failed to get Dev Console URL:', result.error);
@@ -196,27 +201,39 @@ export function Dashboard() {
<CardDescription>{t('quickActions.description')}</CardDescription> <CardDescription>{t('quickActions.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> <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" /> <Plus className="h-5 w-5" />
<span>{t('quickActions.addChannel')}</span> <span>{t('quickActions.addChannel')}</span>
</Link> </Link>
</Button> </Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild> <Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/skills"> <Link to="/cron" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'create_cron' })}>
<Puzzle className="h-5 w-5" /> <Clock className="h-5 w-5" />
<span>{t('quickActions.browseSkills')}</span> <span>{t('quickActions.createCron')}</span>
</Link> </Link>
</Button> </Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild> <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" /> <MessageSquare className="h-5 w-5" />
<span>{t('quickActions.openChat')}</span> <span>{t('quickActions.openChat')}</span>
</Link> </Link>
</Button> </Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild> <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" /> <Settings className="h-5 w-5" />
<span>{t('quickActions.settings')}</span> <span>{t('quickActions.settings')}</span>
</Link> </Link>
@@ -244,13 +261,15 @@ export function Dashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{channels.length === 0 ? ( {channels.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <FeedbackState
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" /> state="empty"
<p>{t('noChannels')}</p> title={t('noChannels')}
<Button variant="link" asChild className="mt-2"> action={(
<Link to="/channels">{t('addFirst')}</Link> <Button variant="link" asChild className="mt-2">
</Button> <Link to="/channels">{t('addFirst')}</Link>
</div> </Button>
)}
/>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{channels.slice(0, 5).map((channel) => ( {channels.slice(0, 5).map((channel) => (
@@ -286,13 +305,15 @@ export function Dashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{skills.filter((s) => s.enabled).length === 0 ? ( {skills.filter((s) => s.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <FeedbackState
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" /> state="empty"
<p>{t('noSkills')}</p> title={t('noSkills')}
<Button variant="link" asChild className="mt-2"> action={(
<Link to="/skills">{t('enableSome')}</Link> <Button variant="link" asChild className="mt-2">
</Button> <Link to="/skills">{t('enableSome')}</Link>
</div> </Button>
)}
/>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{skills {skills
@@ -322,17 +343,11 @@ export function Dashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{usageLoading ? ( {usageLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('recentTokenHistory.loading')}</div> <FeedbackState state="loading" title={t('recentTokenHistory.loading')} />
) : visibleUsageHistory.length === 0 ? ( ) : visibleUsageHistory.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <FeedbackState state="empty" title={t('recentTokenHistory.empty')} />
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('recentTokenHistory.empty')}</p>
</div>
) : filteredUsageHistory.length === 0 ? ( ) : filteredUsageHistory.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <FeedbackState state="empty" title={t('recentTokenHistory.emptyForWindow')} />
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('recentTokenHistory.emptyForWindow')}</p>
</div>
) : ( ) : (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">

View File

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

View File

@@ -31,6 +31,7 @@ import { useSettingsStore } from '@/stores/settings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from '@/i18n'; import { SUPPORTED_LANGUAGES } from '@/i18n';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { invokeIpc } from '@/lib/api-client';
interface SetupStep { interface SetupStep {
id: string; id: string;
title: string; title: string;
@@ -146,6 +147,18 @@ export function Setup() {
} }
}, [safeStepIndex, providerConfigured, runtimeChecksPassed]); }, [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 () => { const handleNext = async () => {
if (isLastStep) { if (isLastStep) {
// Complete setup // Complete setup
@@ -382,7 +395,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
// Check OpenClaw package status // Check OpenClaw package status
try { try {
const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as { const openclawStatus = await invokeIpc('openclaw:status') as {
packageExists: boolean; packageExists: boolean;
isBuilt: boolean; isBuilt: boolean;
dir: string; dir: string;
@@ -526,7 +539,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const handleShowLogs = async () => { const handleShowLogs = async () => {
try { try {
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string; const logs = await invokeIpc('log:readFile', 100) as string;
setLogContent(logs); setLogContent(logs);
setShowLogs(true); setShowLogs(true);
} catch { } catch {
@@ -537,9 +550,9 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const handleOpenLogDir = async () => { const handleOpenLogDir = async () => {
try { try {
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string; const logDir = await invokeIpc('log:getDir') as string;
if (logDir) { if (logDir) {
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir); await invokeIpc('shell:showItemInFolder', logDir);
} }
} catch { } catch {
// ignore // ignore
@@ -727,7 +740,7 @@ function ProviderContent({
if (selectedProvider) { if (selectedProvider) {
try { try {
await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider); await invokeIpc('provider:setDefault', selectedProvider);
} catch (error) { } catch (error) {
console.error('Failed to set default provider:', error); console.error('Failed to set default provider:', error);
} }
@@ -761,7 +774,7 @@ function ProviderContent({
if (!selectedProvider) return; if (!selectedProvider) return;
try { 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)); const existingTypes = new Set(list.map(l => l.type));
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
toast.error(t('settings:aiProviders.toast.minimaxConflict')); toast.error(t('settings:aiProviders.toast.minimaxConflict'));
@@ -780,7 +793,7 @@ function ProviderContent({
setOauthError(null); setOauthError(null);
try { try {
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider); await invokeIpc('provider:requestOAuth', selectedProvider);
} catch (e) { } catch (e) {
setOauthError(String(e)); setOauthError(String(e));
setOauthFlowing(false); setOauthFlowing(false);
@@ -791,7 +804,7 @@ function ProviderContent({
setOauthFlowing(false); setOauthFlowing(false);
setOauthData(null); setOauthData(null);
setOauthError(null); setOauthError(null);
await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); await invokeIpc('provider:cancelOAuth');
}; };
// On mount, try to restore previously configured provider // On mount, try to restore previously configured provider
@@ -799,8 +812,8 @@ function ProviderContent({
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; const defaultId = await invokeIpc('provider:getDefault') as string | null;
const setupProviderTypes = new Set<string>(providers.map((p) => p.id)); const setupProviderTypes = new Set<string>(providers.map((p) => p.id));
const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type)); const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type));
const preferred = const preferred =
@@ -813,7 +826,7 @@ function ProviderContent({
const typeInfo = providers.find((p) => p.id === preferred.type); const typeInfo = providers.find((p) => p.id === preferred.type);
const requiresKey = typeInfo?.requiresApiKey ?? false; const requiresKey = typeInfo?.requiresApiKey ?? false;
onConfiguredChange(!requiresKey || preferred.hasKey); 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) { if (storedKey) {
onApiKeyChange(storedKey); onApiKeyChange(storedKey);
} }
@@ -835,8 +848,8 @@ function ProviderContent({
(async () => { (async () => {
if (!selectedProvider) return; if (!selectedProvider) return;
try { try {
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; const defaultId = await invokeIpc('provider:getDefault') as string | null;
const sameType = list.filter((p) => p.type === selectedProvider); const sameType = list.filter((p) => p.type === selectedProvider);
const preferredInstance = const preferredInstance =
(defaultId && sameType.find((p) => p.id === defaultId)) (defaultId && sameType.find((p) => p.id === defaultId))
@@ -845,11 +858,11 @@ function ProviderContent({
const providerIdForLoad = preferredInstance?.id || selectedProvider; const providerIdForLoad = preferredInstance?.id || selectedProvider;
setSelectedProviderConfigId(providerIdForLoad); setSelectedProviderConfigId(providerIdForLoad);
const savedProvider = await window.electron.ipcRenderer.invoke( const savedProvider = await invokeIpc(
'provider:get', 'provider:get',
providerIdForLoad providerIdForLoad
) as { baseUrl?: string; model?: string } | null; ) 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 (!cancelled) {
if (storedKey) { if (storedKey) {
onApiKeyChange(storedKey); onApiKeyChange(storedKey);
@@ -906,7 +919,7 @@ function ProviderContent({
if (!selectedProvider) return; if (!selectedProvider) return;
try { 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)); const existingTypes = new Set(list.map(l => l.type));
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
toast.error(t('settings:aiProviders.toast.minimaxConflict')); 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 // Validate key if the provider requires one and a key was entered
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey'); const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
if (isApiKeyRequired && apiKey) { if (isApiKeyRequired && apiKey) {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'provider:validateKey', 'provider:validateKey',
selectedProviderConfigId || selectedProvider, selectedProviderConfigId || selectedProvider,
apiKey, apiKey,
@@ -961,7 +974,7 @@ function ProviderContent({
const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey); const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey);
// Save provider config + API key, then set as default // Save provider config + API key, then set as default
const saveResult = await window.electron.ipcRenderer.invoke( const saveResult = await invokeIpc(
'provider:save', 'provider:save',
{ {
id: providerIdForSave, id: providerIdForSave,
@@ -980,7 +993,7 @@ function ProviderContent({
throw new Error(saveResult.error || 'Failed to save provider config'); throw new Error(saveResult.error || 'Failed to save provider config');
} }
const defaultResult = await window.electron.ipcRenderer.invoke( const defaultResult = await invokeIpc(
'provider:setDefault', 'provider:setDefault',
providerIdForSave providerIdForSave
) as { success: boolean; error?: string }; ) as { success: boolean; error?: string };
@@ -1275,7 +1288,7 @@ function ProviderContent({
<Button <Button
variant="secondary" variant="secondary"
className="w-full" 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" /> <ExternalLink className="h-4 w-4 mr-2" />
Open Login Page Open Login Page
@@ -1363,7 +1376,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
setOverallProgress(10); setOverallProgress(10);
// Step 2: Call the backend to install uv and setup Python // 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; success: boolean;
error?: string error?: string
}; };

View File

@@ -38,6 +38,8 @@ import { useSkillsStore } from '@/stores/skills';
import { useGatewayStore } from '@/stores/gateway'; import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import { trackUiEvent } from '@/lib/telemetry';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Skill, MarketplaceSkill } from '@/types/skill'; import type { Skill, MarketplaceSkill } from '@/types/skill';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -84,14 +86,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
const handleOpenClawhub = async () => { const handleOpenClawhub = async () => {
if (skill.slug) { 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 () => { const handleOpenEditor = async () => {
if (skill.slug) { if (skill.slug) {
try { 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) { if (result.success) {
toast.success(t('toast.openedEditor')); toast.success(t('toast.openedEditor'));
} else { } else {
@@ -134,7 +136,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
}, {} as Record<string, string>); }, {} as Record<string, string>);
// Use direct file access instead of Gateway RPC for reliability // 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', 'skill:updateConfig',
{ {
skillKey: skill.id, skillKey: skill.id,
@@ -381,7 +383,7 @@ function MarketplaceSkillCard({
onUninstall onUninstall
}: MarketplaceSkillCardProps) { }: MarketplaceSkillCardProps) {
const handleCardClick = () => { 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 ( return (
@@ -600,6 +602,35 @@ export function Skills() {
marketplace: skills.filter(s => !s.isBundled).length, 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 // Handle toggle
const handleToggle = useCallback(async (skillId: string, enable: boolean) => { const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
try { try {
@@ -619,11 +650,11 @@ export function Skills() {
const handleOpenSkillsFolder = useCallback(async () => { const handleOpenSkillsFolder = useCallback(async () => {
try { try {
const skillsDir = await window.electron.ipcRenderer.invoke('openclaw:getSkillsDir') as string; const skillsDir = await invokeIpc<string>('openclaw:getSkillsDir');
if (!skillsDir) { if (!skillsDir) {
throw new Error('Skills directory not available'); 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) { if (result) {
// shell.openPath returns an error string if the path doesn't exist // 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')) { 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'); const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills');
useEffect(() => { useEffect(() => {
window.electron.ipcRenderer.invoke('openclaw:getSkillsDir') invokeIpc<string>('openclaw:getSkillsDir')
.then((dir) => setSkillsDirPath(dir as string)) .then((dir) => setSkillsDirPath(dir as string))
.catch(console.error); .catch(console.error);
}, []); }, []);
@@ -804,6 +835,20 @@ export function Skills() {
<Globe className="h-3 w-3" /> <Globe className="h-3 w-3" />
{t('filter.marketplace', { count: sourceStats.marketplace })} {t('filter.marketplace', { count: sourceStats.marketplace })}
</Button> </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>
</div> </div>

View File

@@ -4,6 +4,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { Channel, ChannelType } from '../types/channel'; import type { Channel, ChannelType } from '../types/channel';
import { invokeIpc } from '@/lib/api-client';
interface AddChannelParams { interface AddChannelParams {
type: ChannelType; type: ChannelType;
@@ -17,7 +18,7 @@ interface ChannelsState {
error: string | null; error: string | null;
// Actions // Actions
fetchChannels: () => Promise<void>; fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise<void>;
addChannel: (params: AddChannelParams) => Promise<Channel>; addChannel: (params: AddChannelParams) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>; deleteChannel: (channelId: string) => Promise<void>;
connectChannel: (channelId: string) => Promise<void>; connectChannel: (channelId: string) => Promise<void>;
@@ -33,13 +34,17 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
loading: false, loading: false,
error: null, error: null,
fetchChannels: async () => { fetchChannels: async (options) => {
set({ loading: true, error: null }); const probe = options?.probe ?? false;
const silent = options?.silent ?? false;
if (!silent) {
set({ loading: true, error: null });
}
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'channels.status', 'channels.status',
{ probe: true } { probe }
) as { ) as {
success: boolean; success: boolean;
result?: { 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 { } else {
// Gateway not available - try to show channels from local config // Gateway not available - try to show channels from local config
set({ channels: [], loading: false }); set((state) => ({ channels: [], loading: silent ? state.loading : false }));
} }
} catch { } catch {
// Gateway not connected, show empty // Gateway not connected, show empty
set({ channels: [], loading: false }); set((state) => ({ channels: [], loading: silent ? state.loading : false }));
} }
}, },
addChannel: async (params) => { addChannel: async (params) => {
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'channels.add', 'channels.add',
params params
@@ -184,13 +189,13 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
try { try {
// Delete the channel configuration from openclaw.json // Delete the channel configuration from openclaw.json
await window.electron.ipcRenderer.invoke('channel:deleteConfig', channelType); await invokeIpc('channel:deleteConfig', channelType);
} catch (error) { } catch (error) {
console.error('Failed to delete channel config:', error); console.error('Failed to delete channel config:', error);
} }
try { try {
await window.electron.ipcRenderer.invoke( await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'channels.delete', 'channels.delete',
{ channelId: channelType } { channelId: channelType }
@@ -211,7 +216,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
updateChannel(channelId, { status: 'connecting', error: undefined }); updateChannel(channelId, { status: 'connecting', error: undefined });
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'channels.connect', 'channels.connect',
{ channelId } { channelId }
@@ -231,7 +236,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
const { updateChannel } = get(); const { updateChannel } = get();
try { try {
await window.electron.ipcRenderer.invoke( await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'channels.disconnect', 'channels.disconnect',
{ channelId } { channelId }
@@ -244,7 +249,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
}, },
requestQrCode: async (channelType) => { requestQrCode: async (channelType) => {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'channels.requestQr', 'channels.requestQr',
{ type: channelType } { type: channelType }

View File

@@ -4,6 +4,7 @@
* Communicates with OpenClaw Gateway via gateway:rpc IPC. * Communicates with OpenClaw Gateway via gateway:rpc IPC.
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import { invokeIpc } from '@/lib/api-client';
// ── Types ──────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────
@@ -596,7 +597,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
if (needPreview.length === 0) return false; if (needPreview.length === 0) return false;
try { try {
const thumbnails = await window.electron.ipcRenderer.invoke( const thumbnails = await invokeIpc(
'media:getThumbnails', 'media:getThumbnails',
needPreview, needPreview,
) as Record<string, { preview: string | null; fileSize: number }>; ) as Record<string, { preview: string | null; fileSize: number }>;
@@ -928,7 +929,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
loadSessions: async () => { loadSessions: async () => {
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'sessions.list', 'sessions.list',
{} {}
@@ -1001,7 +1002,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
void Promise.all( void Promise.all(
sessionsToLabel.map(async (session) => { sessionsToLabel.map(async (session) => {
try { try {
const r = await window.electron.ipcRenderer.invoke( const r = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'chat.history', 'chat.history',
{ sessionKey: session.key, limit: 1000 }, { 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 // The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
// sessions.list and token-usage queries both skip it automatically. // sessions.list and token-usage queries both skip it automatically.
try { try {
const result = await window.electron.ipcRenderer.invoke('session:delete', key) as { const result = await invokeIpc('session:delete', key) as {
success: boolean; success: boolean;
error?: string; error?: string;
}; };
@@ -1185,7 +1186,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!quiet) set({ loading: true, error: null }); if (!quiet) set({ loading: true, error: null });
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'chat.history', 'chat.history',
{ sessionKey: currentSessionKey, limit: 200 } { sessionKey: currentSessionKey, limit: 200 }
@@ -1425,7 +1426,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const CHAT_SEND_TIMEOUT_MS = 120_000; const CHAT_SEND_TIMEOUT_MS = 120_000;
if (hasMedia) { if (hasMedia) {
result = await window.electron.ipcRenderer.invoke( result = await invokeIpc(
'chat:sendWithMedia', 'chat:sendWithMedia',
{ {
sessionKey: currentSessionKey, sessionKey: currentSessionKey,
@@ -1440,7 +1441,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}, },
) as { success: boolean; result?: { runId?: string }; error?: string }; ) as { success: boolean; result?: { runId?: string }; error?: string };
} else { } else {
result = await window.electron.ipcRenderer.invoke( result = await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'chat.send', 'chat.send',
{ {
@@ -1477,7 +1478,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ streamingTools: [] }); set({ streamingTools: [] });
try { try {
await window.electron.ipcRenderer.invoke( await invokeIpc(
'gateway:rpc', 'gateway:rpc',
'chat.abort', 'chat.abort',
{ sessionKey: currentSessionKey }, { sessionKey: currentSessionKey },

View File

@@ -4,6 +4,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
import { invokeIpc } from '@/lib/api-client';
interface CronState { interface CronState {
jobs: CronJob[]; jobs: CronJob[];
@@ -29,7 +30,7 @@ export const useCronStore = create<CronState>((set) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[]; const result = await invokeIpc<CronJob[]>('cron:list');
set({ jobs: result, loading: false }); set({ jobs: result, loading: false });
} catch (error) { } catch (error) {
set({ error: String(error), loading: false }); set({ error: String(error), loading: false });
@@ -38,7 +39,7 @@ export const useCronStore = create<CronState>((set) => ({
createJob: async (input) => { createJob: async (input) => {
try { 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] })); set((state) => ({ jobs: [...state.jobs, job] }));
return job; return job;
} catch (error) { } catch (error) {
@@ -49,7 +50,7 @@ export const useCronStore = create<CronState>((set) => ({
updateJob: async (id, input) => { updateJob: async (id, input) => {
try { try {
await window.electron.ipcRenderer.invoke('cron:update', id, input); await invokeIpc('cron:update', id, input);
set((state) => ({ set((state) => ({
jobs: state.jobs.map((job) => jobs: state.jobs.map((job) =>
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
@@ -63,7 +64,7 @@ export const useCronStore = create<CronState>((set) => ({
deleteJob: async (id) => { deleteJob: async (id) => {
try { try {
await window.electron.ipcRenderer.invoke('cron:delete', id); await invokeIpc('cron:delete', id);
set((state) => ({ set((state) => ({
jobs: state.jobs.filter((job) => job.id !== id), jobs: state.jobs.filter((job) => job.id !== id),
})); }));
@@ -75,7 +76,7 @@ export const useCronStore = create<CronState>((set) => ({
toggleJob: async (id, enabled) => { toggleJob: async (id, enabled) => {
try { try {
await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled); await invokeIpc('cron:toggle', id, enabled);
set((state) => ({ set((state) => ({
jobs: state.jobs.map((job) => jobs: state.jobs.map((job) =>
job.id === id ? { ...job, enabled } : job job.id === id ? { ...job, enabled } : job
@@ -89,11 +90,11 @@ export const useCronStore = create<CronState>((set) => ({
triggerJob: async (id) => { triggerJob: async (id) => {
try { 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); console.log('Cron trigger result:', result);
// Refresh jobs after trigger to update lastRun/nextRun state // Refresh jobs after trigger to update lastRun/nextRun state
try { try {
const jobs = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[]; const jobs = await invokeIpc<CronJob[]>('cron:list');
set({ jobs }); set({ jobs });
} catch { } catch {
// Ignore refresh error // Ignore refresh error

View File

@@ -4,6 +4,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { GatewayStatus } from '../types/gateway'; import type { GatewayStatus } from '../types/gateway';
import { invokeIpc } from '@/lib/api-client';
let gatewayInitPromise: Promise<void> | null = null; let gatewayInitPromise: Promise<void> | null = null;
@@ -49,7 +50,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
gatewayInitPromise = (async () => { gatewayInitPromise = (async () => {
try { try {
// Get initial status first // 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 }); set({ status, isInitialized: true });
// Listen for status changes // Listen for status changes
@@ -197,7 +198,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
start: async () => { start: async () => {
try { try {
set({ status: { ...get().status, state: 'starting' }, lastError: null }); 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) { if (!result.success) {
set({ set({
@@ -215,7 +216,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
stop: async () => { stop: async () => {
try { try {
await window.electron.ipcRenderer.invoke('gateway:stop'); await invokeIpc('gateway:stop');
set({ status: { ...get().status, state: 'stopped' }, lastError: null }); set({ status: { ...get().status, state: 'stopped' }, lastError: null });
} catch (error) { } catch (error) {
console.error('Failed to stop Gateway:', error); console.error('Failed to stop Gateway:', error);
@@ -226,7 +227,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
restart: async () => { restart: async () => {
try { try {
set({ status: { ...get().status, state: 'starting' }, lastError: null }); 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) { if (!result.success) {
set({ set({
@@ -244,7 +245,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
checkHealth: async () => { checkHealth: async () => {
try { try {
const result = await window.electron.ipcRenderer.invoke('gateway:health') as { const result = await invokeIpc('gateway:health') as {
success: boolean; success: boolean;
ok: boolean; ok: boolean;
error?: string; error?: string;
@@ -267,7 +268,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
}, },
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => { 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; success: boolean;
result?: T; result?: T;
error?: string; error?: string;

View File

@@ -4,6 +4,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
import { invokeIpc } from '@/lib/api-client';
// Re-export types for consumers that imported from here // Re-export types for consumers that imported from here
export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
@@ -45,8 +46,8 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const providers = await window.electron.ipcRenderer.invoke('provider:list') as ProviderWithKeyInfo[]; const providers = await invokeIpc<ProviderWithKeyInfo[]>('provider:list');
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; const defaultId = await invokeIpc<string | null>('provider:getDefault');
set({ set({
providers, providers,
@@ -66,7 +67,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
updatedAt: new Date().toISOString(), 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) { if (!result.success) {
throw new Error(result.error || 'Failed to save provider'); throw new Error(result.error || 'Failed to save provider');
@@ -95,7 +96,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
updatedAt: new Date().toISOString(), 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) { if (!result.success) {
throw new Error(result.error || 'Failed to update provider'); throw new Error(result.error || 'Failed to update provider');
@@ -111,7 +112,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
deleteProvider: async (providerId) => { deleteProvider: async (providerId) => {
try { 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) { if (!result.success) {
throw new Error(result.error || 'Failed to delete provider'); throw new Error(result.error || 'Failed to delete provider');
@@ -127,7 +128,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
setApiKey: async (providerId, apiKey) => { setApiKey: async (providerId, apiKey) => {
try { 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) { if (!result.success) {
throw new Error(result.error || 'Failed to set API key'); 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) => { updateProviderWithKey: async (providerId, updates, apiKey) => {
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc<{ success: boolean; error?: string }>(
'provider:updateWithKey', 'provider:updateWithKey',
providerId, providerId,
updates, updates,
apiKey apiKey
) as { success: boolean; error?: string }; );
if (!result.success) { if (!result.success) {
throw new Error(result.error || 'Failed to update provider'); throw new Error(result.error || 'Failed to update provider');
@@ -163,7 +164,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
deleteApiKey: async (providerId) => { deleteApiKey: async (providerId) => {
try { 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) { if (!result.success) {
throw new Error(result.error || 'Failed to delete API key'); throw new Error(result.error || 'Failed to delete API key');
@@ -179,7 +180,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
setDefaultProvider: async (providerId) => { setDefaultProvider: async (providerId) => {
try { 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) { if (!result.success) {
throw new Error(result.error || 'Failed to set default provider'); 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) => { validateApiKey: async (providerId, apiKey, options) => {
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc<{ valid: boolean; error?: string }>(
'provider:validateKey', 'provider:validateKey',
providerId, providerId,
apiKey, apiKey,
options options
) as { valid: boolean; error?: string }; );
return result; return result;
} catch (error) { } catch (error) {
return { valid: false, error: String(error) }; return { valid: false, error: String(error) };
@@ -208,7 +209,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
getApiKey: async (providerId) => { getApiKey: async (providerId) => {
try { try {
return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null; return await invokeIpc<string | null>('provider:getApiKey', providerId);
} catch { } catch {
return null; return null;
} }

View File

@@ -5,9 +5,11 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import i18n from '@/i18n'; import i18n from '@/i18n';
import { invokeIpc } from '@/lib/api-client';
type Theme = 'light' | 'dark' | 'system'; type Theme = 'light' | 'dark' | 'system';
type UpdateChannel = 'stable' | 'beta' | 'dev'; type UpdateChannel = 'stable' | 'beta' | 'dev';
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
interface SettingsState { interface SettingsState {
// General // General
@@ -25,6 +27,7 @@ interface SettingsState {
proxyHttpsServer: string; proxyHttpsServer: string;
proxyAllServer: string; proxyAllServer: string;
proxyBypassRules: string; proxyBypassRules: string;
gatewayTransportPreference: GatewayTransportPreference;
// Update // Update
updateChannel: UpdateChannel; updateChannel: UpdateChannel;
@@ -52,6 +55,7 @@ interface SettingsState {
setProxyHttpsServer: (value: string) => void; setProxyHttpsServer: (value: string) => void;
setProxyAllServer: (value: string) => void; setProxyAllServer: (value: string) => void;
setProxyBypassRules: (value: string) => void; setProxyBypassRules: (value: string) => void;
setGatewayTransportPreference: (value: GatewayTransportPreference) => void;
setUpdateChannel: (channel: UpdateChannel) => void; setUpdateChannel: (channel: UpdateChannel) => void;
setAutoCheckUpdate: (value: boolean) => void; setAutoCheckUpdate: (value: boolean) => void;
setAutoDownloadUpdate: (value: boolean) => void; setAutoDownloadUpdate: (value: boolean) => void;
@@ -79,6 +83,7 @@ const defaultSettings = {
proxyHttpsServer: '', proxyHttpsServer: '',
proxyAllServer: '', proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1', proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
gatewayTransportPreference: 'ws-first' as GatewayTransportPreference,
updateChannel: 'stable' as UpdateChannel, updateChannel: 'stable' as UpdateChannel,
autoCheckUpdate: true, autoCheckUpdate: true,
autoDownloadUpdate: false, autoDownloadUpdate: false,
@@ -94,7 +99,7 @@ export const useSettingsStore = create<SettingsState>()(
init: async () => { init: async () => {
try { 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 })); set((state) => ({ ...state, ...settings }));
if (settings.language) { if (settings.language) {
i18n.changeLanguage(settings.language); i18n.changeLanguage(settings.language);
@@ -106,17 +111,21 @@ export const useSettingsStore = create<SettingsState>()(
}, },
setTheme: (theme) => set({ theme }), 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 }), setStartMinimized: (startMinimized) => set({ startMinimized }),
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }), setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void invokeIpc('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); },
setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); }, setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void invokeIpc('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); },
setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }), setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }),
setProxyServer: (proxyServer) => set({ proxyServer }), setProxyServer: (proxyServer) => set({ proxyServer }),
setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }), setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }),
setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }), setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }),
setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }), setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }),
setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }), setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }),
setGatewayTransportPreference: (gatewayTransportPreference) => {
set({ gatewayTransportPreference });
void invokeIpc('settings:set', 'gatewayTransportPreference', gatewayTransportPreference).catch(() => {});
},
setUpdateChannel: (updateChannel) => set({ updateChannel }), setUpdateChannel: (updateChannel) => set({ updateChannel }),
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }), setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }), setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),

View File

@@ -4,6 +4,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { Skill, MarketplaceSkill } from '../types/skill'; import type { Skill, MarketplaceSkill } from '../types/skill';
import { invokeIpc } from '@/lib/api-client';
type GatewaySkillStatus = { type GatewaySkillStatus = {
skillKey: string; skillKey: string;
@@ -70,20 +71,20 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
} }
try { try {
// 1. Fetch from Gateway (running skills) // 1. Fetch from Gateway (running skills)
const gatewayResult = await window.electron.ipcRenderer.invoke( const gatewayResult = await invokeIpc<GatewayRpcResponse<GatewaySkillsStatusResult>>(
'gateway:rpc', 'gateway:rpc',
'skills.status' 'skills.status'
) as GatewayRpcResponse<GatewaySkillsStatusResult>; );
// 2. Fetch from ClawHub (installed on disk) // 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' 'clawhub:list'
) as { success: boolean; results?: ClawHubListResult[]; error?: string }; );
// 3. Fetch configurations directly from Electron (since Gateway doesn't return them) // 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' 'skill:getAllConfigs'
) as Record<string, { apiKey?: string; env?: Record<string, string> }>; );
let combinedSkills: Skill[] = []; let combinedSkills: Skill[] = [];
const currentSkills = get().skills; const currentSkills = get().skills;
@@ -155,7 +156,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
searchSkills: async (query: string) => { searchSkills: async (query: string) => {
set({ searching: true, searchError: null }); set({ searching: true, searchError: null });
try { 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) { if (result.success) {
set({ searchResults: result.results || [] }); set({ searchResults: result.results || [] });
} else { } else {
@@ -177,7 +178,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
installSkill: async (slug: string, version?: string) => { installSkill: async (slug: string, version?: string) => {
set((state) => ({ installing: { ...state.installing, [slug]: true } })); set((state) => ({ installing: { ...state.installing, [slug]: true } }));
try { 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.success) {
if (result.error?.includes('Timeout')) { if (result.error?.includes('Timeout')) {
throw new Error('installTimeoutError'); throw new Error('installTimeoutError');
@@ -204,7 +205,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
uninstallSkill: async (slug: string) => { uninstallSkill: async (slug: string) => {
set((state) => ({ installing: { ...state.installing, [slug]: true } })); set((state) => ({ installing: { ...state.installing, [slug]: true } }));
try { 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) { if (!result.success) {
throw new Error(result.error || 'Uninstall failed'); throw new Error(result.error || 'Uninstall failed');
} }
@@ -226,11 +227,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
const { updateSkill } = get(); const { updateSkill } = get();
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc<GatewayRpcResponse<unknown>>(
'gateway:rpc', 'gateway:rpc',
'skills.update', 'skills.update',
{ skillKey: skillId, enabled: true } { skillKey: skillId, enabled: true }
) as GatewayRpcResponse<unknown>; );
if (result.success) { if (result.success) {
updateSkill(skillId, { enabled: true }); updateSkill(skillId, { enabled: true });
@@ -252,11 +253,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
} }
try { try {
const result = await window.electron.ipcRenderer.invoke( const result = await invokeIpc<GatewayRpcResponse<unknown>>(
'gateway:rpc', 'gateway:rpc',
'skills.update', 'skills.update',
{ skillKey: skillId, enabled: false } { skillKey: skillId, enabled: false }
) as GatewayRpcResponse<unknown>; );
if (result.success) { if (result.success) {
updateSkill(skillId, { enabled: false }); updateSkill(skillId, { enabled: false });

View File

@@ -4,6 +4,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import { useSettingsStore } from './settings'; import { useSettingsStore } from './settings';
import { invokeIpc } from '@/lib/api-client';
export interface UpdateInfo { export interface UpdateInfo {
version: string; version: string;
@@ -63,7 +64,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
// Get current version // Get current version
try { try {
const version = await window.electron.ipcRenderer.invoke('update:version'); const version = await invokeIpc<string>('update:version');
set({ currentVersion: version as string }); set({ currentVersion: version as string });
} catch (error) { } catch (error) {
console.error('Failed to get version:', error); console.error('Failed to get version:', error);
@@ -71,12 +72,12 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
// Get current status // Get current status
try { try {
const status = await window.electron.ipcRenderer.invoke('update:status') as { const status = await invokeIpc<{
status: UpdateStatus; status: UpdateStatus;
info?: UpdateInfo; info?: UpdateInfo;
progress?: ProgressInfo; progress?: ProgressInfo;
error?: string; error?: string;
}; }>('update:status');
set({ set({
status: status.status, status: status.status,
updateInfo: status.info || null, updateInfo: status.info || null,
@@ -117,7 +118,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
// Sync auto-download preference to the main process // Sync auto-download preference to the main process
if (autoDownloadUpdate) { if (autoDownloadUpdate) {
window.electron.ipcRenderer.invoke('update:setAutoDownload', true).catch(() => {}); invokeIpc('update:setAutoDownload', true).catch(() => {});
} }
// Auto-check for updates on startup (respects user toggle) // Auto-check for updates on startup (respects user toggle)
@@ -133,7 +134,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
try { try {
const result = await Promise.race([ 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)) new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000))
]) as { ]) as {
success: boolean; success: boolean;
@@ -172,10 +173,10 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
set({ status: 'downloading', error: null }); set({ status: 'downloading', error: null });
try { try {
const result = await window.electron.ipcRenderer.invoke('update:download') as { const result = await invokeIpc<{
success: boolean; success: boolean;
error?: string; error?: string;
}; }>('update:download');
if (!result.success) { if (!result.success) {
set({ status: 'error', error: result.error || 'Failed to download update' }); set({ status: 'error', error: result.error || 'Failed to download update' });
@@ -186,12 +187,12 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
}, },
installUpdate: () => { installUpdate: () => {
window.electron.ipcRenderer.invoke('update:install'); void invokeIpc('update:install');
}, },
cancelAutoInstall: async () => { cancelAutoInstall: async () => {
try { try {
await window.electron.ipcRenderer.invoke('update:cancelAutoInstall'); await invokeIpc('update:cancelAutoInstall');
} catch (error) { } catch (error) {
console.error('Failed to cancel auto-install:', error); console.error('Failed to cancel auto-install:', error);
} }
@@ -199,7 +200,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
setChannel: async (channel) => { setChannel: async (channel) => {
try { try {
await window.electron.ipcRenderer.invoke('update:setChannel', channel); await invokeIpc('update:setChannel', channel);
} catch (error) { } catch (error) {
console.error('Failed to set update channel:', error); console.error('Failed to set update channel:', error);
} }
@@ -207,7 +208,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
setAutoDownload: async (enable) => { setAutoDownload: async (enable) => {
try { try {
await window.electron.ipcRenderer.invoke('update:setAutoDownload', enable); await invokeIpc('update:setAutoDownload', enable);
} catch (error) { } catch (error) {
console.error('Failed to set auto-download:', 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();
});
});