feat: chat interface with gateway WebSocket integration (HQ-21)
- GatewayClient class: WS connection, auto-reconnect, request/response, events - ChatPage with thread list sidebar + message area - Real-time streaming responses via chat events - Thread management with localStorage persistence - Message bubbles with user/assistant/system styles - Build args for VITE_WS_URL and VITE_WS_TOKEN - SPA routing already supported by nginx config
This commit is contained in:
196
frontend/src/lib/gateway.ts
Normal file
196
frontend/src/lib/gateway.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Gateway WebSocket client for Hammer Dashboard chat
|
||||
|
||||
type MessageHandler = (msg: any) => void;
|
||||
type StateHandler = (connected: boolean) => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextId() {
|
||||
return `r${++reqCounter}`;
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private token: string;
|
||||
private connected = false;
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
private eventHandlers = new Map<string, Set<MessageHandler>>();
|
||||
private stateHandlers = new Set<StateHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send connect handshake
|
||||
const connectId = nextId();
|
||||
this._send({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "webchat",
|
||||
displayName: "Hammer Dashboard",
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
instanceId: `dash-${Date.now()}`,
|
||||
},
|
||||
auth: {
|
||||
token: this.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for hello-ok
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
this.connected = true;
|
||||
this.stateHandlers.forEach((h) => h(true));
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("Gateway connect failed:", err);
|
||||
this.connected = false;
|
||||
this.stateHandlers.forEach((h) => h(false));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gateway message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
this.stateHandlers.forEach((h) => h(false));
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = setTimeout(() => this._connect(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose will handle reconnect
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
onStateChange(handler: StateHandler) {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => this.stateHandlers.delete(handler);
|
||||
}
|
||||
|
||||
on(event: string, handler: MessageHandler) {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, new Set());
|
||||
}
|
||||
this.eventHandlers.get(event)!.add(handler);
|
||||
return () => this.eventHandlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || !this.connected) {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
const id = nextId();
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this._send({ type: "req", id, method, params });
|
||||
|
||||
// Timeout after 60s
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// Chat methods
|
||||
async chatHistory(sessionKey: string, limit = 50) {
|
||||
return this.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
async chatSend(sessionKey: string, message: string) {
|
||||
return this.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async chatAbort(sessionKey: string) {
|
||||
return this.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
async sessionsList(limit = 50) {
|
||||
return this.request("sessions.list", { limit });
|
||||
}
|
||||
|
||||
private _send(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMessage(msg: any) {
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
pending.resolve(msg.payload);
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
const handlers = this.eventHandlers.get(msg.event);
|
||||
if (handlers) {
|
||||
handlers.forEach((h) => h(msg.payload));
|
||||
}
|
||||
// Also fire wildcard handlers
|
||||
const wildcardHandlers = this.eventHandlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
wildcardHandlers.forEach((h) => h({ event: msg.event, ...msg.payload }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user