From ddaeb0c282d3894406936ba8465b7fbda5ec26bd Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 02:19:55 +0000 Subject: [PATCH] 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 --- docker-compose.dokploy.yml | 3 + frontend/Dockerfile | 5 + frontend/src/lib/gateway.ts | 196 ++++++++++++++ frontend/src/pages/ChatPage.tsx | 460 ++++++++++++++++++++++++++++++-- 4 files changed, 647 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/gateway.ts diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index bcfa672..dfdd6ac 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -34,6 +34,9 @@ services: build: context: ./frontend dockerfile: Dockerfile + args: + VITE_WS_URL: ${VITE_WS_URL:-wss://ws.hammer.donovankelly.xyz} + VITE_WS_TOKEN: ${VITE_WS_TOKEN} ports: - "80" depends_on: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a266438..7216d49 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,11 @@ FROM oven/bun:1 AS build WORKDIR /app +ARG VITE_WS_URL="" +ARG VITE_WS_TOKEN="" +ENV VITE_WS_URL=$VITE_WS_URL +ENV VITE_WS_TOKEN=$VITE_WS_TOKEN + COPY package.json bun.lock* ./ RUN bun install --frozen-lockfile 2>/dev/null || bun install diff --git a/frontend/src/lib/gateway.ts b/frontend/src/lib/gateway.ts new file mode 100644 index 0000000..c290070 --- /dev/null +++ b/frontend/src/lib/gateway.ts @@ -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 void; reject: (e: any) => void }>(); + private eventHandlers = new Map>(); + private stateHandlers = new Set(); + private reconnectTimer: ReturnType | 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 { + 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 })); + } + } + } +} diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 0f5c2d5..f4ea229 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -1,23 +1,449 @@ -export function ChatPage() { - return ( -
- {/* Page Header */} -
-
-

Chat

-

Talk to Hammer directly

-
-
+import { useState, useEffect, useRef, useCallback } from "react"; +import { GatewayClient } from "../lib/gateway"; -
-
- 💬 -

Chat coming soon

-

- You'll be able to chat with Hammer right here in the dashboard. -

+const WS_URL = import.meta.env.VITE_WS_URL || `wss://${window.location.hostname.replace("dash.", "ws.hammer.")}`; +const WS_TOKEN = import.meta.env.VITE_WS_TOKEN || ""; + +interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; + timestamp?: number; +} + +interface ChatThread { + sessionKey: string; + name: string; + lastMessage?: string; + updatedAt: number; +} + +function ThreadList({ + threads, + activeThread, + onSelect, + onCreate, +}: { + threads: ChatThread[]; + activeThread: string | null; + onSelect: (key: string) => void; + onCreate: () => void; +}) { + return ( +
+
+

Threads

+ +
+
+ {threads.length === 0 ? ( +
+ No threads yet +
+ ) : ( + threads.map((thread) => ( + + )) + )} +
+
+ ); +} + +function MessageBubble({ msg }: { msg: ChatMessage }) { + const isUser = msg.role === "user"; + const isSystem = msg.role === "system"; + + if (isSystem) { + return ( +
+ + {msg.content} + +
+ ); + } + + return ( +
+ {!isUser && ( +
+ 🔨 +
+ )} +
+

{msg.content}

+
+
+ ); +} + +function ChatArea({ + messages, + loading, + streaming, + streamText, + onSend, + onAbort, + connected, +}: { + messages: ChatMessage[]; + loading: boolean; + streaming: boolean; + streamText: string; + onSend: (msg: string) => void; + onAbort: () => void; + connected: boolean; +}) { + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, streamText]); + + const handleSend = () => { + const text = input.trim(); + if (!text || !connected) return; + onSend(text); + setInput(""); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Messages */} +
+ {loading ? ( +
Loading messages...
+ ) : messages.length === 0 ? ( +
+ 🔨 +

Send a message to start chatting with Hammer

+
+ ) : ( + <> + {messages.map((msg, i) => ( + + ))} + {streaming && streamText && ( + + )} + + )} +
+
+ + {/* Input */} +
+ {!connected && ( +
+ + Disconnected — reconnecting... +
+ )} +
+