feat(core): initialize project skeleton with Electron + React + TypeScript

Set up the complete project foundation for ClawX, a graphical AI assistant:

- Electron main process with IPC handlers, menu, tray, and gateway management
- React renderer with routing, layout components, and page scaffolding
- Zustand state management for gateway, settings, channels, skills, chat, and cron
- shadcn/ui components with Tailwind CSS and CSS variable theming
- Build tooling with Vite, electron-builder, and TypeScript configuration
- Testing setup with Vitest and Playwright
- Development configurations (ESLint, Prettier, gitignore, env example)
This commit is contained in:
Haze
2026-02-05 23:09:17 +08:00
Unverified
parent 9442e5f77a
commit b8ab0208d0
71 changed files with 14086 additions and 3 deletions

186
electron/gateway/client.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* Gateway WebSocket Client
* Provides a typed interface for Gateway RPC calls
*/
import { GatewayManager } from './manager';
/**
* Channel types supported by OpenClaw
*/
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat';
/**
* Channel status
*/
export interface Channel {
id: string;
type: ChannelType;
name: string;
status: 'connected' | 'disconnected' | 'connecting' | 'error';
lastActivity?: string;
error?: string;
}
/**
* Skill definition
*/
export interface Skill {
id: string;
name: string;
description: string;
enabled: boolean;
category?: string;
icon?: string;
configurable?: boolean;
}
/**
* Chat message
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
channel?: string;
toolCalls?: ToolCall[];
}
/**
* Tool call in a message
*/
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
status: 'pending' | 'running' | 'completed' | 'error';
}
/**
* Gateway Client
* Typed wrapper around GatewayManager for making RPC calls
*/
export class GatewayClient {
constructor(private manager: GatewayManager) {}
// ==================== Channel Methods ====================
/**
* List all channels
*/
async listChannels(): Promise<Channel[]> {
return this.manager.rpc<Channel[]>('channels.list');
}
/**
* Get channel by ID
*/
async getChannel(channelId: string): Promise<Channel> {
return this.manager.rpc<Channel>('channels.get', { channelId });
}
/**
* Connect a channel
*/
async connectChannel(channelId: string): Promise<void> {
return this.manager.rpc<void>('channels.connect', { channelId });
}
/**
* Disconnect a channel
*/
async disconnectChannel(channelId: string): Promise<void> {
return this.manager.rpc<void>('channels.disconnect', { channelId });
}
/**
* Get QR code for channel connection (e.g., WhatsApp)
*/
async getChannelQRCode(channelType: ChannelType): Promise<string> {
return this.manager.rpc<string>('channels.getQRCode', { channelType });
}
// ==================== Skill Methods ====================
/**
* List all skills
*/
async listSkills(): Promise<Skill[]> {
return this.manager.rpc<Skill[]>('skills.list');
}
/**
* Enable a skill
*/
async enableSkill(skillId: string): Promise<void> {
return this.manager.rpc<void>('skills.enable', { skillId });
}
/**
* Disable a skill
*/
async disableSkill(skillId: string): Promise<void> {
return this.manager.rpc<void>('skills.disable', { skillId });
}
/**
* Get skill configuration
*/
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
}
/**
* Update skill configuration
*/
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
}
// ==================== Chat Methods ====================
/**
* Send a chat message
*/
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
}
/**
* Get chat history
*/
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
}
/**
* Clear chat history
*/
async clearChatHistory(): Promise<void> {
return this.manager.rpc<void>('chat.clear');
}
// ==================== System Methods ====================
/**
* Get Gateway health status
*/
async getHealth(): Promise<{ status: string; uptime: number }> {
return this.manager.rpc<{ status: string; uptime: number }>('system.health');
}
/**
* Get Gateway configuration
*/
async getConfig(): Promise<Record<string, unknown>> {
return this.manager.rpc<Record<string, unknown>>('system.config');
}
/**
* Update Gateway configuration
*/
async updateConfig(config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('system.updateConfig', config);
}
}

370
electron/gateway/manager.ts Normal file
View File

