refactor IPC (#341)
This commit is contained in:
committed by
GitHub
Unverified
parent
c03d92e9a2
commit
3d804a9f5e
@@ -228,9 +228,11 @@ export class GatewayManager extends EventEmitter {
|
||||
}> = new Map();
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private lifecycleEpoch = 0;
|
||||
private deferredRestartPending = false;
|
||||
private restartInFlight: Promise<void> | null = null;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
|
||||
constructor(config?: Partial<ReconnectConfig>) {
|
||||
super();
|
||||
@@ -259,6 +261,11 @@ export class GatewayManager extends EventEmitter {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private isUnsupportedShutdownError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /unknown method:\s*shutdown/i.test(message);
|
||||
}
|
||||
|
||||
private formatExit(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
if (code !== null) return `code=${code}`;
|
||||
if (signal) return `signal=${signal}`;
|
||||
@@ -272,6 +279,14 @@ export class GatewayManager extends EventEmitter {
|
||||
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg };
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg };
|
||||
// During renderer refresh / transport switching, loopback websocket probes can time out
|
||||
// while the gateway is reloading. This is expected and not actionable.
|
||||
if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
@@ -536,11 +551,17 @@ export class GatewayManager extends EventEmitter {
|
||||
|
||||
// If this manager is attached to an external gateway process, ask it to shut down
|
||||
// over protocol before closing the socket.
|
||||
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) {
|
||||
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN && this.externalShutdownSupported !== false) {
|
||||
try {
|
||||
await this.rpc('shutdown', undefined, 5000);
|
||||
this.externalShutdownSupported = true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
|
||||
if (this.isUnsupportedShutdownError(error)) {
|
||||
this.externalShutdownSupported = false;
|
||||
logger.info('External Gateway does not support "shutdown"; skipping shutdown RPC for future stops');
|
||||
} else {
|
||||
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +669,71 @@ export class GatewayManager extends EventEmitter {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Gateway process to reload config in-place when possible.
|
||||
* Falls back to restart on unsupported platforms or signaling failures.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
if (this.isRestartDeferred()) {
|
||||
this.markDeferredRestart('reload');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.process?.pid || this.status.state !== 'running') {
|
||||
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
logger.debug('Windows detected, falling back to Gateway restart for reload');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedForMs = this.status.connectedAt
|
||||
? Date.now() - this.status.connectedAt
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
// Avoid signaling a process that just came up; it will already read latest config.
|
||||
if (connectedForMs < 8000) {
|
||||
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGUSR1');
|
||||
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
|
||||
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
|
||||
// If process state doesn't recover quickly, fall back to restart.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
if (this.status.state !== 'running' || !this.process?.pid) {
|
||||
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
|
||||
await this.restart();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||
* in-process reload when possible.
|
||||
*/
|
||||
debouncedReload(delayMs = 1200): void {
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`);
|
||||
this.reloadDebounceTimer = setTimeout(() => {
|
||||
this.reloadDebounceTimer = null;
|
||||
void this.reload().catch((err) => {
|
||||
logger.warn('Debounced Gateway reload failed:', err);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active timers
|
||||
*/
|
||||
@@ -668,6 +754,10 @@ export class GatewayManager extends EventEmitter {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
this.restartDebounceTimer = null;
|
||||
}
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,25 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
import { appUpdater } from './updater';
|
||||
|
||||
type AppRequest = {
|
||||
id?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type AppResponse = {
|
||||
id?: string;
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
error?: {
|
||||
code: 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
import {
|
||||
getOpenClawProviderKeyForType,
|
||||
getOAuthApiKeyEnv,
|
||||
@@ -136,6 +155,9 @@ export function registerIpcHandlers(
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
// Unified request protocol (non-breaking: legacy channels remain available)
|
||||
registerUnifiedRequestHandlers(gatewayManager);
|
||||
|
||||
// Gateway handlers
|
||||
registerGatewayHandlers(gatewayManager, mainWindow);
|
||||
|
||||
@@ -143,7 +165,7 @@ export function registerIpcHandlers(
|
||||
registerClawHubHandlers(clawHubService);
|
||||
|
||||
// OpenClaw handlers
|
||||
registerOpenClawHandlers();
|
||||
registerOpenClawHandlers(gatewayManager);
|
||||
|
||||
// Provider handlers
|
||||
registerProviderHandlers(gatewayManager);
|
||||
@@ -191,6 +213,701 @@ export function registerIpcHandlers(
|
||||
registerFileHandlers();
|
||||
}
|
||||
|
||||
function mapAppErrorCode(error: unknown): AppResponse['error']['code'] {
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
if (msg.includes('timeout')) return 'TIMEOUT';
|
||||
if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION';
|
||||
if (msg.includes('gateway')) return 'GATEWAY';
|
||||
if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION';
|
||||
return 'INTERNAL';
|
||||
}
|
||||
|
||||
function isProxyKey(key: keyof AppSettings): boolean {
|
||||
return (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
);
|
||||
}
|
||||
|
||||
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
const handleProxySettingsChange = async () => {
|
||||
const settings = await getAllSettings();
|
||||
await applyProxySettings(settings);
|
||||
if (gatewayManager.getStatus().state === 'running') {
|
||||
await gatewayManager.restart();
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle('app:request', async (_, request: AppRequest): Promise<AppResponse> => {
|
||||
if (!request || typeof request.module !== 'string' || typeof request.action !== 'string') {
|
||||
return {
|
||||
id: request?.id,
|
||||
ok: false,
|
||||
error: { code: 'VALIDATION', message: 'Invalid app request format' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let data: unknown;
|
||||
switch (request.module) {
|
||||
case 'app': {
|
||||
if (request.action === 'version') data = app.getVersion();
|
||||
else if (request.action === 'name') data = app.getName();
|
||||
else if (request.action === 'platform') data = process.platform;
|
||||
else {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'provider': {
|
||||
if (request.action === 'list') {
|
||||
data = await getAllProvidersWithKeyInfo();
|
||||
break;
|
||||
}
|
||||
if (request.action === 'get') {
|
||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||
if (!providerId) throw new Error('Invalid provider.get payload');
|
||||
data = await getProvider(providerId);
|
||||
break;
|
||||
}
|
||||
if (request.action === 'getDefault') {
|
||||
data = await getDefaultProvider();
|
||||
break;
|
||||
}
|
||||
if (request.action === 'hasApiKey') {
|
||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||
if (!providerId) throw new Error('Invalid provider.hasApiKey payload');
|
||||
data = await hasApiKey(providerId);
|
||||
break;
|
||||
}
|
||||
if (request.action === 'getApiKey') {
|
||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||
if (!providerId) throw new Error('Invalid provider.getApiKey payload');
|
||||
data = await getApiKey(providerId);
|
||||
break;
|
||||
}
|
||||
if (request.action === 'validateKey') {
|
||||
const payload = request.payload as
|
||||
| { providerId?: string; apiKey?: string; options?: { baseUrl?: string } }
|
||||
| [string, string, { baseUrl?: string }?]
|
||||
| undefined;
|
||||
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
|
||||
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
|
||||
const options = Array.isArray(payload) ? payload[2] : payload?.options;
|
||||
if (!providerId || typeof apiKey !== 'string') {
|
||||
throw new Error('Invalid provider.validateKey payload');
|
||||
}
|
||||
|
||||
const provider = await getProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||
data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
|
||||
break;
|
||||
}
|
||||
if (request.action === 'save') {
|
||||
const payload = request.payload as
|
||||
| { config?: ProviderConfig; apiKey?: string }
|
||||
| [ProviderConfig, string?]
|
||||
| undefined;
|
||||
const config = Array.isArray(payload) ? payload[0] : payload?.config;
|
||||
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
|
||||
if (!config) throw new Error('Invalid provider.save payload');
|
||||
|
||||
try {
|
||||
await saveProvider(config);
|
||||
const ock = getOpenClawProviderKey(config.type, config.id);
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(config.id, trimmedKey);
|
||||
try {
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
|
||||
if (api) {
|
||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
const resolvedKey = apiKey !== undefined
|
||||
? (apiKey.trim() || null)
|
||||
: await getApiKey(config.id);
|
||||
if (resolvedKey && config.baseUrl) {
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: config.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`);
|
||||
gatewayManager.debouncedRestart();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw provider config:', err);
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'delete') {
|
||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||
if (!providerId) throw new Error('Invalid provider.delete payload');
|
||||
|
||||
try {
|
||||
const existing = await getProvider(providerId);
|
||||
await deleteProvider(providerId);
|
||||
if (existing?.type) {
|
||||
try {
|
||||
const ock = getOpenClawProviderKey(existing.type, providerId);
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`);
|
||||
gatewayManager.debouncedRestart();
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'setApiKey') {
|
||||
const payload = request.payload as
|
||||
| { providerId?: string; apiKey?: string }
|
||||
| [string, string]
|
||||
| undefined;
|
||||
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
|
||||
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
|
||||
if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload');
|
||||
|
||||
try {
|
||||
await storeApiKey(providerId, apiKey);
|
||||
const provider = await getProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
try {
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'updateWithKey') {
|
||||
const payload = request.payload as
|
||||
| { providerId?: string; updates?: Partial<ProviderConfig>; apiKey?: string }
|
||||
| [string, Partial<ProviderConfig>, string?]
|
||||
| undefined;
|
||||
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
|
||||
const updates = Array.isArray(payload) ? payload[1] : payload?.updates;
|
||||
const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey;
|
||||
if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload');
|
||||
|
||||
const existing = await getProvider(providerId);
|
||||
if (!existing) {
|
||||
data = { success: false, error: 'Provider not found' };
|
||||
break;
|
||||
}
|
||||
|
||||
const previousKey = await getApiKey(providerId);
|
||||
const previousOck = getOpenClawProviderKey(existing.type, providerId);
|
||||
|
||||
try {
|
||||
const nextConfig: ProviderConfig = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
|
||||
await saveProvider(nextConfig);
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(providerId, trimmedKey);
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
} else {
|
||||
await deleteApiKey(providerId);
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fallbackModels = await getProviderFallbackModelRefs(nextConfig);
|
||||
const meta = getProviderConfig(nextConfig.type);
|
||||
const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
|
||||
if (api) {
|
||||
await syncProviderConfigToOpenClaw(ock, nextConfig.model, {
|
||||
baseUrl: nextConfig.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
|
||||
if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') {
|
||||
const resolvedKey = apiKey !== undefined
|
||||
? (apiKey.trim() || null)
|
||||
: await getApiKey(providerId);
|
||||
if (resolvedKey && nextConfig.baseUrl) {
|
||||
const modelId = nextConfig.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: nextConfig.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId === providerId) {
|
||||
const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined;
|
||||
if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: nextConfig.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`);
|
||||
gatewayManager.debouncedRestart();
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
try {
|
||||
await saveProvider(existing);
|
||||
if (previousKey) {
|
||||
await storeApiKey(providerId, previousKey);
|
||||
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
||||
} else {
|
||||
await deleteApiKey(providerId);
|
||||
await removeProviderFromOpenClaw(previousOck);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
||||
}
|
||||
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'deleteApiKey') {
|
||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||
if (!providerId) throw new Error('Invalid provider.deleteApiKey payload');
|
||||
try {
|
||||
await deleteApiKey(providerId);
|
||||
const provider = await getProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
try {
|
||||
if (ock) {
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'setDefault') {
|
||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||
if (!providerId) throw new Error('Invalid provider.setDefault payload');
|
||||
|
||||
try {
|
||||
await setDefaultProvider(providerId);
|
||||
const provider = await getProvider(providerId);
|
||||
if (provider) {
|
||||
try {
|
||||
const ock = getOpenClawProviderKey(provider.type, providerId);
|
||||
const providerKey = await getApiKey(providerId);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||
const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey;
|
||||
|
||||
if (!isOAuthProvider) {
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
|
||||
: undefined;
|
||||
if (provider.type === 'custom' || provider.type === 'ollama') {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
}
|
||||
if (providerKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||
}
|
||||
} else {
|
||||
const defaultBaseUrl = provider.type === 'minimax-portal'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
|
||||
const api: 'anthropic-messages' | 'openai-completions' =
|
||||
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'anthropic-messages'
|
||||
: 'openai-completions';
|
||||
|
||||
let baseUrl = provider.baseUrl || defaultBaseUrl;
|
||||
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
|
||||
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'minimax-portal'
|
||||
: provider.type;
|
||||
|
||||
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
}, fallbackModels);
|
||||
|
||||
try {
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(provider.type === 'custom' || provider.type === 'ollama') &&
|
||||
providerKey &&
|
||||
provider.baseUrl
|
||||
) {
|
||||
const modelId = provider.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: providerKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`);
|
||||
gatewayManager.debouncedRestart();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to set OpenClaw default model:', err);
|
||||
}
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'update': {
|
||||
if (request.action === 'status') {
|
||||
data = appUpdater.getStatus();
|
||||
break;
|
||||
}
|
||||
if (request.action === 'version') {
|
||||
data = appUpdater.getCurrentVersion();
|
||||
break;
|
||||
}
|
||||
if (request.action === 'check') {
|
||||
try {
|
||||
await appUpdater.checkForUpdates();
|
||||
data = { success: true, status: appUpdater.getStatus() };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error), status: appUpdater.getStatus() };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'download') {
|
||||
try {
|
||||
await appUpdater.downloadUpdate();
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (request.action === 'install') {
|
||||
appUpdater.quitAndInstall();
|
||||
data = { success: true };
|
||||
break;
|
||||
}
|
||||
if (request.action === 'setChannel') {
|
||||
const payload = request.payload as { channel?: 'stable' | 'beta' | 'dev' } | 'stable' | 'beta' | 'dev' | undefined;
|
||||
const channel = typeof payload === 'string' ? payload : payload?.channel;
|
||||
if (!channel) throw new Error('Invalid update.setChannel payload');
|
||||
appUpdater.setChannel(channel);
|
||||
data = { success: true };
|
||||
break;
|
||||
}
|
||||
if (request.action === 'setAutoDownload') {
|
||||
const payload = request.payload as { enable?: boolean } | boolean | undefined;
|
||||
const enable = typeof payload === 'boolean' ? payload : payload?.enable;
|
||||
if (typeof enable !== 'boolean') throw new Error('Invalid update.setAutoDownload payload');
|
||||
appUpdater.setAutoDownload(enable);
|
||||
data = { success: true };
|
||||
break;
|
||||
}
|
||||
if (request.action === 'cancelAutoInstall') {
|
||||
appUpdater.cancelAutoInstall();
|
||||
data = { success: true };
|
||||
break;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'cron': {
|
||||
if (request.action === 'list') {
|
||||
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true });
|
||||
const jobs = (result as { jobs?: GatewayCronJob[] })?.jobs ?? [];
|
||||
data = jobs.map(transformCronJob);
|
||||
break;
|
||||
}
|
||||
if (request.action === 'create') {
|
||||
const payload = request.payload as
|
||||
| { input?: { name: string; message: string; schedule: string; enabled?: boolean } }
|
||||
| [{ name: string; message: string; schedule: string; enabled?: boolean }]
|
||||
| { name: string; message: string; schedule: string; enabled?: boolean }
|
||||
| undefined;
|
||||
const input = Array.isArray(payload)
|
||||
? payload[0]
|
||||
: ('input' in (payload ?? {}) ? (payload as { input: { name: string; message: string; schedule: string; enabled?: boolean } }).input : payload);
|
||||
if (!input) throw new Error('Invalid cron.create payload');
|
||||
const gatewayInput = {
|
||||
name: input.name,
|
||||
schedule: { kind: 'cron', expr: input.schedule },
|
||||
payload: { kind: 'agentTurn', message: input.message },
|
||||
enabled: input.enabled ?? true,
|
||||
wakeMode: 'next-heartbeat',
|
||||
sessionTarget: 'isolated',
|
||||
delivery: { mode: 'none' },
|
||||
};
|
||||
const created = await gatewayManager.rpc('cron.add', gatewayInput);
|
||||
data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created;
|
||||
break;
|
||||
}
|
||||
if (request.action === 'update') {
|
||||
const payload = request.payload as
|
||||
| { id?: string; input?: Record<string, unknown> }
|
||||
| [string, Record<string, unknown>]
|
||||
| undefined;
|
||||
const id = Array.isArray(payload) ? payload[0] : payload?.id;
|
||||
const input = Array.isArray(payload) ? payload[1] : payload?.input;
|
||||
if (!id || !input) throw new Error('Invalid cron.update payload');
|
||||
const patch = { ...input };
|
||||
if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule };
|
||||
if (typeof patch.message === 'string') {
|
||||
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||
delete patch.message;
|
||||
}
|
||||
data = await gatewayManager.rpc('cron.update', { id, patch });
|
||||
break;
|
||||
}
|
||||
if (request.action === 'delete') {
|
||||
const payload = request.payload as { id?: string } | string | undefined;
|
||||
const id = typeof payload === 'string' ? payload : payload?.id;
|
||||
if (!id) throw new Error('Invalid cron.delete payload');
|
||||
data = await gatewayManager.rpc('cron.remove', { id });
|
||||
break;
|
||||
}
|
||||
if (request.action === 'toggle') {
|
||||
const payload = request.payload as { id?: string; enabled?: boolean } | [string, boolean] | undefined;
|
||||
const id = Array.isArray(payload) ? payload[0] : payload?.id;
|
||||
const enabled = Array.isArray(payload) ? payload[1] : payload?.enabled;
|
||||
if (!id || typeof enabled !== 'boolean') throw new Error('Invalid cron.toggle payload');
|
||||
data = await gatewayManager.rpc('cron.update', { id, patch: { enabled } });
|
||||
break;
|
||||
}
|
||||
if (request.action === 'trigger') {
|
||||
const payload = request.payload as { id?: string } | string | undefined;
|
||||
const id = typeof payload === 'string' ? payload : payload?.id;
|
||||
if (!id) throw new Error('Invalid cron.trigger payload');
|
||||
data = await gatewayManager.rpc('cron.run', { id, mode: 'force' });
|
||||
break;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'usage': {
|
||||
if (request.action === 'recentTokenHistory') {
|
||||
const payload = request.payload as { limit?: number } | number | undefined;
|
||||
const limit = typeof payload === 'number' ? payload : payload?.limit;
|
||||
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
|
||||
? Math.max(Math.floor(limit), 1)
|
||||
: undefined;
|
||||
data = await getRecentTokenUsageHistory(safeLimit);
|
||||
break;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'settings': {
|
||||
if (request.action === 'getAll') {
|
||||
data = await getAllSettings();
|
||||
break;
|
||||
}
|
||||
if (request.action === 'get') {
|
||||
const payload = request.payload as { key?: keyof AppSettings } | [keyof AppSettings] | undefined;
|
||||
const key = Array.isArray(payload) ? payload[0] : payload?.key;
|
||||
if (!key) throw new Error('Invalid settings.get payload');
|
||||
data = await getSetting(key);
|
||||
break;
|
||||
}
|
||||
if (request.action === 'set') {
|
||||
const payload = request.payload as
|
||||
| { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] }
|
||||
| [keyof AppSettings, AppSettings[keyof AppSettings]]
|
||||
| undefined;
|
||||
const key = Array.isArray(payload) ? payload[0] : payload?.key;
|
||||
const value = Array.isArray(payload) ? payload[1] : payload?.value;
|
||||
if (!key) throw new Error('Invalid settings.set payload');
|
||||
await setSetting(key, value as never);
|
||||
if (isProxyKey(key)) {
|
||||
await handleProxySettingsChange();
|
||||
}
|
||||
data = { success: true };
|
||||
break;
|
||||
}
|
||||
if (request.action === 'setMany') {
|
||||
const patch = (request.payload ?? {}) as Partial<AppSettings>;
|
||||
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
|
||||
for (const [key, value] of entries) {
|
||||
await setSetting(key, value as never);
|
||||
}
|
||||
if (entries.some(([key]) => isProxyKey(key))) {
|
||||
await handleProxySettingsChange();
|
||||
}
|
||||
data = { success: true };
|
||||
break;
|
||||
}
|
||||
if (request.action === 'reset') {
|
||||
await resetSettings();
|
||||
const settings = await getAllSettings();
|
||||
await handleProxySettingsChange();
|
||||
data = { success: true, settings };
|
||||
break;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id: request.id, ok: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: mapAppErrorCode(error),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill config IPC handlers
|
||||
* Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC)
|
||||
@@ -493,6 +1210,14 @@ function registerGatewayHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
type GatewayHttpProxyRequest = {
|
||||
path?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
// Get Gateway status
|
||||
ipcMain.handle('gateway:status', () => {
|
||||
return gatewayManager.getStatus();
|
||||
@@ -543,6 +1268,72 @@ function registerGatewayHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
// Gateway HTTP proxy
|
||||
// Renderer must not call gateway HTTP directly (CORS); all HTTP traffic
|
||||
// should go through this main-process proxy.
|
||||
ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => {
|
||||
try {
|
||||
const status = gatewayManager.getStatus();
|
||||
const port = status.port || 18789;
|
||||
const path = request?.path && request.path.startsWith('/') ? request.path : '/';
|
||||
const method = (request?.method || 'GET').toUpperCase();
|
||||
const timeoutMs =
|
||||
typeof request?.timeoutMs === 'number' && request.timeoutMs > 0
|
||||
? request.timeoutMs
|
||||
: 15000;
|
||||
|
||||
const token = await getSetting('gatewayToken');
|
||||
const headers: Record<string, string> = {
|
||||
...(request?.headers ?? {}),
|
||||
};
|
||||
if (!headers.Authorization && !headers.authorization && token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (request?.body !== undefined && request?.body !== null) {
|
||||
body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
text,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Chat send with media — reads staged files from disk and builds attachments.
|
||||
// Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments.
|
||||
// All other files are referenced by path in the message text so the model
|
||||
@@ -700,7 +1491,7 @@ function registerGatewayHandlers(
|
||||
* OpenClaw-related IPC handlers
|
||||
* For checking package status and channel configuration
|
||||
*/
|
||||
function registerOpenClawHandlers(): void {
|
||||
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
@@ -813,9 +1604,12 @@ function registerOpenClawHandlers(): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
logger.info(
|
||||
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally`
|
||||
);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
@@ -823,12 +1617,12 @@ function registerOpenClawHandlers(): void {
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
// Do not force stop/start here. Recent Gateway builds detect channel config
|
||||
// changes and perform an internal service restart; forcing another restart
|
||||
// from Electron can race with reconnect and kill the newly spawned process.
|
||||
logger.info(
|
||||
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload`
|
||||
);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to save channel config:', error);
|
||||
@@ -862,6 +1656,12 @@ function registerOpenClawHandlers(): void {
|
||||
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
||||
try {
|
||||
await deleteChannelConfig(channelType);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel config:', error);
|
||||
@@ -884,6 +1684,12 @@ function registerOpenClawHandlers(): void {
|
||||
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
||||
try {
|
||||
await setChannelEnabled(channelType, enabled);
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`);
|
||||
gatewayManager.debouncedReload();
|
||||
} else {
|
||||
logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to set channel enabled:', error);
|
||||
|
||||
@@ -21,6 +21,7 @@ const electronAPI = {
|
||||
'gateway:stop',
|
||||
'gateway:restart',
|
||||
'gateway:rpc',
|
||||
'gateway:httpProxy',
|
||||
'gateway:health',
|
||||
'gateway:getControlUiUrl',
|
||||
// OpenClaw
|
||||
@@ -41,6 +42,7 @@ const electronAPI = {
|
||||
'app:platform',
|
||||
'app:quit',
|
||||
'app:relaunch',
|
||||
'app:request',
|
||||
// Window controls
|
||||
'window:minimize',
|
||||
'window:maximize',
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface PluginsConfig {
|
||||
export interface OpenClawConfig {
|
||||
channels?: Record<string, ChannelConfigData>;
|
||||
plugins?: PluginsConfig;
|
||||
commands?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
|
||||
await ensureConfigDir();
|
||||
|
||||
try {
|
||||
// Enable graceful in-process reload authorization for SIGUSR1 flows.
|
||||
const commands =
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {};
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to write OpenClaw config', error);
|
||||
|
||||
@@ -136,6 +136,15 @@ async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||
}
|
||||
|
||||
@@ -819,6 +828,20 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands section ───────────────────────────────────────────
|
||||
// Required for SIGUSR1 in-process reload authorization.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
if (commands.restart !== true) {
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
modified = true;
|
||||
console.log('[sanitize] Enabling commands.restart for graceful reload support');
|
||||
}
|
||||
|
||||
// ── tools.web.search.kimi ─────────────────────────────────────
|
||||
// OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over
|
||||
// environment/auth-profiles. A stale inline key can cause persistent 401s.
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface AppSettings {
|
||||
proxyHttpsServer: string;
|
||||
proxyAllServer: string;
|
||||
proxyBypassRules: string;
|
||||
gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable' | 'beta' | 'dev';
|
||||
@@ -73,6 +74,7 @@ const defaults: AppSettings = {
|
||||
proxyHttpsServer: '',
|
||||
proxyAllServer: '',
|
||||
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||
gatewayTransportPreference: 'ws-first',
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable',
|
||||
|
||||
@@ -40,4 +40,22 @@ export default [
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']",
|
||||
message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/lib/api-client.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
143
refactor.md
Normal file
143
refactor.md
Normal 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.
|
||||
@@ -18,6 +18,7 @@ import { Settings } from './pages/Settings';
|
||||
import { Setup } from './pages/Setup';
|
||||
import { useSettingsStore } from './stores/settings';
|
||||
import { useGatewayStore } from './stores/gateway';
|
||||
import { applyGatewayTransportPreference } from './lib/api-client';
|
||||
|
||||
|
||||
/**
|
||||
@@ -90,6 +91,7 @@ function App() {
|
||||
const initSettings = useSettingsStore((state) => state.init);
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
const language = useSettingsStore((state) => state.language);
|
||||
const gatewayTransportPreference = useSettingsStore((state) => state.gatewayTransportPreference);
|
||||
const setupComplete = useSettingsStore((state) => state.setupComplete);
|
||||
const initGateway = useGatewayStore((state) => state.init);
|
||||
|
||||
@@ -149,6 +151,10 @@ function App() {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
applyGatewayTransportPreference(gatewayTransportPreference);
|
||||
}, [gatewayTransportPreference]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
|
||||
25
src/components/common/FeedbackState.tsx
Normal file
25
src/components/common/FeedbackState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Navigation sidebar with menu items.
|
||||
* No longer fixed - sits inside the flex layout below the title bar.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
@@ -24,8 +24,17 @@ import { useChatStore } from '@/stores/chat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SessionBucketKey =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'withinWeek'
|
||||
| 'withinTwoWeeks'
|
||||
| 'withinMonth'
|
||||
| 'older';
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -66,6 +75,25 @@ function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
|
||||
if (!activityMs || activityMs <= 0) return 'older';
|
||||
|
||||
const now = new Date(nowMs);
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
|
||||
if (activityMs >= startOfToday) return 'today';
|
||||
if (activityMs >= startOfYesterday) return 'yesterday';
|
||||
|
||||
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
|
||||
if (daysAgo <= 7) return 'withinWeek';
|
||||
if (daysAgo <= 14) return 'withinTwoWeeks';
|
||||
if (daysAgo <= 30) return 'withinMonth';
|
||||
return 'older';
|
||||
}
|
||||
|
||||
const INITIAL_NOW_MS = Date.now();
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
@@ -82,15 +110,12 @@ export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const isOnChat = useLocation().pathname === '/';
|
||||
|
||||
const mainSessions = sessions.filter((s) => s.key.endsWith(':main'));
|
||||
const otherSessions = sessions.filter((s) => !s.key.endsWith(':main'));
|
||||
|
||||
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
||||
sessionLabels[key] ?? label ?? displayName ?? key;
|
||||
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc('gateway:getControlUiUrl') as {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
@@ -105,8 +130,35 @@ export function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation(['common', 'chat']);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
|
||||
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 60 * 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
|
||||
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
|
||||
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
|
||||
{ key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] },
|
||||
{ key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] },
|
||||
{ key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] },
|
||||
{ key: 'older', label: t('chat:historyBuckets.older'), sessions: [] },
|
||||
];
|
||||
const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record<
|
||||
SessionBucketKey,
|
||||
(typeof sessionBuckets)[number]
|
||||
>;
|
||||
|
||||
for (const session of [...sessions].sort((a, b) =>
|
||||
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
|
||||
)) {
|
||||
const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs);
|
||||
sessionBucketMap[bucketKey].sessions.push(session);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
|
||||
@@ -153,43 +205,47 @@ export function Sidebar() {
|
||||
{/* Session list — below Settings, only when expanded */}
|
||||
{!sidebarCollapsed && sessions.length > 0 && (
|
||||
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
|
||||
{[...mainSessions, ...[...otherSessions].sort((a, b) =>
|
||||
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
|
||||
)].map((s) => (
|
||||
<div key={s.key} className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||
className={cn(
|
||||
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors',
|
||||
!s.key.endsWith(':main') && 'pr-7',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isOnChat && currentSessionKey === s.key
|
||||
? 'bg-accent/60 text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{getSessionLabel(s.key, s.displayName, s.label)}
|
||||
</button>
|
||||
{!s.key.endsWith(':main') && (
|
||||
<button
|
||||
aria-label="Delete session"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSessionToDelete({
|
||||
key: s.key,
|
||||
label: getSessionLabel(s.key, s.displayName, s.label),
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sessionBuckets.map((bucket) => (
|
||||
bucket.sessions.length > 0 ? (
|
||||
<div key={bucket.key} className="pt-1">
|
||||
<div className="px-3 py-1 text-[11px] font-medium text-muted-foreground/80">
|
||||
{bucket.label}
|
||||
</div>
|
||||
{bucket.sessions.map((s) => (
|
||||
<div key={s.key} className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||
className={cn(
|
||||
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors pr-7',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isOnChat && currentSessionKey === s.key
|
||||
? 'bg-accent/60 text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{getSessionLabel(s.key, s.displayName, s.label)}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete session"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSessionToDelete({
|
||||
key: s.key,
|
||||
label: getSessionLabel(s.key, s.displayName, s.label),
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Minus, Square, X, Copy } from 'lucide-react';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
const isMac = window.electron?.platform === 'darwin';
|
||||
|
||||
@@ -23,25 +24,25 @@ function WindowsTitleBar() {
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial state
|
||||
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => {
|
||||
invokeIpc('window:isMaximized').then((val) => {
|
||||
setMaximized(val as boolean);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electron.ipcRenderer.invoke('window:minimize');
|
||||
invokeIpc('window:minimize');
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
window.electron.ipcRenderer.invoke('window:maximize').then(() => {
|
||||
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => {
|
||||
invokeIpc('window:maximize').then(() => {
|
||||
invokeIpc('window:isMaximized').then((val) => {
|
||||
setMaximized(val as boolean);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.electron.ipcRenderer.invoke('window:close');
|
||||
invokeIpc('window:close');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
shouldInvertInDark,
|
||||
} from '@/lib/providers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
@@ -704,7 +705,7 @@ function AddProviderDialog({
|
||||
setOauthError(null);
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType);
|
||||
await invokeIpc('provider:requestOAuth', selectedType);
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
@@ -715,7 +716,7 @@ function AddProviderDialog({
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
|
||||
await invokeIpc('provider:cancelOAuth');
|
||||
};
|
||||
|
||||
// Only custom can be added multiple times.
|
||||
@@ -1013,7 +1014,7 @@ function AddProviderDialog({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
|
||||
onClick={() => invokeIpc('shell:openExternal', oauthData.verificationUri)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('aiProviders.oauth.openLoginPage')}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"gatewayWarning": "Gateway service is not running. Channels cannot connect.",
|
||||
"available": "Available Channels",
|
||||
"availableDesc": "Connect a new channel",
|
||||
"configured": "Configured Channels",
|
||||
"configuredDesc": "Manage channels that are already configured",
|
||||
"configuredBadge": "Configured",
|
||||
"deleteConfirm": "Are you sure you want to delete this channel?",
|
||||
"showAll": "Show All",
|
||||
"pluginBadge": "Plugin",
|
||||
"toast": {
|
||||
@@ -37,6 +41,8 @@
|
||||
"viewDocs": "View Documentation",
|
||||
"channelName": "Channel Name",
|
||||
"channelNamePlaceholder": "My {{name}}",
|
||||
"enableChannel": "Enable Channel",
|
||||
"enableChannelDesc": "When off, config is saved but the channel stays disabled",
|
||||
"credentialsVerified": "Credentials Verified",
|
||||
"validationFailed": "Validation Failed",
|
||||
"warnings": "Warnings",
|
||||
@@ -293,4 +299,4 @@
|
||||
}
|
||||
},
|
||||
"viewDocs": "View Documentation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,13 @@
|
||||
"refresh": "Refresh chat",
|
||||
"showThinking": "Show thinking",
|
||||
"hideThinking": "Hide thinking"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"withinWeek": "Within 1 Week",
|
||||
"withinTwoWeeks": "Within 2 Weeks",
|
||||
"withinMonth": "Within 1 Month",
|
||||
"older": "Older than 1 Month"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"paused": "Task paused",
|
||||
"deleted": "Task deleted",
|
||||
"triggered": "Task triggered successfully",
|
||||
"failedTrigger": "Failed to trigger task: {{error}}",
|
||||
"failedUpdate": "Failed to update task",
|
||||
"failedDelete": "Failed to delete task",
|
||||
"nameRequired": "Please enter a task name",
|
||||
@@ -66,5 +67,16 @@
|
||||
"channelRequired": "Please select a channel",
|
||||
"discordIdRequired": "Please enter a Discord Channel ID",
|
||||
"scheduleRequired": "Please select or enter a schedule"
|
||||
},
|
||||
"schedule": {
|
||||
"everySeconds": "Every {{count}}s",
|
||||
"everyMinutes": "Every {{count}} minutes",
|
||||
"everyHours": "Every {{count}} hours",
|
||||
"everyDays": "Every {{count}} days",
|
||||
"onceAt": "Once at {{time}}",
|
||||
"weeklyAt": "Weekly on {{day}} at {{time}}",
|
||||
"monthlyAtDay": "Monthly on day {{day}} at {{time}}",
|
||||
"dailyAt": "Daily at {{time}}",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"quickActions": {
|
||||
"title": "Quick Actions",
|
||||
"description": "Common tasks and shortcuts",
|
||||
"addProvider": "Add Provider",
|
||||
"addChannel": "Add Channel",
|
||||
"browseSkills": "Browse Skills",
|
||||
"createCron": "Create Cron",
|
||||
"installSkill": "Install Skill",
|
||||
"openChat": "Open Chat",
|
||||
"settings": "Settings",
|
||||
"devConsole": "Dev Console"
|
||||
|
||||
@@ -113,6 +113,8 @@
|
||||
"proxyHttpsServerHelp": "Advanced override for HTTPS requests. Leave blank to use Proxy Server.",
|
||||
"proxyAllServer": "ALL_PROXY / SOCKS",
|
||||
"proxyAllServerHelp": "Advanced fallback for SOCKS-capable clients and protocols such as Telegram. Leave blank to use Proxy Server.",
|
||||
"showAdvancedProxy": "Show advanced proxy fields",
|
||||
"hideAdvancedProxy": "Hide advanced proxy fields",
|
||||
"proxyBypass": "Bypass Rules",
|
||||
"proxyBypassHelp": "Semicolon, comma, or newline separated hosts that should connect directly.",
|
||||
"proxyRestartNote": "Saving reapplies Electron networking and restarts the Gateway immediately.",
|
||||
@@ -153,6 +155,25 @@
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Power-user options",
|
||||
"transport": {
|
||||
"label": "Gateway Transport Preference",
|
||||
"desc": "Choose how renderer requests gateway RPC: WebSocket, HTTP proxy, or IPC fallback.",
|
||||
"saved": "Gateway transport preference saved",
|
||||
"options": {
|
||||
"wsFirst": "WS First",
|
||||
"httpFirst": "HTTP First",
|
||||
"wsOnly": "WS Only",
|
||||
"httpOnly": "HTTP Only",
|
||||
"ipcOnly": "IPC Only"
|
||||
},
|
||||
"descriptions": {
|
||||
"wsFirst": "WS -> HTTP -> IPC",
|
||||
"httpFirst": "HTTP -> WS -> IPC",
|
||||
"wsOnly": "WS -> IPC",
|
||||
"httpOnly": "HTTP -> IPC",
|
||||
"ipcOnly": "IPC only"
|
||||
}
|
||||
},
|
||||
"devMode": "Developer Mode",
|
||||
"devModeDesc": "Show developer tools and shortcuts"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"search": "Search skills...",
|
||||
"searchMarketplace": "Search marketplace...",
|
||||
"searchButton": "Search",
|
||||
"actions": {
|
||||
"enableVisible": "Enable Visible",
|
||||
"disableVisible": "Disable Visible"
|
||||
},
|
||||
"noSkills": "No skills found",
|
||||
"noSkillsSearch": "Try a different search term",
|
||||
"noSkillsAvailable": "No skills available",
|
||||
@@ -63,7 +67,12 @@
|
||||
"searchRateLimitError": "Search rate limit exceeded. You can also search on ClawHub.ai, download the ZIP, and extract it to \"{{path}}\"",
|
||||
"installRateLimitError": "Installation rate limit exceeded. You can also download the ZIP from ClawHub.ai and extract it to \"{{path}}\"",
|
||||
"fetchTimeoutError": "Fetching skills timed out, please check your network connection.",
|
||||
"fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later."
|
||||
"fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later.",
|
||||
"noBatchEnableTargets": "All visible skills are already enabled.",
|
||||
"noBatchDisableTargets": "All visible skills are already disabled.",
|
||||
"batchEnabled": "{{count}} skills enabled.",
|
||||
"batchDisabled": "{{count}} skills disabled.",
|
||||
"batchPartial": "Updated {{success}} / {{total}} skills. Some items failed."
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
@@ -74,4 +83,4 @@
|
||||
"emptyPrompt": "Search for new skills to expand your capabilities.",
|
||||
"searchError": "ClawHub search failed. Check your connection or installation."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。",
|
||||
"available": "利用可能なチャンネル",
|
||||
"availableDesc": "新しいチャンネルを接続",
|
||||
"configured": "設定済みチャンネル",
|
||||
"configuredDesc": "すでに設定済みのチャンネルを管理",
|
||||
"configuredBadge": "設定済み",
|
||||
"deleteConfirm": "このチャンネルを削除してもよろしいですか?",
|
||||
"showAll": "すべて表示",
|
||||
"pluginBadge": "プラグイン",
|
||||
"toast": {
|
||||
@@ -37,6 +41,8 @@
|
||||
"viewDocs": "ドキュメントを表示",
|
||||
"channelName": "チャンネル名",
|
||||
"channelNamePlaceholder": "マイ {{name}}",
|
||||
"enableChannel": "チャンネルを有効化",
|
||||
"enableChannelDesc": "オフの場合、設定のみ保存しチャンネルは起動しません",
|
||||
"credentialsVerified": "認証情報が確認されました",
|
||||
"validationFailed": "検証に失敗しました",
|
||||
"warnings": "警告",
|
||||
@@ -293,4 +299,4 @@
|
||||
}
|
||||
},
|
||||
"viewDocs": "ドキュメントを表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,13 @@
|
||||
"refresh": "チャットを更新",
|
||||
"showThinking": "思考を表示",
|
||||
"hideThinking": "思考を非表示"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "今日",
|
||||
"yesterday": "昨日",
|
||||
"withinWeek": "1週間以内",
|
||||
"withinTwoWeeks": "2週間以内",
|
||||
"withinMonth": "1か月以内",
|
||||
"older": "1か月より前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"paused": "タスクを停止しました",
|
||||
"deleted": "タスクを削除しました",
|
||||
"triggered": "タスクを正常にトリガーしました",
|
||||
"failedTrigger": "タスクの実行に失敗しました: {{error}}",
|
||||
"failedUpdate": "タスクの更新に失敗しました",
|
||||
"failedDelete": "タスクの削除に失敗しました",
|
||||
"nameRequired": "タスク名を入力してください",
|
||||
@@ -66,5 +67,16 @@
|
||||
"channelRequired": "チャンネルを選択してください",
|
||||
"discordIdRequired": "DiscordチャンネルIDを入力してください",
|
||||
"scheduleRequired": "スケジュールを選択または入力してください"
|
||||
},
|
||||
"schedule": {
|
||||
"everySeconds": "{{count}}秒ごと",
|
||||
"everyMinutes": "{{count}}分ごと",
|
||||
"everyHours": "{{count}}時間ごと",
|
||||
"everyDays": "{{count}}日ごと",
|
||||
"onceAt": "{{time}} に1回実行",
|
||||
"weeklyAt": "毎週 {{day}} {{time}}",
|
||||
"monthlyAtDay": "毎月 {{day}}日 {{time}}",
|
||||
"dailyAt": "毎日 {{time}}",
|
||||
"unknown": "不明"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"quickActions": {
|
||||
"title": "クイックアクション",
|
||||
"description": "よく使うタスクとショートカット",
|
||||
"addProvider": "プロバイダー追加",
|
||||
"addChannel": "チャンネル追加",
|
||||
"browseSkills": "スキルを探す",
|
||||
"createCron": "定期タスク作成",
|
||||
"installSkill": "スキルをインストール",
|
||||
"openChat": "チャットを開く",
|
||||
"settings": "設定",
|
||||
"devConsole": "開発者コンソール"
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"proxyHttpsServerHelp": "HTTPS リクエスト用の高度な上書き設定です。空欄の場合はプロキシサーバーを使用します。",
|
||||
"proxyAllServer": "ALL_PROXY / SOCKS",
|
||||
"proxyAllServerHelp": "SOCKS 対応クライアントや Telegram など向けの高度なフォールバックです。空欄の場合はプロキシサーバーを使用します。",
|
||||
"showAdvancedProxy": "高度なプロキシ項目を表示",
|
||||
"hideAdvancedProxy": "高度なプロキシ項目を非表示",
|
||||
"proxyBypass": "バイパスルール",
|
||||
"proxyBypassHelp": "直接接続するホストをセミコロン、カンマ、または改行で区切って指定します。",
|
||||
"proxyRestartNote": "保存すると Electron のネットワーク設定を再適用し、Gateway をすぐ再起動します。",
|
||||
@@ -152,6 +154,25 @@
|
||||
"advanced": {
|
||||
"title": "詳細設定",
|
||||
"description": "上級ユーザー向けオプション",
|
||||
"transport": {
|
||||
"label": "Gateway 転送優先度",
|
||||
"desc": "レンダラープロセスから Gateway RPC を呼ぶ際の優先プロトコルを選択します。",
|
||||
"saved": "Gateway 転送優先度を保存しました",
|
||||
"options": {
|
||||
"wsFirst": "WS 優先",
|
||||
"httpFirst": "HTTP 優先",
|
||||
"wsOnly": "WS のみ",
|
||||
"httpOnly": "HTTP のみ",
|
||||
"ipcOnly": "IPC のみ"
|
||||
},
|
||||
"descriptions": {
|
||||
"wsFirst": "WS -> HTTP -> IPC",
|
||||
"httpFirst": "HTTP -> WS -> IPC",
|
||||
"wsOnly": "WS -> IPC",
|
||||
"httpOnly": "HTTP -> IPC",
|
||||
"ipcOnly": "IPC のみ"
|
||||
}
|
||||
},
|
||||
"devMode": "開発者モード",
|
||||
"devModeDesc": "開発者ツールとショートカットを表示"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"search": "スキルを検索...",
|
||||
"searchMarketplace": "マーケットプレイスを検索...",
|
||||
"searchButton": "検索",
|
||||
"actions": {
|
||||
"enableVisible": "表示中を一括有効化",
|
||||
"disableVisible": "表示中を一括無効化"
|
||||
},
|
||||
"noSkills": "スキルが見つかりません",
|
||||
"noSkillsSearch": "別の検索語をお試しください",
|
||||
"noSkillsAvailable": "利用可能なスキルがありません",
|
||||
@@ -63,7 +67,12 @@
|
||||
"searchRateLimitError": "検索リクエストの制限を超過しました。ClawHub.aiで検索してZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
|
||||
"installRateLimitError": "インストールリクエストの制限を超過しました。ClawHub.aiからZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
|
||||
"fetchTimeoutError": "スキルリストの取得がタイムアウトしました。ネットワークを確認してください。",
|
||||
"fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。"
|
||||
"fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。",
|
||||
"noBatchEnableTargets": "表示中のスキルはすべて有効です。",
|
||||
"noBatchDisableTargets": "表示中のスキルはすべて無効です。",
|
||||
"batchEnabled": "{{count}} 件のスキルを有効化しました。",
|
||||
"batchDisabled": "{{count}} 件のスキルを無効化しました。",
|
||||
"batchPartial": "{{success}} / {{total}} 件を更新しました。一部失敗しています。"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "マーケットプレイス",
|
||||
@@ -74,4 +83,4 @@
|
||||
"emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。",
|
||||
"searchError": "ClawHub検索に失敗しました。接続またはインストールを確認してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"gatewayWarning": "网关服务未运行,频道无法连接。",
|
||||
"available": "可用频道",
|
||||
"availableDesc": "连接一个新的频道",
|
||||
"configured": "已配置频道",
|
||||
"configuredDesc": "管理已完成配置的频道",
|
||||
"configuredBadge": "已配置",
|
||||
"deleteConfirm": "确定要删除此频道吗?",
|
||||
"showAll": "显示全部",
|
||||
"pluginBadge": "插件",
|
||||
"toast": {
|
||||
@@ -37,6 +41,8 @@
|
||||
"viewDocs": "查看文档",
|
||||
"channelName": "频道名称",
|
||||
"channelNamePlaceholder": "我的 {{name}}",
|
||||
"enableChannel": "启用频道",
|
||||
"enableChannelDesc": "关闭后会保存配置,但不会启动该频道",
|
||||
"credentialsVerified": "凭证已验证",
|
||||
"validationFailed": "验证失败",
|
||||
"warnings": "警告",
|
||||
@@ -293,4 +299,4 @@
|
||||
}
|
||||
},
|
||||
"viewDocs": "查看文档"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,13 @@
|
||||
"refresh": "刷新聊天",
|
||||
"showThinking": "显示思考过程",
|
||||
"hideThinking": "隐藏思考过程"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"withinWeek": "一周内",
|
||||
"withinTwoWeeks": "两周内",
|
||||
"withinMonth": "一个月内",
|
||||
"older": "一个月之前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"paused": "任务已暂停",
|
||||
"deleted": "任务已删除",
|
||||
"triggered": "任务已成功触发",
|
||||
"failedTrigger": "触发任务失败: {{error}}",
|
||||
"failedUpdate": "更新任务失败",
|
||||
"failedDelete": "删除任务失败",
|
||||
"nameRequired": "请输入任务名称",
|
||||
@@ -66,5 +67,16 @@
|
||||
"channelRequired": "请选择频道",
|
||||
"discordIdRequired": "请输入 Discord 频道 ID",
|
||||
"scheduleRequired": "请选择或输入调度计划"
|
||||
},
|
||||
"schedule": {
|
||||
"everySeconds": "每 {{count}} 秒",
|
||||
"everyMinutes": "每 {{count}} 分钟",
|
||||
"everyHours": "每 {{count}} 小时",
|
||||
"everyDays": "每 {{count}} 天",
|
||||
"onceAt": "执行一次,时间:{{time}}",
|
||||
"weeklyAt": "每周 {{day}} {{time}}",
|
||||
"monthlyAtDay": "每月 {{day}} 日 {{time}}",
|
||||
"dailyAt": "每天 {{time}}",
|
||||
"unknown": "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"quickActions": {
|
||||
"title": "快捷操作",
|
||||
"description": "常用任务和快捷方式",
|
||||
"addProvider": "添加模型供应商",
|
||||
"addChannel": "添加频道",
|
||||
"browseSkills": "浏览技能",
|
||||
"createCron": "创建定时任务",
|
||||
"installSkill": "安装技能",
|
||||
"openChat": "打开聊天",
|
||||
"settings": "设置",
|
||||
"devConsole": "开发者控制台"
|
||||
|
||||
@@ -113,6 +113,8 @@
|
||||
"proxyHttpsServerHelp": "HTTPS 请求的高级覆盖项。留空时使用“代理服务器”。",
|
||||
"proxyAllServer": "ALL_PROXY / SOCKS",
|
||||
"proxyAllServerHelp": "支持 SOCKS 的客户端和 Telegram 等协议的高级兜底代理。留空时使用“代理服务器”。",
|
||||
"showAdvancedProxy": "显示高级代理字段",
|
||||
"hideAdvancedProxy": "隐藏高级代理字段",
|
||||
"proxyBypass": "绕过规则",
|
||||
"proxyBypassHelp": "使用分号、逗号或换行分隔需要直连的主机。",
|
||||
"proxyRestartNote": "保存后会立即重新应用 Electron 网络代理,并自动重启 Gateway。",
|
||||
@@ -153,6 +155,25 @@
|
||||
"advanced": {
|
||||
"title": "高级",
|
||||
"description": "高级选项",
|
||||
"transport": {
|
||||
"label": "网关传输策略",
|
||||
"desc": "选择渲染进程访问网关 RPC 的优先协议:WebSocket、HTTP 代理或 IPC 回退。",
|
||||
"saved": "网关传输策略已保存",
|
||||
"options": {
|
||||
"wsFirst": "WS 优先",
|
||||
"httpFirst": "HTTP 优先",
|
||||
"wsOnly": "仅 WS",
|
||||
"httpOnly": "仅 HTTP",
|
||||
"ipcOnly": "仅 IPC"
|
||||
},
|
||||
"descriptions": {
|
||||
"wsFirst": "WS -> HTTP -> IPC",
|
||||
"httpFirst": "HTTP -> WS -> IPC",
|
||||
"wsOnly": "WS -> IPC",
|
||||
"httpOnly": "HTTP -> IPC",
|
||||
"ipcOnly": "仅 IPC"
|
||||
}
|
||||
},
|
||||
"devMode": "开发者模式",
|
||||
"devModeDesc": "显示开发者工具和快捷方式"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"search": "搜索技能...",
|
||||
"searchMarketplace": "搜索市场...",
|
||||
"searchButton": "搜索",
|
||||
"actions": {
|
||||
"enableVisible": "批量启用可见项",
|
||||
"disableVisible": "批量禁用可见项"
|
||||
},
|
||||
"noSkills": "未找到技能",
|
||||
"noSkillsSearch": "尝试不同的搜索词",
|
||||
"noSkillsAvailable": "暂无可用技能",
|
||||
@@ -63,7 +67,12 @@
|
||||
"searchRateLimitError": "搜索请求过于频繁。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{{path}}\"",
|
||||
"installRateLimitError": "安装请求过于频繁。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{{path}}\"",
|
||||
"fetchTimeoutError": "获取技能列表超时,请检查网络。",
|
||||
"fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。"
|
||||
"fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。",
|
||||
"noBatchEnableTargets": "当前可见技能都已启用。",
|
||||
"noBatchDisableTargets": "当前可见技能都已禁用。",
|
||||
"batchEnabled": "已启用 {{count}} 个技能。",
|
||||
"batchDisabled": "已禁用 {{count}} 个技能。",
|
||||
"batchPartial": "已更新 {{success}} / {{total}} 个技能,部分操作失败。"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "市场",
|
||||
@@ -74,4 +83,4 @@
|
||||
"emptyPrompt": "搜索新技能以扩展您的能力。",
|
||||
"searchError": "ClawHub 搜索失败。请检查您的连接或安装。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
916
src/lib/api-client.ts
Normal file
916
src/lib/api-client.ts
Normal 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
29
src/lib/telemetry.ts
Normal 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;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { HashRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './styles/globals.css';
|
||||
import { initializeDefaultTransports } from './lib/api-client';
|
||||
|
||||
initializeDefaultTransports();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Channels Page
|
||||
* Manage messaging channel connections with configuration UI
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Radio,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from '@/types/channel';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
export function Channels() {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -54,17 +55,23 @@ export function Channels() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
|
||||
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
|
||||
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
|
||||
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
|
||||
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
|
||||
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
|
||||
// Fetch channels on mount
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
void fetchChannels({ probe: false });
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Fetch configured channel types from config file
|
||||
const fetchConfiguredTypes = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as {
|
||||
const result = await invokeIpc('channel:listConfigured') as {
|
||||
success: boolean;
|
||||
channels?: string[];
|
||||
};
|
||||
@@ -77,29 +84,86 @@ export function Channels() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchConfiguredTypes();
|
||||
}, [fetchConfiguredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
if (refreshDebounceRef.current) {
|
||||
clearTimeout(refreshDebounceRef.current);
|
||||
}
|
||||
refreshDebounceRef.current = setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 300);
|
||||
});
|
||||
return () => {
|
||||
if (refreshDebounceRef.current) {
|
||||
clearTimeout(refreshDebounceRef.current);
|
||||
refreshDebounceRef.current = null;
|
||||
}
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [fetchChannels, fetchConfiguredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayStatus.state === 'running') {
|
||||
setChannelSnapshot(channels);
|
||||
setConfiguredTypesSnapshot(configuredTypes);
|
||||
}
|
||||
}, [gatewayStatus.state, channels, configuredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousState = lastGatewayStateRef.current;
|
||||
const currentState = gatewayStatus.state;
|
||||
const justReconnected =
|
||||
currentState === 'running' &&
|
||||
previousState !== 'running';
|
||||
lastGatewayStateRef.current = currentState;
|
||||
|
||||
if (!justReconnected) return;
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]);
|
||||
|
||||
// Delay warning to avoid flicker during expected short reload/restart windows.
|
||||
useEffect(() => {
|
||||
const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error';
|
||||
const timer = setTimeout(() => {
|
||||
setShowGatewayWarning(shouldWarn);
|
||||
}, shouldWarn ? 1800 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [gatewayStatus.state]);
|
||||
|
||||
// Get channel types to display
|
||||
const displayedChannelTypes = getPrimaryChannels();
|
||||
const isGatewayTransitioning =
|
||||
gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting';
|
||||
const channelsForView =
|
||||
isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels;
|
||||
const configuredTypesForView =
|
||||
isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes;
|
||||
|
||||
// Single source of truth for configured status across cards, stats and badges.
|
||||
const configuredTypeSet = useMemo(() => {
|
||||
const set = new Set<string>(configuredTypesForView);
|
||||
if (set.size === 0 && channelsForView.length > 0) {
|
||||
channelsForView.forEach((channel) => set.add(channel.type));
|
||||
}
|
||||
return set;
|
||||
}, [configuredTypesForView, channelsForView]);
|
||||
|
||||
const configuredChannels = useMemo(
|
||||
() => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)),
|
||||
[channelsForView, configuredTypeSet]
|
||||
);
|
||||
|
||||
// Connected/disconnected channel counts
|
||||
const connectedCount = channels.filter((c) => c.status === 'connected').length;
|
||||
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
|
||||
|
||||
if (loading) {
|
||||
if (loading && channels.length === 0) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
@@ -118,8 +182,20 @@ export function Channels() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchChannels}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setRefreshing(true);
|
||||
await fetchChannels({ probe: true, silent: true });
|
||||
await fetchConfiguredTypes();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2${refreshing ? ' animate-spin' : ''}`} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
@@ -138,7 +214,7 @@ export function Channels() {
|
||||
<Radio className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{channels.length}</p>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +240,7 @@ export function Channels() {
|
||||
<PowerOff className="h-6 w-6 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,7 +249,7 @@ export function Channels() {
|
||||
</div>
|
||||
|
||||
{/* Gateway Warning */}
|
||||
{gatewayStatus.state !== 'running' && (
|
||||
{showGatewayWarning && (
|
||||
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||
@@ -194,7 +270,7 @@ export function Channels() {
|
||||
)}
|
||||
|
||||
{/* Configured Channels */}
|
||||
{channels.length > 0 && (
|
||||
{configuredChannels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('configured')}</CardTitle>
|
||||
@@ -202,7 +278,7 @@ export function Channels() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
{configuredChannels.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
@@ -230,7 +306,7 @@ export function Channels() {
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{displayedChannelTypes.map((type) => {
|
||||
const meta = CHANNEL_META[type];
|
||||
const isConfigured = configuredTypes.includes(type);
|
||||
const isConfigured = configuredTypeSet.has(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
@@ -243,7 +319,7 @@ export function Channels() {
|
||||
<span className="text-3xl">{meta.icon}</span>
|
||||
<p className="font-medium mt-2">{meta.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{meta.description}
|
||||
{t(meta.description)}
|
||||
</p>
|
||||
{isConfigured && (
|
||||
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
|
||||
@@ -272,8 +348,12 @@ export function Channels() {
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
onChannelAdded={() => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 2200);
|
||||
setShowAddDialog(false);
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
@@ -282,14 +362,16 @@ export function Channels() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!channelToDelete}
|
||||
title={t('common.confirm', 'Confirm')}
|
||||
title={t('common:actions.confirm', 'Confirm')}
|
||||
message={t('deleteConfirm')}
|
||||
confirmLabel={t('common.delete', 'Delete')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (channelToDelete) {
|
||||
await deleteChannel(channelToDelete.id);
|
||||
await fetchConfiguredTypes();
|
||||
await fetchChannels({ probe: false, silent: true });
|
||||
setChannelToDelete(null);
|
||||
}
|
||||
}}
|
||||
@@ -355,7 +437,6 @@ interface AddChannelDialogProps {
|
||||
|
||||
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
|
||||
const { t } = useTranslation('channels');
|
||||
const { addChannel } = useChannelsStore();
|
||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
@@ -382,7 +463,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setChannelName('');
|
||||
setIsExistingConfig(false);
|
||||
// Ensure we clean up any pending QR session if switching away
|
||||
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -391,7 +472,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'channel:getFormValues',
|
||||
selectedType
|
||||
) as { success: boolean; values?: Record<string, string> };
|
||||
@@ -439,7 +520,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.success(t('toast.whatsappConnected'));
|
||||
const accountId = data?.accountId || channelName.trim() || 'default';
|
||||
try {
|
||||
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||
const saveResult = await invokeIpc(
|
||||
'channel:saveConfig',
|
||||
'whatsapp',
|
||||
{ enabled: true }
|
||||
@@ -452,15 +533,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
} catch (error) {
|
||||
console.error('Failed to save WhatsApp config:', error);
|
||||
}
|
||||
// Register the channel locally so it shows up immediately
|
||||
addChannel({
|
||||
type: 'whatsapp',
|
||||
name: channelName || 'WhatsApp',
|
||||
}).then(() => {
|
||||
// Restart gateway to pick up the new session
|
||||
window.electron.ipcRenderer.invoke('gateway:restart').catch(console.error);
|
||||
onChannelAdded();
|
||||
});
|
||||
// channel:saveConfig triggers main-process reload/restart handling.
|
||||
// UI state refresh is handled by parent onChannelAdded().
|
||||
onChannelAdded();
|
||||
};
|
||||
|
||||
const onError = (...args: unknown[]) => {
|
||||
@@ -480,9 +555,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
if (typeof removeSuccessListener === 'function') removeSuccessListener();
|
||||
if (typeof removeErrorListener === 'function') removeErrorListener();
|
||||
// Cancel when unmounting or switching types
|
||||
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
};
|
||||
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
|
||||
}, [selectedType, channelName, onChannelAdded, t]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedType) return;
|
||||
@@ -491,7 +566,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
@@ -538,14 +613,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
// For QR-based channels, request QR code
|
||||
if (meta.connectionType === 'qr') {
|
||||
const accountId = channelName.trim() || 'default';
|
||||
await window.electron.ipcRenderer.invoke('channel:requestWhatsAppQr', accountId);
|
||||
await invokeIpc('channel:requestWhatsAppQr', accountId);
|
||||
// The QR code will be set via event listener
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Validate credentials against the actual service API
|
||||
if (meta.connectionType === 'token') {
|
||||
const validationResponse = await window.electron.ipcRenderer.invoke(
|
||||
const validationResponse = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
@@ -592,7 +667,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
// Step 2: Save channel configuration via IPC
|
||||
const config: Record<string, unknown> = { ...configValues };
|
||||
const saveResult = await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedType, config) as {
|
||||
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
@@ -605,20 +680,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.warning(saveResult.warning);
|
||||
}
|
||||
|
||||
// Step 3: Add a local channel entry for the UI
|
||||
await addChannel({
|
||||
type: selectedType,
|
||||
name: channelName || CHANNEL_NAMES[selectedType],
|
||||
token: configValues[meta.configFields[0]?.key] || undefined,
|
||||
});
|
||||
// Step 3: Do not call channels.add from renderer; this races with
|
||||
// gateway reload/restart windows and can create stale local entries.
|
||||
|
||||
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||
|
||||
// Gateway restart is now handled server-side via debouncedRestart()
|
||||
// inside the channel:saveConfig IPC handler, so we don't need to
|
||||
// trigger it explicitly here. This avoids cascading restarts when
|
||||
// multiple config changes happen in quick succession (e.g. during
|
||||
// the setup wizard).
|
||||
// Gateway reload/restart is handled in the main-process save handler.
|
||||
// Renderer should only persist config and refresh local UI state.
|
||||
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
||||
|
||||
// Brief delay so user can see the success state before dialog closes
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Send, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,7 +101,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
const pickFiles = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('dialog:open', {
|
||||
const result = await invokeIpc('dialog:open', {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
}) as { canceled: boolean; filePaths?: string[] };
|
||||
if (result.canceled || !result.filePaths?.length) return;
|
||||
@@ -125,7 +126,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
// Stage all files via IPC
|
||||
console.log('[pickFiles] Staging files:', result.filePaths);
|
||||
const staged = await window.electron.ipcRenderer.invoke(
|
||||
const staged = await invokeIpc(
|
||||
'file:stage',
|
||||
result.filePaths,
|
||||
) as Array<{
|
||||
@@ -192,7 +193,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
console.log(`[stageBuffer] Reading file: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||
const base64 = await readFileAsBase64(file);
|
||||
console.log(`[stageBuffer] Base64 length: ${base64?.length ?? 'null'}`);
|
||||
const staged = await window.electron.ipcRenderer.invoke('file:stageBuffer', {
|
||||
const staged = await invokeIpc('file:stageBuffer', {
|
||||
base64,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
@@ -226,6 +227,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
}, []);
|
||||
|
||||
const allReady = attachments.length === 0 || attachments.every(a => a.status === 'ready');
|
||||
const hasFailedAttachments = attachments.some((a) => a.status === 'error');
|
||||
const canSend = (input.trim() || attachments.length > 0) && allReady && !disabled && !sending;
|
||||
const canStop = sending && !disabled && !!onStop;
|
||||
|
||||
@@ -391,6 +393,22 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>Tip: switch sessions from the sidebar to keep context clean.</span>
|
||||
{hasFailedAttachments && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => {
|
||||
setAttachments((prev) => prev.filter((att) => att.status !== 'error'));
|
||||
void pickFiles();
|
||||
}}
|
||||
>
|
||||
Retry failed attachments
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
||||
import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils';
|
||||
|
||||
@@ -539,7 +540,7 @@ function ImageLightbox({
|
||||
|
||||
const handleShowInFolder = useCallback(() => {
|
||||
if (filePath) {
|
||||
window.electron.ipcRenderer.invoke('shell:showItemInFolder', filePath);
|
||||
invokeIpc('shell:showItemInFolder', filePath);
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
|
||||
@@ -37,17 +37,18 @@ import { toast } from 'sonner';
|
||||
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
|
||||
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
// Common cron schedule presets
|
||||
const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [
|
||||
{ label: 'Every minute', value: '* * * * *', type: 'interval' },
|
||||
{ label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' },
|
||||
{ label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' },
|
||||
{ label: 'Every hour', value: '0 * * * *', type: 'interval' },
|
||||
{ label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' },
|
||||
{ label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' },
|
||||
{ label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' },
|
||||
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
|
||||
const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [
|
||||
{ key: 'everyMinute', value: '* * * * *', type: 'interval' },
|
||||
{ key: 'every5Min', value: '*/5 * * * *', type: 'interval' },
|
||||
{ key: 'every15Min', value: '*/15 * * * *', type: 'interval' },
|
||||
{ key: 'everyHour', value: '0 * * * *', type: 'interval' },
|
||||
{ key: 'daily9am', value: '0 9 * * *', type: 'daily' },
|
||||
{ key: 'daily6pm', value: '0 18 * * *', type: 'daily' },
|
||||
{ key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' },
|
||||
{ key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' },
|
||||
];
|
||||
|
||||
// Parse cron schedule to human-readable format
|
||||
@@ -55,25 +56,25 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] =
|
||||
// { kind: "cron", expr: "...", tz?: "..." }
|
||||
// { kind: "every", everyMs: number }
|
||||
// { kind: "at", at: "..." }
|
||||
function parseCronSchedule(schedule: unknown): string {
|
||||
function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string {
|
||||
// Handle Gateway CronSchedule object format
|
||||
if (schedule && typeof schedule === 'object') {
|
||||
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
|
||||
if (s.kind === 'cron' && typeof s.expr === 'string') {
|
||||
return parseCronExpr(s.expr);
|
||||
return parseCronExpr(s.expr, t);
|
||||
}
|
||||
if (s.kind === 'every' && typeof s.everyMs === 'number') {
|
||||
const ms = s.everyMs;
|
||||
if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`;
|
||||
if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`;
|
||||
if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`;
|
||||
return `Every ${Math.round(ms / 86_400_000)} days`;
|
||||
if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) });
|
||||
if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) });
|
||||
if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) });
|
||||
return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) });
|
||||
}
|
||||
if (s.kind === 'at' && typeof s.at === 'string') {
|
||||
try {
|
||||
return `Once at ${new Date(s.at).toLocaleString()}`;
|
||||
return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() });
|
||||
} catch {
|
||||
return `Once at ${s.at}`;
|
||||
return t('schedule.onceAt', { time: s.at });
|
||||
}
|
||||
}
|
||||
return String(schedule);
|
||||
@@ -81,39 +82,96 @@ function parseCronSchedule(schedule: unknown): string {
|
||||
|
||||
// Handle plain cron string
|
||||
if (typeof schedule === 'string') {
|
||||
return parseCronExpr(schedule);
|
||||
return parseCronExpr(schedule, t);
|
||||
}
|
||||
|
||||
return String(schedule ?? 'Unknown');
|
||||
return String(schedule ?? t('schedule.unknown'));
|
||||
}
|
||||
|
||||
// Parse a plain cron expression string to human-readable text
|
||||
function parseCronExpr(cron: string): string {
|
||||
function parseCronExpr(cron: string, t: TFunction<'cron'>): string {
|
||||
const preset = schedulePresets.find((p) => p.value === cron);
|
||||
if (preset) return preset.label;
|
||||
if (preset) return t(`presets.${preset.key}` as const);
|
||||
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) return cron;
|
||||
|
||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
||||
|
||||
if (minute === '*' && hour === '*') return 'Every minute';
|
||||
if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`;
|
||||
if (hour === '*' && minute === '0') return 'Every hour';
|
||||
if (minute === '*' && hour === '*') return t('presets.everyMinute');
|
||||
if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) });
|
||||
if (hour === '*' && minute === '0') return t('presets.everyHour');
|
||||
if (dayOfWeek !== '*' && dayOfMonth === '*') {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`;
|
||||
return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` });
|
||||
}
|
||||
if (dayOfMonth !== '*') {
|
||||
return `Monthly on day ${dayOfMonth} at ${hour}:${minute.padStart(2, '0')}`;
|
||||
return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` });
|
||||
}
|
||||
if (hour !== '*') {
|
||||
return `Daily at ${hour}:${minute.padStart(2, '0')}`;
|
||||
return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` });
|
||||
}
|
||||
|
||||
return cron;
|
||||
}
|
||||
|
||||
function estimateNextRun(scheduleExpr: string): string | null {
|
||||
const now = new Date();
|
||||
const next = new Date(now.getTime());
|
||||
|
||||
if (scheduleExpr === '* * * * *') {
|
||||
next.setSeconds(0, 0);
|
||||
next.setMinutes(next.getMinutes() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '*/5 * * * *') {
|
||||
const delta = 5 - (next.getMinutes() % 5 || 5);
|
||||
next.setSeconds(0, 0);
|
||||
next.setMinutes(next.getMinutes() + delta);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '*/15 * * * *') {
|
||||
const delta = 15 - (next.getMinutes() % 15 || 15);
|
||||
next.setSeconds(0, 0);
|
||||
next.setMinutes(next.getMinutes() + delta);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 * * * *') {
|
||||
next.setMinutes(0, 0, 0);
|
||||
next.setHours(next.getHours() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') {
|
||||
const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18;
|
||||
next.setSeconds(0, 0);
|
||||
next.setHours(targetHour, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 9 * * 1') {
|
||||
next.setSeconds(0, 0);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
const day = next.getDay();
|
||||
const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7;
|
||||
next.setDate(next.getDate() + daysUntilMonday);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 9 1 * *') {
|
||||
next.setSeconds(0, 0);
|
||||
next.setDate(1);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
if (next <= now) next.setMonth(next.getMonth() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create/Edit Task Dialog
|
||||
interface TaskDialogProps {
|
||||
job?: CronJob;
|
||||
@@ -141,6 +199,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
const [customSchedule, setCustomSchedule] = useState('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [enabled, setEnabled] = useState(job?.enabled ?? true);
|
||||
const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -226,15 +285,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
className="justify-start"
|
||||
>
|
||||
<Timer className="h-4 w-4 mr-2" />
|
||||
{preset.label === 'Every minute' ? t('presets.everyMinute') :
|
||||
preset.label === 'Every 5 minutes' ? t('presets.every5Min') :
|
||||
preset.label === 'Every 15 minutes' ? t('presets.every15Min') :
|
||||
preset.label === 'Every hour' ? t('presets.everyHour') :
|
||||
preset.label === 'Daily at 9am' ? t('presets.daily9am') :
|
||||
preset.label === 'Daily at 6pm' ? t('presets.daily6pm') :
|
||||
preset.label === 'Weekly (Mon 9am)' ? t('presets.weeklyMon') :
|
||||
preset.label === 'Monthly (1st at 9am)' ? t('presets.monthly1st') :
|
||||
preset.label}
|
||||
{t(`presets.${preset.key}` as const)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,6 +305,9 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
>
|
||||
{useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedulePreview ? `${t('card.next')}: ${schedulePreview}` : t('dialog.cronPlaceholder')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
@@ -270,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
{t('common:actions.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{t('common:status.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -312,7 +366,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
toast.success(t('toast.triggered'));
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger cron job:', error);
|
||||
toast.error(`Failed to trigger task: ${error instanceof Error ? error.message : String(error)}`);
|
||||
toast.error(t('toast.failedTrigger', { error: error instanceof Error ? error.message : String(error) }));
|
||||
} finally {
|
||||
setTriggering(false);
|
||||
}
|
||||
@@ -345,7 +399,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
<CardTitle className="text-lg">{job.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Timer className="h-3 w-3" />
|
||||
{parseCronSchedule(job.schedule)}
|
||||
{parseCronSchedule(job.schedule, t)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-1">Edit</span>
|
||||
<span className="ml-1">{t('common:actions.edit', 'Edit')}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<span className="ml-1 text-destructive">Delete</span>
|
||||
<span className="ml-1 text-destructive">{t('common:actions.delete', 'Delete')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -643,10 +697,10 @@ export function Cron() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!jobToDelete}
|
||||
title={t('common.confirm', 'Confirm')}
|
||||
title={t('common:actions.confirm', 'Confirm')}
|
||||
message={t('card.deleteConfirm')}
|
||||
confirmLabel={t('common.delete', 'Delete')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (jobToDelete) {
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
Settings,
|
||||
Plus,
|
||||
Terminal,
|
||||
Coins,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -25,6 +25,9 @@ import { useChannelsStore } from '@/stores/channels';
|
||||
import { useSkillsStore } from '@/stores/skills';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||
import { FeedbackState } from '@/components/common/FeedbackState';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type UsageHistoryEntry = {
|
||||
@@ -60,12 +63,13 @@ export function Dashboard() {
|
||||
|
||||
// Fetch data only when gateway is running
|
||||
useEffect(() => {
|
||||
trackUiEvent('dashboard.page_viewed');
|
||||
if (isGatewayRunning) {
|
||||
fetchChannels();
|
||||
fetchSkills();
|
||||
window.electron.ipcRenderer.invoke('usage:recentTokenHistory')
|
||||
invokeIpc<UsageHistoryEntry[]>('usage:recentTokenHistory')
|
||||
.then((entries) => {
|
||||
setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []);
|
||||
setUsageHistory(Array.isArray(entries) ? entries : []);
|
||||
setUsagePage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -107,12 +111,13 @@ export function Dashboard() {
|
||||
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>('gateway:getControlUiUrl');
|
||||
if (result.success && result.url) {
|
||||
trackUiEvent('dashboard.quick_action', { action: 'dev_console' });
|
||||
window.electron.openExternal(result.url);
|
||||
} else {
|
||||
console.error('Failed to get Dev Console URL:', result.error);
|
||||
@@ -196,27 +201,39 @@ export function Dashboard() {
|
||||
<CardDescription>{t('quickActions.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/channels">
|
||||
<Link to="/settings" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'add_provider' })}>
|
||||
<Wrench className="h-5 w-5" />
|
||||
<span>{t('quickActions.addProvider')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/channels" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'add_channel' })}>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span>{t('quickActions.addChannel')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/skills">
|
||||
<Puzzle className="h-5 w-5" />
|
||||
<span>{t('quickActions.browseSkills')}</span>
|
||||
<Link to="/cron" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'create_cron' })}>
|
||||
<Clock className="h-5 w-5" />
|
||||
<span>{t('quickActions.createCron')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/">
|
||||
<Link to="/skills" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'install_skill' })}>
|
||||
<Puzzle className="h-5 w-5" />
|
||||
<span>{t('quickActions.installSkill')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'open_chat' })}>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span>{t('quickActions.openChat')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/settings">
|
||||
<Link to="/settings" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'open_settings' })}>
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>{t('quickActions.settings')}</span>
|
||||
</Link>
|
||||
@@ -244,13 +261,15 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channels.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('noChannels')}</p>
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/channels">{t('addFirst')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<FeedbackState
|
||||
state="empty"
|
||||
title={t('noChannels')}
|
||||
action={(
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/channels">{t('addFirst')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{channels.slice(0, 5).map((channel) => (
|
||||
@@ -286,13 +305,15 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{skills.filter((s) => s.enabled).length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('noSkills')}</p>
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/skills">{t('enableSome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<FeedbackState
|
||||
state="empty"
|
||||
title={t('noSkills')}
|
||||
action={(
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/skills">{t('enableSome')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills
|
||||
@@ -322,17 +343,11 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usageLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('recentTokenHistory.loading')}</div>
|
||||
<FeedbackState state="loading" title={t('recentTokenHistory.loading')} />
|
||||
) : visibleUsageHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('recentTokenHistory.empty')}</p>
|
||||
</div>
|
||||
<FeedbackState state="empty" title={t('recentTokenHistory.empty')} />
|
||||
) : filteredUsageHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('recentTokenHistory.emptyForWindow')}</p>
|
||||
</div>
|
||||
<FeedbackState state="empty" title={t('recentTokenHistory.emptyForWindow')} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Moon,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
ExternalLink,
|
||||
Key,
|
||||
@@ -28,6 +30,8 @@ import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||
import { invokeIpc, toUserMessage } from '@/lib/api-client';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
type ControlUiInfo = {
|
||||
@@ -36,6 +40,8 @@ type ControlUiInfo = {
|
||||
port: number;
|
||||
};
|
||||
|
||||
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const {
|
||||
@@ -51,12 +57,14 @@ export function Settings() {
|
||||
proxyHttpsServer,
|
||||
proxyAllServer,
|
||||
proxyBypassRules,
|
||||
gatewayTransportPreference,
|
||||
setProxyEnabled,
|
||||
setProxyServer,
|
||||
setProxyHttpServer,
|
||||
setProxyHttpsServer,
|
||||
setProxyAllServer,
|
||||
setProxyBypassRules,
|
||||
setGatewayTransportPreference,
|
||||
autoCheckUpdate,
|
||||
setAutoCheckUpdate,
|
||||
autoDownloadUpdate,
|
||||
@@ -77,8 +85,17 @@ export function Settings() {
|
||||
const [proxyAllServerDraft, setProxyAllServerDraft] = useState('');
|
||||
const [proxyBypassRulesDraft, setProxyBypassRulesDraft] = useState('');
|
||||
const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false);
|
||||
const [showAdvancedProxy, setShowAdvancedProxy] = useState(false);
|
||||
const [savingProxy, setSavingProxy] = useState(false);
|
||||
|
||||
const transportOptions: Array<{ value: GatewayTransportPreference; labelKey: string; descKey: string }> = [
|
||||
{ value: 'ws-first', labelKey: 'advanced.transport.options.wsFirst', descKey: 'advanced.transport.descriptions.wsFirst' },
|
||||
{ value: 'http-first', labelKey: 'advanced.transport.options.httpFirst', descKey: 'advanced.transport.descriptions.httpFirst' },
|
||||
{ value: 'ws-only', labelKey: 'advanced.transport.options.wsOnly', descKey: 'advanced.transport.descriptions.wsOnly' },
|
||||
{ value: 'http-only', labelKey: 'advanced.transport.options.httpOnly', descKey: 'advanced.transport.descriptions.httpOnly' },
|
||||
{ value: 'ipc-only', labelKey: 'advanced.transport.options.ipcOnly', descKey: 'advanced.transport.descriptions.ipcOnly' },
|
||||
];
|
||||
|
||||
const isWindows = window.electron.platform === 'win32';
|
||||
const showCliTools = true;
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
@@ -86,7 +103,7 @@ export function Settings() {
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
|
||||
const logs = await invokeIpc<string>('log:readFile', 100);
|
||||
setLogContent(logs);
|
||||
setShowLogs(true);
|
||||
} catch {
|
||||
@@ -97,9 +114,9 @@ export function Settings() {
|
||||
|
||||
const handleOpenLogDir = async () => {
|
||||
try {
|
||||
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string;
|
||||
const logDir = await invokeIpc<string>('log:getDir');
|
||||
if (logDir) {
|
||||
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir);
|
||||
await invokeIpc('shell:showItemInFolder', logDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -109,15 +126,16 @@ export function Settings() {
|
||||
// Open developer console
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
port?: number;
|
||||
error?: string;
|
||||
};
|
||||
}>('gateway:getControlUiUrl');
|
||||
if (result.success && result.url && result.token && typeof result.port === 'number') {
|
||||
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
|
||||
trackUiEvent('settings.open_dev_console');
|
||||
window.electron.openExternal(result.url);
|
||||
} else {
|
||||
console.error('Failed to get Dev Console URL:', result.error);
|
||||
@@ -129,12 +147,12 @@ export function Settings() {
|
||||
|
||||
const refreshControlUiInfo = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
port?: number;
|
||||
};
|
||||
}>('gateway:getControlUiUrl');
|
||||
if (result.success && result.url && result.token && typeof result.port === 'number') {
|
||||
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
|
||||
}
|
||||
@@ -159,11 +177,11 @@ export function Settings() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('openclaw:getCliCommand') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>('openclaw:getCliCommand');
|
||||
if (cancelled) return;
|
||||
if (result.success && result.command) {
|
||||
setOpenclawCliCommand(result.command);
|
||||
@@ -235,7 +253,7 @@ export function Settings() {
|
||||
const normalizedHttpsServer = proxyHttpsServerDraft.trim();
|
||||
const normalizedAllServer = proxyAllServerDraft.trim();
|
||||
const normalizedBypassRules = proxyBypassRulesDraft.trim();
|
||||
await window.electron.ipcRenderer.invoke('settings:setMany', {
|
||||
await invokeIpc('settings:setMany', {
|
||||
proxyEnabled: proxyEnabledDraft,
|
||||
proxyServer: normalizedProxyServer,
|
||||
proxyHttpServer: normalizedHttpServer,
|
||||
@@ -252,8 +270,9 @@ export function Settings() {
|
||||
setProxyEnabled(proxyEnabledDraft);
|
||||
|
||||
toast.success(t('gateway.proxySaved'));
|
||||
trackUiEvent('settings.proxy_saved', { enabled: proxyEnabledDraft });
|
||||
} catch (error) {
|
||||
toast.error(`${t('gateway.proxySaveFailed')}: ${String(error)}`);
|
||||
toast.error(`${t('gateway.proxySaveFailed')}: ${toUserMessage(error)}`);
|
||||
} finally {
|
||||
setSavingProxy(false);
|
||||
}
|
||||
@@ -438,7 +457,22 @@ export function Settings() {
|
||||
</div>
|
||||
|
||||
{devModeUnlocked && (
|
||||
<>
|
||||
<div className="rounded-md border border-border/60 p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowAdvancedProxy((prev) => !prev)}
|
||||
>
|
||||
{showAdvancedProxy ? (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{showAdvancedProxy ? t('gateway.hideAdvancedProxy') : t('gateway.showAdvancedProxy')}
|
||||
</Button>
|
||||
{showAdvancedProxy && (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
|
||||
<Input
|
||||
@@ -477,7 +511,9 @@ export function Settings() {
|
||||
{t('gateway.proxyAllServerHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -585,6 +621,34 @@ export function Settings() {
|
||||
<CardDescription>{t('developer.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('advanced.transport.label')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('advanced.transport.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{transportOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={gatewayTransportPreference === option.value ? 'default' : 'outline'}
|
||||
className="justify-between"
|
||||
onClick={() => {
|
||||
setGatewayTransportPreference(option.value);
|
||||
toast.success(t('advanced.transport.saved'));
|
||||
}}
|
||||
>
|
||||
<span>{t(option.labelKey)}</span>
|
||||
<span className="text-xs opacity-80">{t(option.descKey)}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('developer.console')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -146,6 +147,18 @@ export function Setup() {
|
||||
}
|
||||
}, [safeStepIndex, providerConfigured, runtimeChecksPassed]);
|
||||
|
||||
// Keep setup flow linear: advance to provider step automatically
|
||||
// once runtime checks become healthy.
|
||||
useEffect(() => {
|
||||
if (safeStepIndex !== STEP.RUNTIME || !runtimeChecksPassed) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentStep(STEP.PROVIDER);
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [runtimeChecksPassed, safeStepIndex]);
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLastStep) {
|
||||
// Complete setup
|
||||
@@ -382,7 +395,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
// Check OpenClaw package status
|
||||
try {
|
||||
const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as {
|
||||
const openclawStatus = await invokeIpc('openclaw:status') as {
|
||||
packageExists: boolean;
|
||||
isBuilt: boolean;
|
||||
dir: string;
|
||||
@@ -526,7 +539,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
|
||||
const logs = await invokeIpc('log:readFile', 100) as string;
|
||||
setLogContent(logs);
|
||||
setShowLogs(true);
|
||||
} catch {
|
||||
@@ -537,9 +550,9 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
const handleOpenLogDir = async () => {
|
||||
try {
|
||||
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string;
|
||||
const logDir = await invokeIpc('log:getDir') as string;
|
||||
if (logDir) {
|
||||
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir);
|
||||
await invokeIpc('shell:showItemInFolder', logDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -727,7 +740,7 @@ function ProviderContent({
|
||||
|
||||
if (selectedProvider) {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider);
|
||||
await invokeIpc('provider:setDefault', selectedProvider);
|
||||
} catch (error) {
|
||||
console.error('Failed to set default provider:', error);
|
||||
}
|
||||
@@ -761,7 +774,7 @@ function ProviderContent({
|
||||
if (!selectedProvider) return;
|
||||
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>;
|
||||
const list = await invokeIpc('provider:list') as Array<{ type: string }>;
|
||||
const existingTypes = new Set(list.map(l => l.type));
|
||||
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
@@ -780,7 +793,7 @@ function ProviderContent({
|
||||
setOauthError(null);
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider);
|
||||
await invokeIpc('provider:requestOAuth', selectedProvider);
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
@@ -791,7 +804,7 @@ function ProviderContent({
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
|
||||
await invokeIpc('provider:cancelOAuth');
|
||||
};
|
||||
|
||||
// On mount, try to restore previously configured provider
|
||||
@@ -799,8 +812,8 @@ function ProviderContent({
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null;
|
||||
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await invokeIpc('provider:getDefault') as string | null;
|
||||
const setupProviderTypes = new Set<string>(providers.map((p) => p.id));
|
||||
const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type));
|
||||
const preferred =
|
||||
@@ -813,7 +826,7 @@ function ProviderContent({
|
||||
const typeInfo = providers.find((p) => p.id === preferred.type);
|
||||
const requiresKey = typeInfo?.requiresApiKey ?? false;
|
||||
onConfiguredChange(!requiresKey || preferred.hasKey);
|
||||
const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', preferred.id) as string | null;
|
||||
const storedKey = await invokeIpc('provider:getApiKey', preferred.id) as string | null;
|
||||
if (storedKey) {
|
||||
onApiKeyChange(storedKey);
|
||||
}
|
||||
@@ -835,8 +848,8 @@ function ProviderContent({
|
||||
(async () => {
|
||||
if (!selectedProvider) return;
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null;
|
||||
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await invokeIpc('provider:getDefault') as string | null;
|
||||
const sameType = list.filter((p) => p.type === selectedProvider);
|
||||
const preferredInstance =
|
||||
(defaultId && sameType.find((p) => p.id === defaultId))
|
||||
@@ -845,11 +858,11 @@ function ProviderContent({
|
||||
const providerIdForLoad = preferredInstance?.id || selectedProvider;
|
||||
setSelectedProviderConfigId(providerIdForLoad);
|
||||
|
||||
const savedProvider = await window.electron.ipcRenderer.invoke(
|
||||
const savedProvider = await invokeIpc(
|
||||
'provider:get',
|
||||
providerIdForLoad
|
||||
) as { baseUrl?: string; model?: string } | null;
|
||||
const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', providerIdForLoad) as string | null;
|
||||
const storedKey = await invokeIpc('provider:getApiKey', providerIdForLoad) as string | null;
|
||||
if (!cancelled) {
|
||||
if (storedKey) {
|
||||
onApiKeyChange(storedKey);
|
||||
@@ -906,7 +919,7 @@ function ProviderContent({
|
||||
if (!selectedProvider) return;
|
||||
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>;
|
||||
const list = await invokeIpc('provider:list') as Array<{ type: string }>;
|
||||
const existingTypes = new Set(list.map(l => l.type));
|
||||
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
@@ -927,7 +940,7 @@ function ProviderContent({
|
||||
// Validate key if the provider requires one and a key was entered
|
||||
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
|
||||
if (isApiKeyRequired && apiKey) {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'provider:validateKey',
|
||||
selectedProviderConfigId || selectedProvider,
|
||||
apiKey,
|
||||
@@ -961,7 +974,7 @@ function ProviderContent({
|
||||
const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey);
|
||||
|
||||
// Save provider config + API key, then set as default
|
||||
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||
const saveResult = await invokeIpc(
|
||||
'provider:save',
|
||||
{
|
||||
id: providerIdForSave,
|
||||
@@ -980,7 +993,7 @@ function ProviderContent({
|
||||
throw new Error(saveResult.error || 'Failed to save provider config');
|
||||
}
|
||||
|
||||
const defaultResult = await window.electron.ipcRenderer.invoke(
|
||||
const defaultResult = await invokeIpc(
|
||||
'provider:setDefault',
|
||||
providerIdForSave
|
||||
) as { success: boolean; error?: string };
|
||||
@@ -1275,7 +1288,7 @@ function ProviderContent({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
|
||||
onClick={() => invokeIpc('shell:openExternal', oauthData.verificationUri)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Login Page
|
||||
@@ -1363,7 +1376,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
|
||||
setOverallProgress(10);
|
||||
|
||||
// Step 2: Call the backend to install uv and setup Python
|
||||
const result = await window.electron.ipcRenderer.invoke('uv:install-all') as {
|
||||
const result = await invokeIpc('uv:install-all') as {
|
||||
success: boolean;
|
||||
error?: string
|
||||
};
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useSkillsStore } from '@/stores/skills';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { toast } from 'sonner';
|
||||
import type { Skill, MarketplaceSkill } from '@/types/skill';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -84,14 +86,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
|
||||
|
||||
const handleOpenClawhub = async () => {
|
||||
if (skill.slug) {
|
||||
await window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
await invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditor = async () => {
|
||||
if (skill.slug) {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:openSkillReadme', skill.slug);
|
||||
if (result.success) {
|
||||
toast.success(t('toast.openedEditor'));
|
||||
} else {
|
||||
@@ -134,7 +136,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Use direct file access instead of Gateway RPC for reliability
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>(
|
||||
'skill:updateConfig',
|
||||
{
|
||||
skillKey: skill.id,
|
||||
@@ -381,7 +383,7 @@ function MarketplaceSkillCard({
|
||||
onUninstall
|
||||
}: MarketplaceSkillCardProps) {
|
||||
const handleCardClick = () => {
|
||||
window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
void invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -600,6 +602,35 @@ export function Skills() {
|
||||
marketplace: skills.filter(s => !s.isBundled).length,
|
||||
};
|
||||
|
||||
const bulkToggleVisible = useCallback(async (enable: boolean) => {
|
||||
const candidates = filteredSkills.filter((skill) => !skill.isCore && skill.enabled !== enable);
|
||||
if (candidates.length === 0) {
|
||||
toast.info(enable ? t('toast.noBatchEnableTargets') : t('toast.noBatchDisableTargets'));
|
||||
return;
|
||||
}
|
||||
|
||||
let succeeded = 0;
|
||||
for (const skill of candidates) {
|
||||
try {
|
||||
if (enable) {
|
||||
await enableSkill(skill.id);
|
||||
} else {
|
||||
await disableSkill(skill.id);
|
||||
}
|
||||
succeeded += 1;
|
||||
} catch {
|
||||
// Continue to next skill and report final summary.
|
||||
}
|
||||
}
|
||||
|
||||
trackUiEvent('skills.batch_toggle', { enable, total: candidates.length, succeeded });
|
||||
if (succeeded === candidates.length) {
|
||||
toast.success(enable ? t('toast.batchEnabled', { count: succeeded }) : t('toast.batchDisabled', { count: succeeded }));
|
||||
return;
|
||||
}
|
||||
toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length }));
|
||||
}, [disableSkill, enableSkill, filteredSkills, t]);
|
||||
|
||||
// Handle toggle
|
||||
const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
|
||||
try {
|
||||
@@ -619,11 +650,11 @@ export function Skills() {
|
||||
|
||||
const handleOpenSkillsFolder = useCallback(async () => {
|
||||
try {
|
||||
const skillsDir = await window.electron.ipcRenderer.invoke('openclaw:getSkillsDir') as string;
|
||||
const skillsDir = await invokeIpc<string>('openclaw:getSkillsDir');
|
||||
if (!skillsDir) {
|
||||
throw new Error('Skills directory not available');
|
||||
}
|
||||
const result = await window.electron.ipcRenderer.invoke('shell:openPath', skillsDir) as string;
|
||||
const result = await invokeIpc<string>('shell:openPath', skillsDir);
|
||||
if (result) {
|
||||
// shell.openPath returns an error string if the path doesn't exist
|
||||
if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) {
|
||||
@@ -640,7 +671,7 @@ export function Skills() {
|
||||
const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills');
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.invoke('openclaw:getSkillsDir')
|
||||
invokeIpc<string>('openclaw:getSkillsDir')
|
||||
.then((dir) => setSkillsDirPath(dir as string))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
@@ -804,6 +835,20 @@ export function Skills() {
|
||||
<Globe className="h-3 w-3" />
|
||||
{t('filter.marketplace', { count: sourceStats.marketplace })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void bulkToggleVisible(true); }}
|
||||
>
|
||||
{t('actions.enableVisible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void bulkToggleVisible(false); }}
|
||||
>
|
||||
{t('actions.disableVisible')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Channel, ChannelType } from '../types/channel';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
interface AddChannelParams {
|
||||
type: ChannelType;
|
||||
@@ -17,7 +18,7 @@ interface ChannelsState {
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchChannels: () => Promise<void>;
|
||||
fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise<void>;
|
||||
addChannel: (params: AddChannelParams) => Promise<Channel>;
|
||||
deleteChannel: (channelId: string) => Promise<void>;
|
||||
connectChannel: (channelId: string) => Promise<void>;
|
||||
@@ -33,13 +34,17 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchChannels: async () => {
|
||||
set({ loading: true, error: null });
|
||||
fetchChannels: async (options) => {
|
||||
const probe = options?.probe ?? false;
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
set({ loading: true, error: null });
|
||||
}
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.status',
|
||||
{ probe: true }
|
||||
{ probe }
|
||||
) as {
|
||||
success: boolean;
|
||||
result?: {
|
||||
@@ -126,20 +131,20 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
|
||||
set({ channels, loading: false });
|
||||
set((state) => ({ channels, loading: silent ? state.loading : false }));
|
||||
} else {
|
||||
// Gateway not available - try to show channels from local config
|
||||
set({ channels: [], loading: false });
|
||||
set((state) => ({ channels: [], loading: silent ? state.loading : false }));
|
||||
}
|
||||
} catch {
|
||||
// Gateway not connected, show empty
|
||||
set({ channels: [], loading: false });
|
||||
set((state) => ({ channels: [], loading: silent ? state.loading : false }));
|
||||
}
|
||||
},
|
||||
|
||||
addChannel: async (params) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.add',
|
||||
params
|
||||
@@ -184,13 +189,13 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
|
||||
try {
|
||||
// Delete the channel configuration from openclaw.json
|
||||
await window.electron.ipcRenderer.invoke('channel:deleteConfig', channelType);
|
||||
await invokeIpc('channel:deleteConfig', channelType);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel config:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.delete',
|
||||
{ channelId: channelType }
|
||||
@@ -211,7 +216,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
updateChannel(channelId, { status: 'connecting', error: undefined });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.connect',
|
||||
{ channelId }
|
||||
@@ -231,7 +236,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
const { updateChannel } = get();
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.disconnect',
|
||||
{ channelId }
|
||||
@@ -244,7 +249,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
},
|
||||
|
||||
requestQrCode: async (channelType) => {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.requestQr',
|
||||
{ type: channelType }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Communicates with OpenClaw Gateway via gateway:rpc IPC.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -596,7 +597,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
|
||||
if (needPreview.length === 0) return false;
|
||||
|
||||
try {
|
||||
const thumbnails = await window.electron.ipcRenderer.invoke(
|
||||
const thumbnails = await invokeIpc(
|
||||
'media:getThumbnails',
|
||||
needPreview,
|
||||
) as Record<string, { preview: string | null; fileSize: number }>;
|
||||
@@ -928,7 +929,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
loadSessions: async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'sessions.list',
|
||||
{}
|
||||
@@ -1001,7 +1002,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
void Promise.all(
|
||||
sessionsToLabel.map(async (session) => {
|
||||
try {
|
||||
const r = await window.electron.ipcRenderer.invoke(
|
||||
const r = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
@@ -1077,7 +1078,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||
// sessions.list and token-usage queries both skip it automatically.
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('session:delete', key) as {
|
||||
const result = await invokeIpc('session:delete', key) as {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
@@ -1185,7 +1186,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (!quiet) set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ sessionKey: currentSessionKey, limit: 200 }
|
||||
@@ -1425,7 +1426,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const CHAT_SEND_TIMEOUT_MS = 120_000;
|
||||
|
||||
if (hasMedia) {
|
||||
result = await window.electron.ipcRenderer.invoke(
|
||||
result = await invokeIpc(
|
||||
'chat:sendWithMedia',
|
||||
{
|
||||
sessionKey: currentSessionKey,
|
||||
@@ -1440,7 +1441,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
) as { success: boolean; result?: { runId?: string }; error?: string };
|
||||
} else {
|
||||
result = await window.electron.ipcRenderer.invoke(
|
||||
result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.send',
|
||||
{
|
||||
@@ -1477,7 +1478,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
set({ streamingTools: [] });
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.abort',
|
||||
{ sessionKey: currentSessionKey },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
interface CronState {
|
||||
jobs: CronJob[];
|
||||
@@ -29,7 +30,7 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
|
||||
const result = await invokeIpc<CronJob[]>('cron:list');
|
||||
set({ jobs: result, loading: false });
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
@@ -38,7 +39,7 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
createJob: async (input) => {
|
||||
try {
|
||||
const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob;
|
||||
const job = await invokeIpc<CronJob>('cron:create', input);
|
||||
set((state) => ({ jobs: [...state.jobs, job] }));
|
||||
return job;
|
||||
} catch (error) {
|
||||
@@ -49,7 +50,7 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
updateJob: async (id, input) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:update', id, input);
|
||||
await invokeIpc('cron:update', id, input);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
|
||||
@@ -63,7 +64,7 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
deleteJob: async (id) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:delete', id);
|
||||
await invokeIpc('cron:delete', id);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.filter((job) => job.id !== id),
|
||||
}));
|
||||
@@ -75,7 +76,7 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
toggleJob: async (id, enabled) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled);
|
||||
await invokeIpc('cron:toggle', id, enabled);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, enabled } : job
|
||||
@@ -89,11 +90,11 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
triggerJob: async (id) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
||||
const result = await invokeIpc<unknown>('cron:trigger', id);
|
||||
console.log('Cron trigger result:', result);
|
||||
// Refresh jobs after trigger to update lastRun/nextRun state
|
||||
try {
|
||||
const jobs = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
|
||||
const jobs = await invokeIpc<CronJob[]>('cron:list');
|
||||
set({ jobs });
|
||||
} catch {
|
||||
// Ignore refresh error
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
let gatewayInitPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -49,7 +50,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
gatewayInitPromise = (async () => {
|
||||
try {
|
||||
// Get initial status first
|
||||
const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus;
|
||||
const status = await invokeIpc('gateway:status') as GatewayStatus;
|
||||
set({ status, isInitialized: true });
|
||||
|
||||
// Listen for status changes
|
||||
@@ -197,7 +198,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
start: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' }, lastError: null });
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string };
|
||||
const result = await invokeIpc('gateway:start') as { success: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
set({
|
||||
@@ -215,7 +216,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
|
||||
stop: async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('gateway:stop');
|
||||
await invokeIpc('gateway:stop');
|
||||
set({ status: { ...get().status, state: 'stopped' }, lastError: null });
|
||||
} catch (error) {
|
||||
console.error('Failed to stop Gateway:', error);
|
||||
@@ -226,7 +227,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
restart: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' }, lastError: null });
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:restart') as { success: boolean; error?: string };
|
||||
const result = await invokeIpc('gateway:restart') as { success: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
set({
|
||||
@@ -244,7 +245,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:health') as {
|
||||
const result = await invokeIpc('gateway:health') as {
|
||||
success: boolean;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -267,7 +268,7 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
},
|
||||
|
||||
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:rpc', method, params, timeoutMs) as {
|
||||
const result = await invokeIpc('gateway:rpc', method, params, timeoutMs) as {
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
// Re-export types for consumers that imported from here
|
||||
export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
|
||||
@@ -45,8 +46,8 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const providers = await window.electron.ipcRenderer.invoke('provider:list') as ProviderWithKeyInfo[];
|
||||
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null;
|
||||
const providers = await invokeIpc<ProviderWithKeyInfo[]>('provider:list');
|
||||
const defaultId = await invokeIpc<string | null>('provider:getDefault');
|
||||
|
||||
set({
|
||||
providers,
|
||||
@@ -66,7 +67,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await window.electron.ipcRenderer.invoke('provider:save', fullConfig, apiKey) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', fullConfig, apiKey);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save provider');
|
||||
@@ -95,7 +96,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await window.electron.ipcRenderer.invoke('provider:save', updatedConfig, apiKey) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', updatedConfig, apiKey);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider');
|
||||
@@ -111,7 +112,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
deleteProvider: async (providerId) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('provider:delete', providerId) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:delete', providerId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete provider');
|
||||
@@ -127,7 +128,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
setApiKey: async (providerId, apiKey) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('provider:setApiKey', providerId, apiKey) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setApiKey', providerId, apiKey);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set API key');
|
||||
@@ -143,12 +144,12 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
updateProviderWithKey: async (providerId, updates, apiKey) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>(
|
||||
'provider:updateWithKey',
|
||||
providerId,
|
||||
updates,
|
||||
apiKey
|
||||
) as { success: boolean; error?: string };
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider');
|
||||
@@ -163,7 +164,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
deleteApiKey: async (providerId) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('provider:deleteApiKey', providerId) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:deleteApiKey', providerId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete API key');
|
||||
@@ -179,7 +180,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
setDefaultProvider: async (providerId) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('provider:setDefault', providerId) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setDefault', providerId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set default provider');
|
||||
@@ -194,12 +195,12 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
validateApiKey: async (providerId, apiKey, options) => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc<{ valid: boolean; error?: string }>(
|
||||
'provider:validateKey',
|
||||
providerId,
|
||||
apiKey,
|
||||
options
|
||||
) as { valid: boolean; error?: string };
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { valid: false, error: String(error) };
|
||||
@@ -208,7 +209,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
getApiKey: async (providerId) => {
|
||||
try {
|
||||
return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null;
|
||||
return await invokeIpc<string | null>('provider:getApiKey', providerId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import i18n from '@/i18n';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
||||
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
||||
|
||||
interface SettingsState {
|
||||
// General
|
||||
@@ -25,6 +27,7 @@ interface SettingsState {
|
||||
proxyHttpsServer: string;
|
||||
proxyAllServer: string;
|
||||
proxyBypassRules: string;
|
||||
gatewayTransportPreference: GatewayTransportPreference;
|
||||
|
||||
// Update
|
||||
updateChannel: UpdateChannel;
|
||||
@@ -52,6 +55,7 @@ interface SettingsState {
|
||||
setProxyHttpsServer: (value: string) => void;
|
||||
setProxyAllServer: (value: string) => void;
|
||||
setProxyBypassRules: (value: string) => void;
|
||||
setGatewayTransportPreference: (value: GatewayTransportPreference) => void;
|
||||
setUpdateChannel: (channel: UpdateChannel) => void;
|
||||
setAutoCheckUpdate: (value: boolean) => void;
|
||||
setAutoDownloadUpdate: (value: boolean) => void;
|
||||
@@ -79,6 +83,7 @@ const defaultSettings = {
|
||||
proxyHttpsServer: '',
|
||||
proxyAllServer: '',
|
||||
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||
gatewayTransportPreference: 'ws-first' as GatewayTransportPreference,
|
||||
updateChannel: 'stable' as UpdateChannel,
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: false,
|
||||
@@ -94,7 +99,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
|
||||
init: async () => {
|
||||
try {
|
||||
const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial<typeof defaultSettings>;
|
||||
const settings = await invokeIpc<Partial<typeof defaultSettings>>('settings:getAll');
|
||||
set((state) => ({ ...state, ...settings }));
|
||||
if (settings.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
@@ -106,17 +111,21 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void window.electron.ipcRenderer.invoke('settings:set', 'language', language).catch(() => {}); },
|
||||
setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void invokeIpc('settings:set', 'language', language).catch(() => {}); },
|
||||
setStartMinimized: (startMinimized) => set({ startMinimized }),
|
||||
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
||||
setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); },
|
||||
setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); },
|
||||
setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void invokeIpc('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); },
|
||||
setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void invokeIpc('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); },
|
||||
setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }),
|
||||
setProxyServer: (proxyServer) => set({ proxyServer }),
|
||||
setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }),
|
||||
setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }),
|
||||
setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }),
|
||||
setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }),
|
||||
setGatewayTransportPreference: (gatewayTransportPreference) => {
|
||||
set({ gatewayTransportPreference });
|
||||
void invokeIpc('settings:set', 'gatewayTransportPreference', gatewayTransportPreference).catch(() => {});
|
||||
},
|
||||
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
||||
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
||||
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Skill, MarketplaceSkill } from '../types/skill';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
type GatewaySkillStatus = {
|
||||
skillKey: string;
|
||||
@@ -70,20 +71,20 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
}
|
||||
try {
|
||||
// 1. Fetch from Gateway (running skills)
|
||||
const gatewayResult = await window.electron.ipcRenderer.invoke(
|
||||
const gatewayResult = await invokeIpc<GatewayRpcResponse<GatewaySkillsStatusResult>>(
|
||||
'gateway:rpc',
|
||||
'skills.status'
|
||||
) as GatewayRpcResponse<GatewaySkillsStatusResult>;
|
||||
);
|
||||
|
||||
// 2. Fetch from ClawHub (installed on disk)
|
||||
const clawhubResult = await window.electron.ipcRenderer.invoke(
|
||||
const clawhubResult = await invokeIpc<{ success: boolean; results?: ClawHubListResult[]; error?: string }>(
|
||||
'clawhub:list'
|
||||
) as { success: boolean; results?: ClawHubListResult[]; error?: string };
|
||||
);
|
||||
|
||||
// 3. Fetch configurations directly from Electron (since Gateway doesn't return them)
|
||||
const configResult = await window.electron.ipcRenderer.invoke(
|
||||
const configResult = await invokeIpc<Record<string, { apiKey?: string; env?: Record<string, string> }>>(
|
||||
'skill:getAllConfigs'
|
||||
) as Record<string, { apiKey?: string; env?: Record<string, string> }>;
|
||||
);
|
||||
|
||||
let combinedSkills: Skill[] = [];
|
||||
const currentSkills = get().skills;
|
||||
@@ -155,7 +156,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
searchSkills: async (query: string) => {
|
||||
set({ searching: true, searchError: null });
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('clawhub:search', { query });
|
||||
if (result.success) {
|
||||
set({ searchResults: result.results || [] });
|
||||
} else {
|
||||
@@ -177,7 +178,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
installSkill: async (slug: string, version?: string) => {
|
||||
set((state) => ({ installing: { ...state.installing, [slug]: true } }));
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('clawhub:install', { slug, version }) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:install', { slug, version });
|
||||
if (!result.success) {
|
||||
if (result.error?.includes('Timeout')) {
|
||||
throw new Error('installTimeoutError');
|
||||
@@ -204,7 +205,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
uninstallSkill: async (slug: string) => {
|
||||
set((state) => ({ installing: { ...state.installing, [slug]: true } }));
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('clawhub:uninstall', { slug }) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:uninstall', { slug });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Uninstall failed');
|
||||
}
|
||||
@@ -226,11 +227,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
const { updateSkill } = get();
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
|
||||
'gateway:rpc',
|
||||
'skills.update',
|
||||
{ skillKey: skillId, enabled: true }
|
||||
) as GatewayRpcResponse<unknown>;
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: true });
|
||||
@@ -252,11 +253,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
|
||||
'gateway:rpc',
|
||||
'skills.update',
|
||||
{ skillKey: skillId, enabled: false }
|
||||
) as GatewayRpcResponse<unknown>;
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: false });
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { useSettingsStore } from './settings';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
@@ -63,7 +64,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
|
||||
// Get current version
|
||||
try {
|
||||
const version = await window.electron.ipcRenderer.invoke('update:version');
|
||||
const version = await invokeIpc<string>('update:version');
|
||||
set({ currentVersion: version as string });
|
||||
} catch (error) {
|
||||
console.error('Failed to get version:', error);
|
||||
@@ -71,12 +72,12 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
|
||||
// Get current status
|
||||
try {
|
||||
const status = await window.electron.ipcRenderer.invoke('update:status') as {
|
||||
const status = await invokeIpc<{
|
||||
status: UpdateStatus;
|
||||
info?: UpdateInfo;
|
||||
progress?: ProgressInfo;
|
||||
error?: string;
|
||||
};
|
||||
}>('update:status');
|
||||
set({
|
||||
status: status.status,
|
||||
updateInfo: status.info || null,
|
||||
@@ -117,7 +118,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
|
||||
// Sync auto-download preference to the main process
|
||||
if (autoDownloadUpdate) {
|
||||
window.electron.ipcRenderer.invoke('update:setAutoDownload', true).catch(() => {});
|
||||
invokeIpc('update:setAutoDownload', true).catch(() => {});
|
||||
}
|
||||
|
||||
// Auto-check for updates on startup (respects user toggle)
|
||||
@@ -133,7 +134,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
window.electron.ipcRenderer.invoke('update:check'),
|
||||
invokeIpc('update:check'),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000))
|
||||
]) as {
|
||||
success: boolean;
|
||||
@@ -172,10 +173,10 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
set({ status: 'downloading', error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('update:download') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}>('update:download');
|
||||
|
||||
if (!result.success) {
|
||||
set({ status: 'error', error: result.error || 'Failed to download update' });
|
||||
@@ -186,12 +187,12 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
},
|
||||
|
||||
installUpdate: () => {
|
||||
window.electron.ipcRenderer.invoke('update:install');
|
||||
void invokeIpc('update:install');
|
||||
},
|
||||
|
||||
cancelAutoInstall: async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('update:cancelAutoInstall');
|
||||
await invokeIpc('update:cancelAutoInstall');
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel auto-install:', error);
|
||||
}
|
||||
@@ -199,7 +200,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
|
||||
setChannel: async (channel) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('update:setChannel', channel);
|
||||
await invokeIpc('update:setChannel', channel);
|
||||
} catch (error) {
|
||||
console.error('Failed to set update channel:', error);
|
||||
}
|
||||
@@ -207,7 +208,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
|
||||
setAutoDownload: async (enable) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('update:setAutoDownload', enable);
|
||||
await invokeIpc('update:setAutoDownload', enable);
|
||||
} catch (error) {
|
||||
console.error('Failed to set auto-download:', error);
|
||||
}
|
||||
|
||||
153
tests/unit/api-client.test.ts
Normal file
153
tests/unit/api-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
tests/unit/feedback-state.test.tsx
Normal file
30
tests/unit/feedback-state.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user