cc工作前备份
This commit is contained in:
408
desktop/src/lib/gateway-client.ts
Normal file
408
desktop/src/lib/gateway-client.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* ZCLAW Gateway Client (Browser/Tauri side)
|
||||
*
|
||||
* WebSocket client for OpenClaw Gateway protocol, designed to run
|
||||
* in the Tauri React frontend. Uses native browser WebSocket API.
|
||||
*/
|
||||
|
||||
// === Protocol Types ===
|
||||
|
||||
export interface GatewayRequest {
|
||||
type: 'req';
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GatewayResponse {
|
||||
type: 'res';
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: any;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface GatewayEvent {
|
||||
type: 'event';
|
||||
event: string;
|
||||
payload?: any;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
|
||||
|
||||
export interface AgentStreamDelta {
|
||||
stream: 'assistant' | 'tool' | 'lifecycle';
|
||||
delta?: string;
|
||||
content?: string;
|
||||
tool?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
phase?: 'start' | 'end' | 'error';
|
||||
runId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
|
||||
|
||||
type EventCallback = (payload: any) => void;
|
||||
|
||||
// === Client ===
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: ConnectionState = 'disconnected';
|
||||
private requestId = 0;
|
||||
private pendingRequests = new Map<string, {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
timer: number;
|
||||
}>();
|
||||
private eventListeners = new Map<string, Set<EventCallback>>();
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: number | null = null;
|
||||
private deviceId: string;
|
||||
|
||||
// Options
|
||||
private url: string;
|
||||
private token: string;
|
||||
private autoReconnect: boolean;
|
||||
private reconnectInterval: number;
|
||||
private requestTimeout: number;
|
||||
|
||||
// State change callbacks
|
||||
onStateChange?: (state: ConnectionState) => void;
|
||||
onLog?: (level: string, message: string) => void;
|
||||
|
||||
constructor(opts?: {
|
||||
url?: string;
|
||||
token?: string;
|
||||
autoReconnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
requestTimeout?: number;
|
||||
}) {
|
||||
this.url = opts?.url || 'ws://127.0.0.1:18789';
|
||||
this.token = opts?.token || '';
|
||||
this.autoReconnect = opts?.autoReconnect ?? true;
|
||||
this.reconnectInterval = opts?.reconnectInterval || 3000;
|
||||
this.requestTimeout = opts?.requestTimeout || 30000;
|
||||
this.deviceId = crypto.randomUUID?.() || `zclaw_${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// === Connection ===
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this.state === 'connected' || this.state === 'connecting') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.setState('connecting');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setState('handshaking');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (evt) => {
|
||||
try {
|
||||
const frame: GatewayFrame = JSON.parse(evt.data);
|
||||
this.handleFrame(frame, resolve);
|
||||
} catch (err: any) {
|
||||
this.log('error', `Parse error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (evt) => {
|
||||
const wasConnected = this.state === 'connected';
|
||||
this.cleanup();
|
||||
|
||||
if (wasConnected && this.autoReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
this.emitEvent('close', { code: evt.code, reason: evt.reason });
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (this.state === 'connecting') {
|
||||
this.cleanup();
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
this.cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoReconnect = false;
|
||||
this.cancelReconnect();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
// === Request/Response ===
|
||||
|
||||
async request(method: string, params?: Record<string, any>): Promise<any> {
|
||||
if (this.state !== 'connected') {
|
||||
throw new Error(`Not connected (state: ${this.state})`);
|
||||
}
|
||||
|
||||
const id = `req_${++this.requestId}`;
|
||||
const frame: GatewayRequest = { type: 'req', id, method, params };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out`));
|
||||
}, this.requestTimeout);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
this.send(frame);
|
||||
});
|
||||
}
|
||||
|
||||
// === High-level API ===
|
||||
|
||||
/** Send message to agent, returns { runId, acceptedAt } */
|
||||
async chat(message: string, opts?: { sessionKey?: string; model?: string }): Promise<{ runId: string; acceptedAt: string }> {
|
||||
return this.request('agent', {
|
||||
message,
|
||||
sessionKey: opts?.sessionKey,
|
||||
model: opts?.model,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get Gateway health info */
|
||||
async health(): Promise<any> {
|
||||
return this.request('health');
|
||||
}
|
||||
|
||||
/** Get Gateway status */
|
||||
async status(): Promise<any> {
|
||||
return this.request('status');
|
||||
}
|
||||
|
||||
// ZCLAW custom methods
|
||||
async listClones(): Promise<any> { return this.request('zclaw.clones.list'); }
|
||||
async createClone(opts: { name: string; role?: string; scenarios?: string[] }): Promise<any> { return this.request('zclaw.clones.create', opts); }
|
||||
async updateClone(id: string, updates: Record<string, any>): Promise<any> { return this.request('zclaw.clones.update', { id, updates }); }
|
||||
async deleteClone(id: string): Promise<any> { return this.request('zclaw.clones.delete', { id }); }
|
||||
async getUsageStats(): Promise<any> { return this.request('zclaw.stats.usage'); }
|
||||
async getSessionStats(): Promise<any> { return this.request('zclaw.stats.sessions'); }
|
||||
async getWorkspaceInfo(): Promise<any> { return this.request('zclaw.workspace.info'); }
|
||||
async getPluginStatus(): Promise<any> { return this.request('zclaw.plugins.status'); }
|
||||
async getQuickConfig(): Promise<any> { return this.request('zclaw.config.quick', { get: true }); }
|
||||
async saveQuickConfig(config: Record<string, any>): Promise<any> { return this.request('zclaw.config.quick', config); }
|
||||
async listChannels(): Promise<any> { return this.request('channels.list'); }
|
||||
async getFeishuStatus(): Promise<any> { return this.request('feishu.status'); }
|
||||
async listScheduledTasks(): Promise<any> { return this.request('heartbeat.tasks'); }
|
||||
|
||||
// === Event Subscription ===
|
||||
|
||||
/** Subscribe to a Gateway event (e.g., 'agent', 'chat', 'heartbeat') */
|
||||
on(event: string, callback: EventCallback): () => void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, new Set());
|
||||
}
|
||||
this.eventListeners.get(event)!.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.eventListeners.get(event)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to agent stream events */
|
||||
onAgentStream(callback: (delta: AgentStreamDelta) => void): () => void {
|
||||
return this.on('agent', callback);
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
private handleFrame(frame: GatewayFrame, connectResolve?: (value: void) => void) {
|
||||
if (frame.type === 'event') {
|
||||
this.handleEvent(frame, connectResolve);
|
||||
} else if (frame.type === 'res') {
|
||||
this.handleResponse(frame);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: GatewayEvent, connectResolve?: (value: void) => void) {
|
||||
// Handle connect challenge
|
||||
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
|
||||
this.performHandshake(event.payload?.nonce, connectResolve);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch to listeners
|
||||
this.emitEvent(event.event, event.payload);
|
||||
}
|
||||
|
||||
private performHandshake(_nonce: string, connectResolve?: (value: void) => void) {
|
||||
const connectId = `connect_${Date.now()}`;
|
||||
|
||||
const connectReq: GatewayRequest = {
|
||||
type: 'req',
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'zclaw-tauri',
|
||||
version: '0.2.0',
|
||||
platform: this.detectPlatform(),
|
||||
mode: 'operator',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read', 'operator.write'],
|
||||
auth: this.token ? { token: this.token } : {},
|
||||
locale: 'zh-CN',
|
||||
userAgent: 'zclaw-tauri/0.2.0',
|
||||
device: { id: this.deviceId },
|
||||
},
|
||||
};
|
||||
|
||||
// Temporarily intercept the connect response
|
||||
const originalHandler = this.ws!.onmessage;
|
||||
this.ws!.onmessage = (evt) => {
|
||||
try {
|
||||
const frame = JSON.parse(evt.data);
|
||||
if (frame.type === 'res' && frame.id === connectId) {
|
||||
// Restore normal message handler
|
||||
this.ws!.onmessage = originalHandler;
|
||||
if (frame.ok) {
|
||||
this.setState('connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.emitEvent('connected', frame.payload);
|
||||
this.log('info', 'Connected to Gateway');
|
||||
connectResolve?.();
|
||||
} else {
|
||||
this.log('error', `Handshake failed: ${JSON.stringify(frame.error)}`);
|
||||
this.cleanup();
|
||||
}
|
||||
} else {
|
||||
// Pass through non-connect frames
|
||||
originalHandler?.call(this.ws!, evt);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
this.send(connectReq);
|
||||
}
|
||||
|
||||
private handleResponse(res: GatewayResponse) {
|
||||
const pending = this.pendingRequests.get(res.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(res.id);
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload);
|
||||
} else {
|
||||
pending.reject(new Error(JSON.stringify(res.error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(frame: GatewayFrame) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(event: string, payload: any) {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
for (const cb of listeners) {
|
||||
try { cb(payload); } catch { /* ignore listener errors */ }
|
||||
}
|
||||
}
|
||||
// Also emit wildcard
|
||||
const wildcardListeners = this.eventListeners.get('*');
|
||||
if (wildcardListeners) {
|
||||
for (const cb of wildcardListeners) {
|
||||
try { cb({ event, payload }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState) {
|
||||
this.state = state;
|
||||
this.onStateChange?.(state);
|
||||
this.emitEvent('state', state);
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('Connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
try { this.ws.close(); } catch { /* ignore */ }
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
this.reconnectAttempts++;
|
||||
this.setState('reconnecting');
|
||||
const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
|
||||
|
||||
this.reconnectTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await this.connect();
|
||||
} catch { /* close handler will trigger another reconnect */ }
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private detectPlatform(): string {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes('win')) return 'windows';
|
||||
if (ua.includes('mac')) return 'macos';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
private log(level: string, message: string) {
|
||||
this.onLog?.(level, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _client: GatewayClient | null = null;
|
||||
|
||||
export function getGatewayClient(opts?: ConstructorParameters<typeof GatewayClient>[0]): GatewayClient {
|
||||
if (!_client) {
|
||||
_client = new GatewayClient(opts);
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
Reference in New Issue
Block a user