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 }));
}
}
}
}

View File

@@ -1,23 +1,449 @@
export function ChatPage() {
return (
<div className="min-h-screen">
{/* Page Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
<div className="max-w-4xl mx-auto px-6 py-4">
<h1 className="text-xl font-bold text-gray-900">Chat</h1>
<p className="text-sm text-gray-400">Talk to Hammer directly</p>
</div>
</header>
import { useState, useEffect, useRef, useCallback } from "react";
import { GatewayClient } from "../lib/gateway";
<div className="max-w-4xl mx-auto px-6 py-6">
<div className="border-2 border-dashed border-gray-200 rounded-lg p-12 text-center">
<span className="text-4xl mb-4 block">💬</span>
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat coming soon</h2>
<p className="text-sm text-gray-400">
You'll be able to chat with Hammer right here in the dashboard.
</p>
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 (
<div className="w-64 bg-white border-r border-gray-200 flex flex-col h-full">
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-600">Threads</h3>
<button
onClick={onCreate}
className="text-xs bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600 transition font-medium"
>
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto">
{threads.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">
No threads yet
</div>
) : (
threads.map((thread) => (
<button
key={thread.sessionKey}
onClick={() => onSelect(thread.sessionKey)}
className={`w-full text-left px-3 py-3 border-b border-gray-50 transition ${
activeThread === thread.sessionKey
? "bg-amber-50 border-l-2 border-l-amber-500"
: "hover:bg-gray-50"
}`}
>
<div className="text-sm font-medium text-gray-800 truncate">
{thread.name}
</div>
{thread.lastMessage && (
<div className="text-xs text-gray-400 truncate mt-0.5">
{thread.lastMessage}
</div>
)}
</button>
))
)}
</div>
</div>
);
}
function MessageBubble({ msg }: { msg: ChatMessage }) {
const isUser = msg.role === "user";
const isSystem = msg.role === "system";
if (isSystem) {
return (
<div className="text-center my-2">
<span className="text-xs text-gray-400 bg-gray-100 px-3 py-1 rounded-full">
{msg.content}
</span>
</div>
);
}
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3`}>
{!isUser && (
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
🔨
</div>
)}
<div
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
isUser
? "bg-blue-500 text-white rounded-br-md"
: "bg-gray-100 text-gray-800 rounded-bl-md"
}`}
>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
</div>
</div>
);
}
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<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 (
<div className="flex-1 flex flex-col h-full">
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center text-gray-400 py-12">Loading messages...</div>
) : messages.length === 0 ? (
<div className="text-center text-gray-400 py-12">
<span className="text-4xl block mb-3">🔨</span>
<p className="text-sm">Send a message to start chatting with Hammer</p>
</div>
) : (
<>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{streaming && streamText && (
<MessageBubble msg={{ role: "assistant", content: streamText }} />
)}
</>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-gray-200 bg-white px-4 py-3">
{!connected && (
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full" />
Disconnected reconnecting...
</div>
)}
<div className="flex gap-2 items-end">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={connected ? "Type a message..." : "Connecting..."}
disabled={!connected}
rows={1}
className="flex-1 resize-none rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 disabled:opacity-50 max-h-32"
style={{ minHeight: "42px" }}
/>
{streaming ? (
<button
onClick={onAbort}
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium hover:bg-red-600 transition shrink-0"
>
Stop
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || !connected}
className="px-4 py-2.5 bg-amber-500 text-white rounded-xl text-sm font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
Send
</button>
)}
</div>
</div>
</div>
);
}
export function ChatPage() {
const [gateway] = useState(() => new GatewayClient(WS_URL, WS_TOKEN));
const [connected, setConnected] = useState(false);
const [threads, setThreads] = useState<ChatThread[]>(() => {
try {
return JSON.parse(localStorage.getItem("hammer-chat-threads") || "[]");
} catch {
return [];
}
});
const [activeThread, setActiveThread] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [streamText, setStreamText] = useState("");
// Persist threads to localStorage
useEffect(() => {
localStorage.setItem("hammer-chat-threads", JSON.stringify(threads));
}, [threads]);
// Connect to gateway
useEffect(() => {
if (!WS_TOKEN) return;
gateway.connect();
const unsub = gateway.onStateChange(setConnected);
return () => {
unsub();
gateway.disconnect();
};
}, [gateway]);
// Listen for chat events (streaming responses)
useEffect(() => {
const unsub = gateway.on("chat", (payload: any) => {
if (payload.sessionKey !== activeThread) return;
if (payload.state === "delta" && payload.message?.content) {
setStreaming(true);
// Accumulate delta text
const textParts = payload.message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
if (textParts) {
setStreamText((prev) => prev + textParts);
}
} else if (payload.state === "final") {
// Final message — add to messages
if (payload.message?.content) {
const text = payload.message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
if (text) {
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
// Update thread last message
setThreads((prev) =>
prev.map((t) =>
t.sessionKey === activeThread
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
: t
)
);
}
}
setStreaming(false);
setStreamText("");
} else if (payload.state === "aborted" || payload.state === "error") {
setStreaming(false);
if (streamText) {
// Save partial response
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
}
setStreamText("");
}
});
return unsub;
}, [gateway, activeThread, streamText]);
// Load messages when thread changes
const loadMessages = useCallback(
async (sessionKey: string) => {
setLoading(true);
setMessages([]);
try {
const result = await gateway.chatHistory(sessionKey);
if (result?.messages) {
const msgs: ChatMessage[] = result.messages
.filter((m: any) => m.role === "user" || m.role === "assistant")
.map((m: any) => ({
role: m.role as "user" | "assistant",
content:
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? m.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("")
: "",
}))
.filter((m: ChatMessage) => m.content);
setMessages(msgs);
}
} catch (e) {
console.error("Failed to load chat history:", e);
} finally {
setLoading(false);
}
},
[gateway]
);
useEffect(() => {
if (activeThread && connected) {
loadMessages(activeThread);
}
}, [activeThread, connected, loadMessages]);
const handleCreateThread = () => {
const id = `dash:chat:${Date.now()}`;
const thread: ChatThread = {
sessionKey: id,
name: `Chat ${threads.length + 1}`,
updatedAt: Date.now(),
};
setThreads((prev) => [thread, ...prev]);
setActiveThread(id);
setMessages([]);
};
const handleSend = async (text: string) => {
if (!activeThread) return;
// Add user message immediately
setMessages((prev) => [...prev, { role: "user", content: text }]);
setThreads((prev) =>
prev.map((t) =>
t.sessionKey === activeThread
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
: t
)
);
try {
await gateway.chatSend(activeThread, text);
} catch (e) {
console.error("Failed to send:", e);
setMessages((prev) => [
...prev,
{ role: "system", content: "Failed to send message. Please try again." },
]);
}
};
const handleAbort = async () => {
if (!activeThread) return;
try {
await gateway.chatAbort(activeThread);
} catch (e) {
console.error("Failed to abort:", e);
}
};
// Auto-create first thread if none exist
useEffect(() => {
if (threads.length === 0) {
handleCreateThread();
} else if (!activeThread) {
setActiveThread(threads[0].sessionKey);
}
}, []);
if (!WS_TOKEN) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center p-8">
<span className="text-4xl block mb-4">🔒</span>
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat not configured</h2>
<p className="text-sm text-gray-400">
Gateway WebSocket token not set. Add VITE_WS_TOKEN to environment.
</p>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col">
{/* Page Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
<div className="px-6 py-3 flex items-center justify-between">
<div>
<h1 className="text-lg font-bold text-gray-900">Chat</h1>
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-xs text-gray-400">
{connected ? "Connected" : "Disconnected"}
</span>
</div>
</div>
</header>
{/* Chat body */}
<div className="flex-1 flex overflow-hidden">
<ThreadList
threads={threads}
activeThread={activeThread}
onSelect={(key) => setActiveThread(key)}
onCreate={handleCreateThread}
/>
{activeThread ? (
<ChatArea
messages={messages}
loading={loading}
streaming={streaming}
streamText={streamText}
onSend={handleSend}
onAbort={handleAbort}
connected={connected}
/>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400">
Select or create a thread
</div>
)}
</div>
</div>
);
}