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:
@@ -34,6 +34,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_WS_URL: ${VITE_WS_URL:-wss://ws.hammer.donovankelly.xyz}
|
||||||
|
VITE_WS_TOKEN: ${VITE_WS_TOKEN}
|
||||||
ports:
|
ports:
|
||||||
- "80"
|
- "80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
FROM oven/bun:1 AS build
|
FROM oven/bun:1 AS build
|
||||||
WORKDIR /app
|
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* ./
|
COPY package.json bun.lock* ./
|
||||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
|
||||||
|
|||||||
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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,449 @@
|
|||||||
export function ChatPage() {
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
return (
|
import { GatewayClient } from "../lib/gateway";
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
const WS_URL = import.meta.env.VITE_WS_URL || `wss://${window.location.hostname.replace("dash.", "ws.hammer.")}`;
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-12 text-center">
|
const WS_TOKEN = import.meta.env.VITE_WS_TOKEN || "";
|
||||||
<span className="text-4xl mb-4 block">💬</span>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat coming soon</h2>
|
interface ChatMessage {
|
||||||
<p className="text-sm text-gray-400">
|
role: "user" | "assistant" | "system";
|
||||||
You'll be able to chat with Hammer right here in the dashboard.
|
content: string;
|
||||||
</p>
|
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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user