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:
2026-01-29 02:19:55 +00:00
parent 91bc69e178
commit ddaeb0c282
4 changed files with 647 additions and 17 deletions

196
frontend/src/lib/gateway.ts Normal file
View 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 }));
}
}
}
}