- Remove unused imports and variables in Team components - Fix CollaborationEvent type import in useTeamEvents - Add proper type guards for Hand status in gatewayStore - Fix Session status type compatibility in gateway-client - Remove unused getGatewayClient import from teamStore - Handle unknown payload types in TeamCollaborationView Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1567 lines
46 KiB
TypeScript
1567 lines
46 KiB
TypeScript
/**
|
|
* ZCLAW Gateway Client (Browser/Tauri side)
|
|
*
|
|
* WebSocket client for OpenFang Kernel protocol, designed to run
|
|
* in the Tauri React frontend. Uses native browser WebSocket API.
|
|
* Supports Ed25519 device authentication + JWT.
|
|
*
|
|
* OpenFang Configuration:
|
|
* - Port: 50051
|
|
* - WebSocket path: /ws
|
|
* - REST API: http://127.0.0.1:50051/api/*
|
|
* - Config format: TOML
|
|
*/
|
|
|
|
import nacl from 'tweetnacl';
|
|
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
|
|
|
|
// OpenFang endpoints (actual port is 50051, not 4200)
|
|
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
|
|
export const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:50051/ws';
|
|
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
|
|
export const FALLBACK_GATEWAY_URLS = [DEFAULT_GATEWAY_URL, 'ws://127.0.0.1:4200/ws'];
|
|
const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url';
|
|
const GATEWAY_TOKEN_STORAGE_KEY = 'zclaw_gateway_token';
|
|
|
|
// === 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;
|
|
|
|
function createIdempotencyKey(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
export interface AgentStreamDelta {
|
|
stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow';
|
|
delta?: string;
|
|
content?: string;
|
|
tool?: string;
|
|
toolInput?: string;
|
|
toolOutput?: string;
|
|
phase?: 'start' | 'end' | 'error';
|
|
runId?: string;
|
|
error?: string;
|
|
// Hand event fields
|
|
handName?: string;
|
|
handStatus?: string;
|
|
handResult?: unknown;
|
|
// Workflow event fields
|
|
workflowId?: string;
|
|
workflowStep?: string;
|
|
workflowStatus?: string;
|
|
workflowResult?: unknown;
|
|
}
|
|
|
|
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
|
|
|
|
type EventCallback = (payload: any) => void;
|
|
|
|
export function getStoredGatewayUrl(): string {
|
|
try {
|
|
const stored = localStorage.getItem(GATEWAY_URL_STORAGE_KEY);
|
|
return normalizeGatewayUrl(stored || DEFAULT_GATEWAY_URL);
|
|
} catch {
|
|
return DEFAULT_GATEWAY_URL;
|
|
}
|
|
}
|
|
|
|
export function setStoredGatewayUrl(url: string): string {
|
|
const normalized = normalizeGatewayUrl(url || DEFAULT_GATEWAY_URL);
|
|
try {
|
|
localStorage.setItem(GATEWAY_URL_STORAGE_KEY, normalized);
|
|
} catch { /* ignore localStorage failures */ }
|
|
return normalized;
|
|
}
|
|
|
|
export function getStoredGatewayToken(): string {
|
|
try {
|
|
return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function setStoredGatewayToken(token: string): string {
|
|
const normalized = token.trim();
|
|
try {
|
|
if (normalized) {
|
|
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
|
|
} else {
|
|
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
|
}
|
|
} catch { /* ignore localStorage failures */ }
|
|
return normalized;
|
|
}
|
|
|
|
// === Device Auth ===
|
|
|
|
interface DeviceKeys {
|
|
deviceId: string;
|
|
publicKey: Uint8Array;
|
|
secretKey: Uint8Array;
|
|
publicKeyBase64: string;
|
|
}
|
|
|
|
export interface LocalDeviceIdentity {
|
|
deviceId: string;
|
|
publicKeyBase64: string;
|
|
}
|
|
|
|
async function deriveDeviceId(publicKey: Uint8Array): Promise<string> {
|
|
const stableBytes = Uint8Array.from(publicKey);
|
|
const digest = await crypto.subtle.digest('SHA-256', stableBytes.buffer);
|
|
return Array.from(new Uint8Array(digest))
|
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
async function generateDeviceKeys(): Promise<DeviceKeys> {
|
|
const keyPair = nacl.sign.keyPair();
|
|
const deviceId = await deriveDeviceId(keyPair.publicKey);
|
|
|
|
return {
|
|
deviceId,
|
|
publicKey: keyPair.publicKey,
|
|
secretKey: keyPair.secretKey,
|
|
publicKeyBase64: b64Encode(keyPair.publicKey),
|
|
};
|
|
}
|
|
|
|
async function loadDeviceKeys(): Promise<DeviceKeys> {
|
|
// Try to load from localStorage
|
|
const stored = localStorage.getItem('zclaw_device_keys');
|
|
if (stored) {
|
|
try {
|
|
const parsed = JSON.parse(stored);
|
|
const publicKey = b64Decode(parsed.publicKeyBase64);
|
|
const secretKey = b64Decode(parsed.secretKeyBase64);
|
|
const deviceId = await deriveDeviceId(publicKey);
|
|
|
|
// Validate that the stored deviceId matches the derived one
|
|
if (parsed.deviceId && parsed.deviceId !== deviceId) {
|
|
console.warn('[GatewayClient] Stored deviceId mismatch, regenerating keys');
|
|
throw new Error('Device ID mismatch');
|
|
}
|
|
|
|
return {
|
|
deviceId,
|
|
publicKey,
|
|
secretKey,
|
|
publicKeyBase64: parsed.publicKeyBase64,
|
|
};
|
|
} catch (e) {
|
|
// Invalid stored keys, generate new ones
|
|
console.warn('[GatewayClient] Failed to load stored keys:', e);
|
|
}
|
|
}
|
|
|
|
// Generate new keys
|
|
const keys = await generateDeviceKeys();
|
|
localStorage.setItem('zclaw_device_keys', JSON.stringify({
|
|
deviceId: keys.deviceId,
|
|
publicKeyBase64: keys.publicKeyBase64,
|
|
secretKeyBase64: b64Encode(keys.secretKey),
|
|
}));
|
|
|
|
return keys;
|
|
}
|
|
|
|
export async function getLocalDeviceIdentity(): Promise<LocalDeviceIdentity> {
|
|
const keys = await loadDeviceKeys();
|
|
return {
|
|
deviceId: keys.deviceId,
|
|
publicKeyBase64: keys.publicKeyBase64,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear cached device keys to force regeneration on next connect.
|
|
* Useful when device signature validation fails repeatedly.
|
|
*/
|
|
export function clearDeviceKeys(): void {
|
|
try {
|
|
localStorage.removeItem('zclaw_device_keys');
|
|
console.log('[GatewayClient] Device keys cleared');
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
|
|
function b64Encode(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
function b64Decode(str: string): Uint8Array {
|
|
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
while (str.length % 4) str += '=';
|
|
const binary = atob(str);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function buildDeviceAuthPayload(params: {
|
|
clientId: string;
|
|
clientMode: string;
|
|
deviceId: string;
|
|
nonce: string;
|
|
role: string;
|
|
scopes: string[];
|
|
signedAt: number;
|
|
token?: string;
|
|
}) {
|
|
return [
|
|
'v2',
|
|
params.deviceId,
|
|
params.clientId,
|
|
params.clientMode,
|
|
params.role,
|
|
params.scopes.join(','),
|
|
String(params.signedAt),
|
|
params.token || '',
|
|
params.nonce,
|
|
].join('|');
|
|
}
|
|
|
|
function signDeviceAuth(params: {
|
|
clientId: string;
|
|
clientMode: string;
|
|
deviceId: string;
|
|
nonce: string;
|
|
role: string;
|
|
scopes: string[];
|
|
secretKey: Uint8Array;
|
|
token?: string;
|
|
}): { signature: string; signedAt: number } {
|
|
const signedAt = Date.now();
|
|
const message = buildDeviceAuthPayload({
|
|
clientId: params.clientId,
|
|
clientMode: params.clientMode,
|
|
deviceId: params.deviceId,
|
|
nonce: params.nonce,
|
|
role: params.role,
|
|
scopes: params.scopes,
|
|
signedAt,
|
|
token: params.token,
|
|
});
|
|
const messageBytes = new TextEncoder().encode(message);
|
|
const signature = nacl.sign.detached(messageBytes, params.secretKey);
|
|
|
|
return {
|
|
signature: b64Encode(signature),
|
|
signedAt,
|
|
};
|
|
}
|
|
|
|
// === Client ===
|
|
|
|
export class GatewayClient {
|
|
private ws: WebSocket | null = null;
|
|
private openfangWs: WebSocket | null = null; // OpenFang stream WebSocket
|
|
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 deviceKeysPromise: Promise<DeviceKeys>;
|
|
private streamCallbacks = new Map<string, {
|
|
onDelta: (delta: string) => void;
|
|
onTool?: (tool: string, input: string, output: string) => void;
|
|
onHand?: (name: string, status: string, result?: unknown) => void;
|
|
onComplete: () => void;
|
|
onError: (error: string) => void;
|
|
}>();
|
|
|
|
// 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 = normalizeGatewayUrl(opts?.url || getStoredGatewayUrl());
|
|
this.token = opts?.token ?? getStoredGatewayToken();
|
|
this.autoReconnect = opts?.autoReconnect ?? true;
|
|
this.reconnectInterval = opts?.reconnectInterval || 3000;
|
|
this.requestTimeout = opts?.requestTimeout || 30000;
|
|
this.deviceKeysPromise = loadDeviceKeys();
|
|
}
|
|
|
|
updateOptions(opts?: {
|
|
url?: string;
|
|
token?: string;
|
|
autoReconnect?: boolean;
|
|
reconnectInterval?: number;
|
|
requestTimeout?: number;
|
|
}) {
|
|
if (!opts) return;
|
|
if (opts.url) {
|
|
this.url = normalizeGatewayUrl(opts.url);
|
|
}
|
|
if (opts.token !== undefined) {
|
|
this.token = opts.token;
|
|
}
|
|
if (opts.autoReconnect !== undefined) {
|
|
this.autoReconnect = opts.autoReconnect;
|
|
}
|
|
if (opts.reconnectInterval !== undefined) {
|
|
this.reconnectInterval = opts.reconnectInterval;
|
|
}
|
|
if (opts.requestTimeout !== undefined) {
|
|
this.requestTimeout = opts.requestTimeout;
|
|
}
|
|
}
|
|
|
|
getState(): ConnectionState {
|
|
return this.state;
|
|
}
|
|
|
|
// === Connection ===
|
|
|
|
/** Connect using REST API only (for OpenFang mode) */
|
|
async connectRest(): Promise<void> {
|
|
if (this.state === 'connected') {
|
|
return;
|
|
}
|
|
this.setState('connecting');
|
|
try {
|
|
// Check if OpenFang API is healthy
|
|
const health = await this.restGet<{ status: string; version?: string }>('/api/health');
|
|
if (health.status === 'ok') {
|
|
this.setState('connected');
|
|
this.log('info', `Connected to OpenFang via REST API${health.version ? ` (v${health.version})` : ''}`);
|
|
} else {
|
|
throw new Error('Health check failed');
|
|
}
|
|
} catch (err: any) {
|
|
this.setState('disconnected');
|
|
throw new Error(`Failed to connect to OpenFang: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
connect(): Promise<void> {
|
|
if (this.state === 'connected' || this.state === 'connecting' || this.state === 'handshaking') {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Check if URL is for OpenFang (port 50051) - use REST mode
|
|
if (this.url.includes(':50051')) {
|
|
return this.connectRest();
|
|
}
|
|
|
|
this.autoReconnect = true;
|
|
this.setState('connecting');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const settleResolve = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve();
|
|
};
|
|
const settleReject = (error: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
const handshakeTimer = window.setTimeout(() => {
|
|
this.log('error', `Handshake timed out after ${this.requestTimeout}ms`);
|
|
this.cleanup();
|
|
settleReject(new Error(`Gateway handshake timed out after ${this.requestTimeout}ms`));
|
|
}, this.requestTimeout);
|
|
|
|
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, () => {
|
|
clearTimeout(handshakeTimer);
|
|
settleResolve();
|
|
}, (error) => {
|
|
clearTimeout(handshakeTimer);
|
|
settleReject(error);
|
|
});
|
|
} catch (err: any) {
|
|
this.log('error', `Parse error: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = (evt) => {
|
|
const wasConnected = this.state === 'connected';
|
|
const closedDuringConnect = !wasConnected && !settled;
|
|
this.cleanup();
|
|
|
|
if (wasConnected && this.autoReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
this.emitEvent('close', { code: evt.code, reason: evt.reason });
|
|
if (closedDuringConnect) {
|
|
clearTimeout(handshakeTimer);
|
|
settleReject(new Error(evt.reason || `WebSocket closed before handshake completed (code: ${evt.code})`));
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = () => {
|
|
if (this.state === 'connecting' || this.state === 'handshaking') {
|
|
clearTimeout(handshakeTimer);
|
|
this.cleanup();
|
|
settleReject(new Error('WebSocket connection failed'));
|
|
}
|
|
};
|
|
} catch (err) {
|
|
clearTimeout(handshakeTimer);
|
|
this.cleanup();
|
|
settleReject(err instanceof Error ? err : new Error(String(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 ===
|
|
|
|
// Default agent ID for OpenFang (will be set dynamically from /api/agents)
|
|
private defaultAgentId: string = 'f77004c8-418f-4132-b7d4-7ecb9d66f44c';
|
|
|
|
/** Set the default agent ID */
|
|
setDefaultAgentId(agentId: string): void {
|
|
this.defaultAgentId = agentId;
|
|
this.log('info', `Default agent set to: ${agentId}`);
|
|
}
|
|
|
|
/** Get the current default agent ID */
|
|
getDefaultAgentId(): string {
|
|
return this.defaultAgentId;
|
|
}
|
|
|
|
/** Send message to agent (OpenFang chat API) */
|
|
async chat(message: string, opts?: {
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
idempotencyKey?: string;
|
|
extraSystemPrompt?: string;
|
|
model?: string;
|
|
temperature?: number;
|
|
maxTokens?: number;
|
|
}): Promise<{ runId: string; sessionId?: string; response?: string }> {
|
|
// OpenFang uses /api/agents/{agentId}/message endpoint
|
|
const agentId = opts?.agentId || this.defaultAgentId;
|
|
const result = await this.restPost<{ response?: string; input_tokens?: number; output_tokens?: number }>(`/api/agents/${agentId}/message`, {
|
|
message,
|
|
session_id: opts?.sessionKey,
|
|
});
|
|
// OpenFang returns { response, input_tokens, output_tokens }
|
|
return {
|
|
runId: createIdempotencyKey(),
|
|
sessionId: opts?.sessionKey,
|
|
response: result.response,
|
|
};
|
|
}
|
|
|
|
/** Send message with streaming response (OpenFang WebSocket) */
|
|
async chatStream(
|
|
message: string,
|
|
callbacks: {
|
|
onDelta: (delta: string) => void;
|
|
onTool?: (tool: string, input: string, output: string) => void;
|
|
onHand?: (name: string, status: string, result?: unknown) => void;
|
|
onComplete: () => void;
|
|
onError: (error: string) => void;
|
|
},
|
|
opts?: {
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
}
|
|
): Promise<{ runId: string }> {
|
|
const agentId = opts?.agentId || this.defaultAgentId;
|
|
const runId = createIdempotencyKey();
|
|
const sessionId = opts?.sessionKey || `session_${Date.now()}`;
|
|
|
|
// Store callbacks for this run
|
|
this.streamCallbacks.set(runId, callbacks);
|
|
|
|
// Connect to OpenFang WebSocket if not connected
|
|
this.connectOpenFangStream(agentId, runId, sessionId, message);
|
|
|
|
return { runId };
|
|
}
|
|
|
|
/** Connect to OpenFang streaming WebSocket */
|
|
private connectOpenFangStream(
|
|
agentId: string,
|
|
runId: string,
|
|
sessionId: string,
|
|
message: string
|
|
): void {
|
|
// Close existing connection if any
|
|
if (this.openfangWs && this.openfangWs.readyState !== WebSocket.CLOSED) {
|
|
this.openfangWs.close();
|
|
}
|
|
|
|
// Build WebSocket URL
|
|
// In dev mode, use Vite proxy; in production, use direct connection
|
|
let wsUrl: string;
|
|
if (typeof window !== 'undefined' && window.location.port === '1420') {
|
|
// Dev mode: use Vite proxy with relative path
|
|
wsUrl = `ws://${window.location.host}/api/agents/${agentId}/ws`;
|
|
} else {
|
|
// Production: extract from stored URL
|
|
const httpUrl = this.getRestBaseUrl();
|
|
wsUrl = httpUrl.replace(/^http/, 'ws') + `/api/agents/${agentId}/ws`;
|
|
}
|
|
|
|
this.log('info', `Connecting to OpenFang stream: ${wsUrl}`);
|
|
|
|
try {
|
|
this.openfangWs = new WebSocket(wsUrl);
|
|
|
|
this.openfangWs.onopen = () => {
|
|
this.log('info', 'OpenFang WebSocket connected');
|
|
// Send chat message using OpenFang actual protocol
|
|
const chatRequest = {
|
|
type: 'message',
|
|
content: message,
|
|
session_id: sessionId,
|
|
};
|
|
this.openfangWs?.send(JSON.stringify(chatRequest));
|
|
};
|
|
|
|
this.openfangWs.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.handleOpenFangStreamEvent(runId, data, sessionId);
|
|
} catch (err: any) {
|
|
this.log('error', `Failed to parse stream event: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
this.openfangWs.onerror = (_event) => {
|
|
this.log('error', 'OpenFang WebSocket error');
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
callbacks.onError('WebSocket connection failed');
|
|
this.streamCallbacks.delete(runId);
|
|
}
|
|
};
|
|
|
|
this.openfangWs.onclose = (event) => {
|
|
this.log('info', `OpenFang WebSocket closed: ${event.code} ${event.reason}`);
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks && event.code !== 1000) {
|
|
callbacks.onError(`Connection closed: ${event.reason || 'unknown'}`);
|
|
}
|
|
this.streamCallbacks.delete(runId);
|
|
this.openfangWs = null;
|
|
};
|
|
} catch (err: any) {
|
|
this.log('error', `Failed to create WebSocket: ${err.message}`);
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
callbacks.onError(err.message);
|
|
this.streamCallbacks.delete(runId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Handle OpenFang stream events */
|
|
private handleOpenFangStreamEvent(runId: string, data: any, sessionId: string): void {
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (!callbacks) return;
|
|
|
|
switch (data.type) {
|
|
// OpenFang actual event types
|
|
case 'text_delta':
|
|
// Stream delta content
|
|
if (data.content) {
|
|
callbacks.onDelta(data.content);
|
|
}
|
|
break;
|
|
|
|
case 'phase':
|
|
// Phase change: streaming | done
|
|
if (data.phase === 'done') {
|
|
callbacks.onComplete();
|
|
this.streamCallbacks.delete(runId);
|
|
if (this.openfangWs) {
|
|
this.openfangWs.close(1000, 'Stream complete');
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'response':
|
|
// Final response with tokens info
|
|
if (data.content) {
|
|
// If we haven't received any deltas yet, send the full response
|
|
// This handles non-streaming responses
|
|
}
|
|
// Mark complete if phase done wasn't sent
|
|
callbacks.onComplete();
|
|
this.streamCallbacks.delete(runId);
|
|
if (this.openfangWs) {
|
|
this.openfangWs.close(1000, 'Stream complete');
|
|
}
|
|
break;
|
|
|
|
case 'typing':
|
|
// Typing indicator: { state: 'start' | 'stop' }
|
|
// Can be used for UI feedback
|
|
break;
|
|
|
|
case 'tool_call':
|
|
// Tool call event
|
|
if (callbacks.onTool && data.tool) {
|
|
callbacks.onTool(data.tool, JSON.stringify(data.input || {}), data.output || '');
|
|
}
|
|
break;
|
|
|
|
case 'tool_result':
|
|
if (callbacks.onTool && data.tool) {
|
|
callbacks.onTool(data.tool, '', data.result || data.output || '');
|
|
}
|
|
break;
|
|
|
|
case 'hand':
|
|
if (callbacks.onHand && data.hand_name) {
|
|
callbacks.onHand(data.hand_name, data.status || 'triggered', data.result);
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
callbacks.onError(data.message || data.error || data.content || 'Unknown error');
|
|
this.streamCallbacks.delete(runId);
|
|
if (this.openfangWs) {
|
|
this.openfangWs.close(1011, 'Error');
|
|
}
|
|
break;
|
|
|
|
case 'connected':
|
|
// Connection established
|
|
this.log('info', `OpenFang agent connected: ${data.agent_id}`);
|
|
break;
|
|
|
|
case 'agents_updated':
|
|
// Agents list updated
|
|
this.log('debug', 'Agents list updated');
|
|
break;
|
|
|
|
default:
|
|
// Emit unknown events for debugging
|
|
this.log('debug', `Stream event: ${data.type}`);
|
|
}
|
|
|
|
// Also emit to general 'agent' event listeners
|
|
this.emitEvent('agent', {
|
|
stream: data.type === 'text_delta' ? 'assistant' : data.type,
|
|
delta: data.content,
|
|
content: data.content,
|
|
runId,
|
|
sessionId,
|
|
...data,
|
|
});
|
|
}
|
|
|
|
/** Cancel an ongoing stream */
|
|
cancelStream(runId: string): void {
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
callbacks.onError('Stream cancelled');
|
|
this.streamCallbacks.delete(runId);
|
|
}
|
|
if (this.openfangWs && this.openfangWs.readyState === WebSocket.OPEN) {
|
|
this.openfangWs.close(1000, 'User cancelled');
|
|
}
|
|
}
|
|
|
|
/** Get Gateway health info */
|
|
async health(): Promise<any> {
|
|
return this.request('health');
|
|
}
|
|
|
|
/** Get Gateway status */
|
|
async status(): Promise<any> {
|
|
return this.request('status');
|
|
}
|
|
|
|
// === REST API Helpers (OpenFang) ===
|
|
|
|
private getRestBaseUrl(): string {
|
|
// In browser dev mode, use Vite proxy (empty string = relative path)
|
|
// In production Tauri, extract HTTP URL from WebSocket URL
|
|
if (typeof window !== 'undefined' && window.location.port === '1420') {
|
|
// Dev mode: use Vite proxy (requests go to /api/* which Vite proxies to backend)
|
|
return '';
|
|
}
|
|
// Production: extract HTTP URL from WebSocket URL
|
|
const wsUrl = this.url;
|
|
return wsUrl.replace(/^ws/, 'http').replace(/\/ws$/, '');
|
|
}
|
|
|
|
private async restGet<T>(path: string): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`);
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
private async restPost<T>(path: string, body?: unknown): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
private async restPut<T>(path: string, body?: unknown): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
private async restDelete<T>(path: string): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
private async restPatch<T>(path: string, body?: unknown): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// === ZCLAW / Agent Methods (OpenFang REST API) ===
|
|
|
|
async listClones(): Promise<any> {
|
|
return this.restGet('/api/agents');
|
|
}
|
|
async createClone(opts: {
|
|
name: string;
|
|
role?: string;
|
|
nickname?: string;
|
|
scenarios?: string[];
|
|
model?: string;
|
|
workspaceDir?: string;
|
|
restrictFiles?: boolean;
|
|
privacyOptIn?: boolean;
|
|
userName?: string;
|
|
userRole?: string;
|
|
}): Promise<any> {
|
|
return this.restPost('/api/agents', opts);
|
|
}
|
|
async updateClone(id: string, updates: Record<string, any>): Promise<any> {
|
|
return this.restPut(`/api/agents/${id}`, updates);
|
|
}
|
|
async deleteClone(id: string): Promise<any> {
|
|
return this.restDelete(`/api/agents/${id}`);
|
|
}
|
|
async getUsageStats(): Promise<any> {
|
|
return this.restGet('/api/stats/usage');
|
|
}
|
|
async getSessionStats(): Promise<any> {
|
|
return this.restGet('/api/stats/sessions');
|
|
}
|
|
async getWorkspaceInfo(): Promise<any> {
|
|
return this.restGet('/api/workspace');
|
|
}
|
|
async getPluginStatus(): Promise<any> {
|
|
return this.restGet('/api/plugins/status');
|
|
}
|
|
async getQuickConfig(): Promise<any> {
|
|
return this.restGet('/api/config/quick');
|
|
}
|
|
async saveQuickConfig(config: Record<string, any>): Promise<any> {
|
|
return this.restPut('/api/config/quick', config);
|
|
}
|
|
async listSkills(): Promise<any> {
|
|
return this.restGet('/api/skills');
|
|
}
|
|
async getSkill(id: string): Promise<any> {
|
|
return this.restGet(`/api/skills/${id}`);
|
|
}
|
|
async createSkill(skill: {
|
|
name: string;
|
|
description?: string;
|
|
triggers: Array<{ type: string; pattern?: string }>;
|
|
actions: Array<{ type: string; params?: Record<string, unknown> }>;
|
|
enabled?: boolean;
|
|
}): Promise<any> {
|
|
return this.restPost('/api/skills', skill);
|
|
}
|
|
async updateSkill(id: string, updates: {
|
|
name?: string;
|
|
description?: string;
|
|
triggers?: Array<{ type: string; pattern?: string }>;
|
|
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
|
|
enabled?: boolean;
|
|
}): Promise<any> {
|
|
return this.restPut(`/api/skills/${id}`, updates);
|
|
}
|
|
async deleteSkill(id: string): Promise<any> {
|
|
return this.restDelete(`/api/skills/${id}`);
|
|
}
|
|
async listChannels(): Promise<any> {
|
|
return this.restGet('/api/channels');
|
|
}
|
|
async getChannel(id: string): Promise<any> {
|
|
return this.restGet(`/api/channels/${id}`);
|
|
}
|
|
async createChannel(channel: {
|
|
type: string;
|
|
name: string;
|
|
config: Record<string, unknown>;
|
|
enabled?: boolean;
|
|
}): Promise<any> {
|
|
return this.restPost('/api/channels', channel);
|
|
}
|
|
async updateChannel(id: string, updates: {
|
|
name?: string;
|
|
config?: Record<string, unknown>;
|
|
enabled?: boolean;
|
|
}): Promise<any> {
|
|
return this.restPut(`/api/channels/${id}`, updates);
|
|
}
|
|
async deleteChannel(id: string): Promise<any> {
|
|
return this.restDelete(`/api/channels/${id}`);
|
|
}
|
|
async getFeishuStatus(): Promise<any> {
|
|
return this.restGet('/api/channels/feishu/status');
|
|
}
|
|
async listScheduledTasks(): Promise<any> {
|
|
return this.restGet('/api/scheduler/tasks');
|
|
}
|
|
|
|
/** Create a scheduled task */
|
|
async createScheduledTask(task: {
|
|
name: string;
|
|
schedule: string;
|
|
scheduleType: 'cron' | 'interval' | 'once';
|
|
target?: {
|
|
type: 'agent' | 'hand' | 'workflow';
|
|
id: string;
|
|
};
|
|
description?: string;
|
|
enabled?: boolean;
|
|
}): Promise<{ id: string; name: string; schedule: string; status: string }> {
|
|
return this.restPost('/api/scheduler/tasks', task);
|
|
}
|
|
|
|
/** Delete a scheduled task */
|
|
async deleteScheduledTask(id: string): Promise<void> {
|
|
return this.restDelete(`/api/scheduler/tasks/${id}`);
|
|
}
|
|
|
|
/** Toggle a scheduled task (enable/disable) */
|
|
async toggleScheduledTask(id: string, enabled: boolean): Promise<{ id: string; enabled: boolean }> {
|
|
return this.restPatch(`/api/scheduler/tasks/${id}`, { enabled });
|
|
}
|
|
|
|
// === OpenFang Hands API ===
|
|
|
|
/** List available Hands */
|
|
async listHands(): Promise<{
|
|
hands: {
|
|
id?: string;
|
|
name: string;
|
|
description?: string;
|
|
status?: string;
|
|
requirements_met?: boolean;
|
|
category?: string;
|
|
icon?: string;
|
|
tool_count?: number;
|
|
tools?: string[];
|
|
metric_count?: number;
|
|
metrics?: string[];
|
|
}[]
|
|
}> {
|
|
return this.restGet('/api/hands');
|
|
}
|
|
|
|
/** Get Hand details */
|
|
async getHand(name: string): Promise<{
|
|
id?: string;
|
|
name?: string;
|
|
description?: string;
|
|
status?: string;
|
|
requirements_met?: boolean;
|
|
category?: string;
|
|
icon?: string;
|
|
provider?: string;
|
|
model?: string;
|
|
requirements?: { description?: string; name?: string; met?: boolean; satisfied?: boolean; details?: string; hint?: string }[];
|
|
tools?: string[];
|
|
metrics?: string[];
|
|
config?: Record<string, unknown>;
|
|
tool_count?: number;
|
|
metric_count?: number;
|
|
}> {
|
|
return this.restGet(`/api/hands/${name}`);
|
|
}
|
|
|
|
/** Trigger a Hand */
|
|
async triggerHand(name: string, params?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
|
|
// OpenFang uses /activate endpoint, not /trigger
|
|
const result = await this.restPost<{
|
|
instance_id: string;
|
|
status: string;
|
|
}>(`/api/hands/${name}/activate`, params || {});
|
|
return { runId: result.instance_id, status: result.status };
|
|
}
|
|
|
|
/** Get Hand execution status */
|
|
async getHandStatus(name: string, runId: string): Promise<{ status: string; result?: unknown }> {
|
|
return this.restGet(`/api/hands/${name}/runs/${runId}`);
|
|
}
|
|
|
|
/** Approve a Hand execution (for needs_approval status) */
|
|
async approveHand(name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
|
|
return this.restPost(`/api/hands/${name}/runs/${runId}/approve`, { approved, reason });
|
|
}
|
|
|
|
/** Cancel a running Hand execution */
|
|
async cancelHand(name: string, runId: string): Promise<{ status: string }> {
|
|
return this.restPost(`/api/hands/${name}/runs/${runId}/cancel`, {});
|
|
}
|
|
|
|
/** List Hand execution runs */
|
|
async listHandRuns(name: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: { runId: string; status: string; startedAt: string }[] }> {
|
|
const params = new URLSearchParams();
|
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
if (opts?.offset) params.set('offset', String(opts.offset));
|
|
return this.restGet(`/api/hands/${name}/runs?${params}`);
|
|
}
|
|
|
|
// === OpenFang Workflows API ===
|
|
|
|
/** List available workflows */
|
|
async listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number }[] }> {
|
|
return this.restGet('/api/workflows');
|
|
}
|
|
|
|
/** Get workflow details */
|
|
async getWorkflow(id: string): Promise<{ id: string; name: string; steps: unknown[] }> {
|
|
return this.restGet(`/api/workflows/${id}`);
|
|
}
|
|
|
|
/** Execute a workflow */
|
|
async executeWorkflow(id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
|
|
return this.restPost(`/api/workflows/${id}/execute`, input);
|
|
}
|
|
|
|
/** Get workflow execution status */
|
|
async getWorkflowRun(workflowId: string, runId: string): Promise<{ status: string; step: string; result?: unknown }> {
|
|
return this.restGet(`/api/workflows/${workflowId}/runs/${runId}`);
|
|
}
|
|
|
|
/** List workflow execution runs */
|
|
async listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{
|
|
runs: Array<{
|
|
runId: string;
|
|
status: string;
|
|
startedAt: string;
|
|
completedAt?: string;
|
|
step?: string;
|
|
result?: unknown;
|
|
error?: string;
|
|
}>;
|
|
}> {
|
|
const params = new URLSearchParams();
|
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
if (opts?.offset) params.set('offset', String(opts.offset));
|
|
return this.restGet(`/api/workflows/${workflowId}/runs?${params}`);
|
|
}
|
|
|
|
/** Cancel a workflow execution */
|
|
async cancelWorkflow(workflowId: string, runId: string): Promise<{ status: string }> {
|
|
return this.restPost(`/api/workflows/${workflowId}/runs/${runId}/cancel`, {});
|
|
}
|
|
|
|
/** Create a new workflow */
|
|
async createWorkflow(workflow: {
|
|
name: string;
|
|
description?: string;
|
|
steps: Array<{
|
|
handName: string;
|
|
name?: string;
|
|
params?: Record<string, unknown>;
|
|
condition?: string;
|
|
}>;
|
|
}): Promise<{ id: string; name: string }> {
|
|
return this.restPost('/api/workflows', workflow);
|
|
}
|
|
|
|
/** Update a workflow */
|
|
async updateWorkflow(id: string, updates: {
|
|
name?: string;
|
|
description?: string;
|
|
steps?: Array<{
|
|
handName: string;
|
|
name?: string;
|
|
params?: Record<string, unknown>;
|
|
condition?: string;
|
|
}>;
|
|
}): Promise<{ id: string; name: string }> {
|
|
return this.restPut(`/api/workflows/${id}`, updates);
|
|
}
|
|
|
|
/** Delete a workflow */
|
|
async deleteWorkflow(id: string): Promise<{ status: string }> {
|
|
return this.restDelete(`/api/workflows/${id}`);
|
|
}
|
|
|
|
// === OpenFang Session API ===
|
|
|
|
/** List all sessions */
|
|
async listSessions(opts?: { limit?: number; offset?: number }): Promise<{
|
|
sessions: Array<{
|
|
id: string;
|
|
agent_id: string;
|
|
created_at: string;
|
|
updated_at?: string;
|
|
message_count?: number;
|
|
status?: 'active' | 'archived' | 'expired';
|
|
}>;
|
|
}> {
|
|
const params = new URLSearchParams();
|
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
if (opts?.offset) params.set('offset', String(opts.offset));
|
|
return this.restGet(`/api/sessions?${params}`);
|
|
}
|
|
|
|
/** Get session details */
|
|
async getSession(sessionId: string): Promise<{
|
|
id: string;
|
|
agent_id: string;
|
|
created_at: string;
|
|
updated_at?: string;
|
|
message_count?: number;
|
|
status?: 'active' | 'archived' | 'expired';
|
|
metadata?: Record<string, unknown>;
|
|
}> {
|
|
return this.restGet(`/api/sessions/${sessionId}`);
|
|
}
|
|
|
|
/** Create a new session */
|
|
async createSession(opts: {
|
|
agent_id: string;
|
|
metadata?: Record<string, unknown>;
|
|
}): Promise<{
|
|
id: string;
|
|
agent_id: string;
|
|
created_at: string;
|
|
}> {
|
|
return this.restPost('/api/sessions', opts);
|
|
}
|
|
|
|
/** Delete a session */
|
|
async deleteSession(sessionId: string): Promise<{ status: string }> {
|
|
return this.restDelete(`/api/sessions/${sessionId}`);
|
|
}
|
|
|
|
/** Get session messages */
|
|
async getSessionMessages(sessionId: string, opts?: {
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<{
|
|
messages: Array<{
|
|
id: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
created_at: string;
|
|
tokens?: { input?: number; output?: number };
|
|
}>;
|
|
}> {
|
|
const params = new URLSearchParams();
|
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
if (opts?.offset) params.set('offset', String(opts.offset));
|
|
return this.restGet(`/api/sessions/${sessionId}/messages?${params}`);
|
|
}
|
|
|
|
// === OpenFang Triggers API ===
|
|
|
|
/** List triggers */
|
|
async listTriggers(): Promise<{ triggers: { id: string; type: string; enabled: boolean }[] }> {
|
|
return this.restGet('/api/triggers');
|
|
}
|
|
|
|
/** Get trigger details */
|
|
async getTrigger(id: string): Promise<{
|
|
id: string;
|
|
type: string;
|
|
name?: string;
|
|
enabled: boolean;
|
|
config?: Record<string, unknown>;
|
|
}> {
|
|
return this.restGet(`/api/triggers/${id}`);
|
|
}
|
|
|
|
/** Create a new trigger */
|
|
async createTrigger(trigger: {
|
|
type: string;
|
|
name?: string;
|
|
enabled?: boolean;
|
|
config?: Record<string, unknown>;
|
|
handName?: string;
|
|
workflowId?: string;
|
|
}): Promise<{ id: string }> {
|
|
return this.restPost('/api/triggers', trigger);
|
|
}
|
|
|
|
/** Update a trigger */
|
|
async updateTrigger(id: string, updates: {
|
|
name?: string;
|
|
enabled?: boolean;
|
|
config?: Record<string, unknown>;
|
|
handName?: string;
|
|
workflowId?: string;
|
|
}): Promise<{ id: string }> {
|
|
return this.restPut(`/api/triggers/${id}`, updates);
|
|
}
|
|
|
|
/** Delete a trigger */
|
|
async deleteTrigger(id: string): Promise<{ status: string }> {
|
|
return this.restDelete(`/api/triggers/${id}`);
|
|
}
|
|
|
|
// === OpenFang Audit API ===
|
|
|
|
/** Get audit logs */
|
|
async getAuditLogs(opts?: { limit?: number; offset?: number }): Promise<{ logs: unknown[] }> {
|
|
const params = new URLSearchParams();
|
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
if (opts?.offset) params.set('offset', String(opts.offset));
|
|
return this.restGet(`/api/audit/logs?${params}`);
|
|
}
|
|
|
|
// === OpenFang Security API ===
|
|
|
|
/** Get security status */
|
|
async getSecurityStatus(): Promise<{ layers: { name: string; enabled: boolean }[] }> {
|
|
return this.restGet('/api/security/status');
|
|
}
|
|
|
|
/** Get capabilities (RBAC) */
|
|
async getCapabilities(): Promise<{ capabilities: string[] }> {
|
|
return this.restGet('/api/capabilities');
|
|
}
|
|
|
|
// === OpenFang Approvals API ===
|
|
|
|
/** List pending/approved/rejected approvals */
|
|
async listApprovals(status?: string): Promise<{
|
|
approvals: {
|
|
id: string;
|
|
hand_name: string;
|
|
run_id: string;
|
|
status: string;
|
|
requested_at: string;
|
|
requested_by?: string;
|
|
reason?: string;
|
|
action?: string;
|
|
params?: Record<string, unknown>;
|
|
responded_at?: string;
|
|
responded_by?: string;
|
|
response_reason?: string;
|
|
}[];
|
|
}> {
|
|
const params = status ? `?status=${status}` : '';
|
|
return this.restGet(`/api/approvals${params}`);
|
|
}
|
|
|
|
/** Respond to an approval (approve/reject) */
|
|
async respondToApproval(approvalId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
|
|
return this.restPost(`/api/approvals/${approvalId}/respond`, { approved, reason });
|
|
}
|
|
|
|
async listModels(): Promise<{ models: GatewayModelChoice[] }> {
|
|
// OpenFang: 使用 REST API
|
|
return this.restGet('/api/models');
|
|
}
|
|
async getConfig(): Promise<GatewayConfigSnapshot | Record<string, any>> {
|
|
// OpenFang: 使用 REST API
|
|
return this.restGet('/api/config');
|
|
}
|
|
async applyConfig(raw: string, baseHash?: string, opts?: { sessionKey?: string; note?: string; restartDelayMs?: number }): Promise<any> {
|
|
return this.request('config.apply', {
|
|
raw,
|
|
baseHash,
|
|
sessionKey: opts?.sessionKey,
|
|
note: opts?.note,
|
|
restartDelayMs: opts?.restartDelayMs,
|
|
});
|
|
}
|
|
|
|
// === 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?: () => void, connectReject?: (error: Error) => void) {
|
|
if (frame.type === 'event') {
|
|
this.handleEvent(frame, connectResolve, connectReject);
|
|
} else if (frame.type === 'res') {
|
|
this.handleResponse(frame);
|
|
}
|
|
}
|
|
|
|
private handleEvent(event: GatewayEvent, connectResolve?: () => void, connectReject?: (error: Error) => void) {
|
|
// Handle connect challenge
|
|
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
|
|
this.performHandshake(event.payload?.nonce, connectResolve, connectReject);
|
|
return;
|
|
}
|
|
|
|
// Dispatch to listeners
|
|
this.emitEvent(event.event, event.payload);
|
|
}
|
|
|
|
private async performHandshake(challengeNonce: string, connectResolve?: () => void, connectReject?: (error: Error) => void) {
|
|
const connectId = `connect_${Date.now()}`;
|
|
// Use a valid client ID from GATEWAY_CLIENT_ID_SET
|
|
// Valid IDs: gateway-client, cli, webchat, node-host, test
|
|
// 'cli' is for control UI / command-line clients
|
|
const clientId = 'cli';
|
|
// Valid modes: cli, webchat, backend, node
|
|
// 'cli' is for command-line/Control UI clients
|
|
const clientMode = 'cli';
|
|
const role = 'operator';
|
|
const scopes = ['operator.read', 'operator.write', 'operator.admin', 'operator.approvals', 'operator.pairing'];
|
|
|
|
// Debug: log token status
|
|
this.log('debug', `Handshake token: ${this.token ? `${this.token.substring(0, 8)}... (${this.token.length} chars)` : '(empty)'}`);
|
|
|
|
try {
|
|
const deviceKeys = await this.deviceKeysPromise;
|
|
|
|
// Debug: log device auth details
|
|
this.log('debug', `Device auth: deviceId=${deviceKeys.deviceId.substring(0, 8)}..., nonce=${challengeNonce.substring(0, 8)}...`);
|
|
|
|
const { signature, signedAt } = signDeviceAuth({
|
|
clientId,
|
|
clientMode,
|
|
deviceId: deviceKeys.deviceId,
|
|
nonce: challengeNonce,
|
|
role,
|
|
scopes,
|
|
secretKey: deviceKeys.secretKey,
|
|
token: this.token,
|
|
});
|
|
|
|
// Debug: log signature details
|
|
this.log('debug', `Signature created: signedAt=${signedAt}, sig=${signature.substring(0, 16)}...`);
|
|
|
|
const connectReq: GatewayRequest = {
|
|
type: 'req',
|
|
id: connectId,
|
|
method: 'connect',
|
|
params: {
|
|
minProtocol: 3,
|
|
maxProtocol: 3,
|
|
client: {
|
|
id: clientId,
|
|
version: '0.2.0',
|
|
platform: this.detectPlatform(),
|
|
mode: clientMode,
|
|
},
|
|
role,
|
|
scopes,
|
|
|
|
auth: this.token ? { token: this.token } : {},
|
|
locale: 'zh-CN',
|
|
userAgent: 'zclaw-tauri/0.2.0',
|
|
device: {
|
|
id: deviceKeys.deviceId,
|
|
publicKey: deviceKeys.publicKeyBase64,
|
|
signature,
|
|
signedAt,
|
|
nonce: challengeNonce,
|
|
},
|
|
},
|
|
};
|
|
|
|
const originalHandler = this.ws!.onmessage;
|
|
this.ws!.onmessage = (evt) => {
|
|
try {
|
|
const frame = JSON.parse(evt.data);
|
|
if (frame.type === 'res' && frame.id === connectId) {
|
|
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 {
|
|
const errorObj = frame.error;
|
|
const errorMessage = errorObj?.message || errorObj?.code || JSON.stringify(errorObj);
|
|
const error = new Error(`Handshake failed: ${errorMessage}`);
|
|
this.log('error', error.message);
|
|
|
|
// Check for signature-related errors and clear device keys if needed
|
|
if (errorMessage.includes('signature') || errorMessage.includes('device')) {
|
|
this.log('warn', 'Device signature failed, clearing cached keys for retry');
|
|
clearDeviceKeys();
|
|
}
|
|
|
|
this.cleanup();
|
|
connectReject?.(error);
|
|
}
|
|
} else {
|
|
originalHandler?.call(this.ws!, evt);
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
};
|
|
|
|
this.send(connectReq);
|
|
} catch (err: any) {
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
this.log('error', error.message);
|
|
this.cleanup();
|
|
connectReject?.(error);
|
|
}
|
|
}
|
|
|
|
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);
|
|
} else if (opts) {
|
|
_client.updateOptions(opts);
|
|
}
|
|
return _client;
|
|
}
|
|
|
|
function normalizeGatewayUrl(url: string): string {
|
|
return url.replace(/\/+$/, '');
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|