@@ -0,0 +1,370 @@
/**
* Gateway Process Manager
* Manages the OpenClaw Gateway process lifecycle
*/
import { spawn, ChildProcess, exec } from 'child_process';
import { EventEmitter } from 'events';
import WebSocket from 'ws';
import { promisify } from 'util';
import { PORTS } from '../utils/config';
const execAsync = promisify(exec);
/**
* Gateway connection status
*/
export interface GatewayStatus {
state: 'stopped' | 'starting' | 'running' | 'error';
port: number;
pid?: number;
uptime?: number;
error?: string;
connectedAt?: number;
}
/**
* Gateway Manager Events
*/
export interface GatewayManagerEvents {
status: (status: GatewayStatus) => void;
message: (message: unknown) => void;
exit: (code: number | null) => void;
error: (error: Error) => void;
}
/**
* Gateway Manager
* Handles starting, stopping, and communicating with the OpenClaw Gateway
*/
export class GatewayManager extends EventEmitter {
private process: ChildProcess | null = null;
private ws: WebSocket | null = null;
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
private reconnectTimer: NodeJS.Timeout | null = null;
private pingInterval: NodeJS.Timeout | null = null;
private pendingRequests: Map<string, {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}> = new Map();
constructor() {
super();
}
/**
* Get current Gateway status
*/
getStatus(): GatewayStatus {
return { ...this.status };
}
/**
* Start Gateway process
*/
async start(): Promise<void> {
if (this.status.state === 'running') {
return;
}
this.setStatus({ state: 'starting' });
try {
// Check if Gateway is already running
const existing = await this.findExistingGateway();
if (existing) {
console.log('Found existing Gateway on port', existing.port);
await this.connect(existing.port);
return;
}
// Start new Gateway process
await this.startProcess();
// Wait for Gateway to be ready
await this.waitForReady();
// Connect WebSocket
await this.connect(this.status.port);
} catch (error) {
this.setStatus({ state: 'error', error: String(error) });
throw error;
}
}
/**
* Stop Gateway process
*/
async stop(): Promise<void> {
// Clear timers
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
// Close WebSocket
if (this.ws) {
this.ws.close();
this.ws = null;
}
// Kill process
if (this.process) {
this.process.kill();
this.process = null;
}
// Reject all pending requests
for (const [id, request] of this.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Gateway stopped'));
}
this.pendingRequests.clear();
this.setStatus({ state: 'stopped' });
}
/**
* Make an RPC call to the Gateway
*/
async rpc<T>(method: string, params?: unknown): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Gateway not connected'));
return;
}
const id = crypto.randomUUID();
// Set timeout for request
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`RPC timeout: ${method}`));
}, 30000);
// Store pending request
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
// Send request
const request = {
jsonrpc: '2.0',
id,
method,
params,
};
this.ws.send(JSON.stringify(request));
});
}
/**
* Find existing Gateway process
*/
private async findExistingGateway(): Promise<{ port: number } | null> {
try {
// Try to connect to default port
const port = PORTS.OPENCLAW_GATEWAY;
const response = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(2000),
});
if (response.ok) {
return { port };
}
} catch {
// Gateway not running
}
return null;
}
/**
* Start Gateway process
*/
private async startProcess(): Promise<void> {
return new Promise((resolve, reject) => {
// Find openclaw command
const command = 'openclaw';
const args = ['gateway', 'run', '--port', String(this.status.port)];
this.process = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
shell: true,
});
this.process.on('error', (error) => {
console.error('Gateway process error:', error);
reject(error);
});
this.process.on('exit', (code) => {
console.log('Gateway process exited with code:', code);
this.emit('exit', code);
if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' });
// Attempt to reconnect
this.scheduleReconnect();
}
});
// Log stdout
this.process.stdout?.on('data', (data) => {
console.log('Gateway:', data.toString());
});
// Log stderr
this.process.stderr?.on('data', (data) => {
console.error('Gateway error:', data.toString());
});
// Store PID
if (this.process.pid) {
this.setStatus({ pid: this.process.pid });
}
resolve();
});
}
/**
* Wait for Gateway to be ready
*/
private async waitForReady(retries = 30, interval = 1000): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(`http://localhost:${this.status.port}/health`, {
signal: AbortSignal.timeout(1000),
});
if (response.ok) {
return;
}
} catch {
// Gateway not ready yet
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error('Gateway failed to start');
}
/**
* Connect WebSocket to Gateway
*/
private async connect(port: number): Promise<void> {
return new Promise((resolve, reject) => {
const wsUrl = `ws://localhost:${port}/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
console.log('WebSocket connected to Gateway');
this.setStatus({
state: 'running',
port,
connectedAt: Date.now(),
});
this.startPing();
resolve();
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
});
this.ws.on('close', () => {
console.log('WebSocket disconnected');
if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' });
this.scheduleReconnect();
}
});
this.ws.on('error', (error) => {
console.error('WebSocket error:', error);
reject(error);
});
});
}
/**
* Handle incoming WebSocket message
*/
private handleMessage(message: { id?: string; result?: unknown; error?: unknown }): void {
// Check if this is a response to a pending request
if (message.id && this.pendingRequests.has(message.id)) {
const request = this.pendingRequests.get(message.id)!;
clearTimeout(request.timeout);
this.pendingRequests.delete(message.id);
if (message.error) {
request.reject(new Error(String(message.error)));
} else {
request.resolve(message.result);
}
return;
}
// Emit message for other handlers
this.emit('message', message);
}
/**
* Start ping interval to keep connection alive
*/
private startPing(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
}
}, 30000);
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.start();
} catch (error) {
console.error('Reconnection failed:', error);
this.scheduleReconnect();
}
}, 5000);
}
/**
* Update status and emit event
*/
private setStatus(update: Partial<GatewayStatus>): void {
this.status = { ...this.status, ...update };
this.emit('status', this.status);
}
}

View File

@@ -0,0 +1,200 @@
/**
* Gateway Protocol Definitions
* JSON-RPC 2.0 protocol types for Gateway communication
*/
/**
* JSON-RPC 2.0 Request
*/
export interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: unknown;
}
/**
* JSON-RPC 2.0 Response
*/
export interface JsonRpcResponse<T = unknown> {
jsonrpc: '2.0';
id: string | number;
result?: T;
error?: JsonRpcError;
}
/**
* JSON-RPC 2.0 Error
*/
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
/**
* JSON-RPC 2.0 Notification (no id, no response expected)
*/
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: unknown;
}
/**
* Standard JSON-RPC 2.0 Error Codes
*/
export enum JsonRpcErrorCode {
/** Invalid JSON was received */
PARSE_ERROR = -32700,
/** The JSON sent is not a valid Request object */
INVALID_REQUEST = -32600,
/** The method does not exist or is not available */
METHOD_NOT_FOUND = -32601,
/** Invalid method parameter(s) */
INVALID_PARAMS = -32602,
/** Internal JSON-RPC error */
INTERNAL_ERROR = -32603,
/** Server error range: -32000 to -32099 */
SERVER_ERROR = -32000,
}
/**
* Gateway-specific error codes
*/
export enum GatewayErrorCode {
/** Gateway not connected */
NOT_CONNECTED = -32001,
/** Authentication required */
AUTH_REQUIRED = -32002,
/** Permission denied */
PERMISSION_DENIED = -32003,
/** Resource not found */
NOT_FOUND = -32004,
/** Operation timeout */
TIMEOUT = -32005,
/** Rate limit exceeded */
RATE_LIMITED = -32006,
}
/**
* Gateway event types
*/
export enum GatewayEventType {
/** Gateway status changed */
STATUS_CHANGED = 'gateway.status_changed',
/** Channel status changed */
CHANNEL_STATUS_CHANGED = 'channel.status_changed',
/** New chat message received */
MESSAGE_RECEIVED = 'chat.message_received',
/** Message sent */
MESSAGE_SENT = 'chat.message_sent',
/** Tool call started */
TOOL_CALL_STARTED = 'tool.call_started',
/** Tool call completed */
TOOL_CALL_COMPLETED = 'tool.call_completed',
/** Error occurred */
ERROR = 'error',
}
/**
* Gateway event payload
*/
export interface GatewayEvent<T = unknown> {
type: GatewayEventType;
timestamp: string;
data: T;
}
/**
* Create a JSON-RPC request
*/
export function createRequest(
method: string,
params?: unknown,
id?: string | number
): JsonRpcRequest {
return {
jsonrpc: '2.0',
id: id ?? crypto.randomUUID(),
method,
params,
};
}
/**
* Create a JSON-RPC success response
*/
export function createSuccessResponse<T>(
id: string | number,
result: T
): JsonRpcResponse<T> {
return {
jsonrpc: '2.0',
id,
result,
};
}
/**
* Create a JSON-RPC error response
*/
export function createErrorResponse(
id: string | number,
code: number,
message: string,
data?: unknown
): JsonRpcResponse {
return {
jsonrpc: '2.0',
id,
error: {
code,
message,
data,
},
};
}
/**
* Check if a message is a JSON-RPC request
*/
export function isRequest(message: unknown): message is JsonRpcRequest {
return (
typeof message === 'object' &&
message !== null &&
'jsonrpc' in message &&
message.jsonrpc === '2.0' &&
'method' in message &&
typeof message.method === 'string' &&
'id' in message
);
}
/**
* Check if a message is a JSON-RPC response
*/
export function isResponse(message: unknown): message is JsonRpcResponse {
return (
typeof message === 'object' &&
message !== null &&
'jsonrpc' in message &&
message.jsonrpc === '2.0' &&
'id' in message &&
('result' in message || 'error' in message)
);
}
/**
* Check if a message is a JSON-RPC notification
*/
export function isNotification(message: unknown): message is JsonRpcNotification {
return (
typeof message === 'object' &&
message !== null &&
'jsonrpc' in message &&
message.jsonrpc === '2.0' &&
'method' in message &&
!('id' in message)
);
}