ChatView.tsx (15435B)
1 import { useEffect, useRef, useCallback, useState } from "react"; 2 import { useChatStore, type Message, type AttachmentInfo } from "../../store/chat"; 3 import { useSessionStore } from "../../store/session"; 4 import { uploadAttachment, type AttachmentResponse, api } from "../../lib/api"; 5 import { WsClient, type WsMessage } from "../../lib/ws"; 6 import { MessageList } from "../Chat/MessageList"; 7 import { Composer } from "../Composer/Composer"; 8 import { ModelPicker, type Model } from "../Composer/ModelPicker"; 9 import { BranchIndicator, type Branch } from "../BranchIndicator/BranchIndicator"; 10 import { migrateDraft } from "../../hooks/useDraft"; 11 12 type ConnectionState = "connecting" | "connected" | "error"; 13 14 const _ATTACHMENT_PATH_RE = 15 /(?:^|\n)(?:\/|\.)?[^\n]*\/attachments\/[^\n]*\.(?:png|jpg|jpeg|gif|webp|svg|bmp|pdf|txt|docx?|xlsx?|csv|json|md)(?:\n|$)/gi; 16 17 function stripAttachmentPaths(content: string): string { 18 return content.replace(_ATTACHMENT_PATH_RE, "").trim(); 19 } 20 21 function matchAttachmentsToMessages( 22 messages: Message[], 23 attachments: AttachmentInfo[], 24 ): void { 25 // Match attachments to user messages by timestamp proximity (±10s). 26 // Each attachment is claimed by at most one message (first-come-first-served). 27 const claimed = new Set<string>(); 28 const userMessages = messages.filter((m) => m.role === "user"); 29 for (const att of attachments) { 30 for (const msg of userMessages) { 31 if (claimed.has(att.id)) break; 32 const diff = Math.abs((msg.timestamp ?? 0) - att.created_at); 33 if (diff <= 10) { 34 if (!msg.attachments) msg.attachments = []; 35 msg.attachments.push(att); 36 claimed.add(att.id); 37 } 38 } 39 } 40 } 41 42 export function ChatView() { 43 const { 44 messages, streaming, thinkingSessionId, addMessage, updateMessageContent, 45 addToolCall, updateToolCall, setStreaming, setThinkingSessionId, setMessages, 46 } = useChatStore(); 47 const { activeSessionId, createSession, listSessions, updateSession, incrementMessageCount, setActiveSession } = useSessionStore(); 48 const wsRef = useRef<WsClient | null>(null); 49 const [connectionState, setConnectionState] = useState<ConnectionState>("connecting"); 50 const [, setUploadErrors] = useState<Map<string, string>>(new Map()); 51 const [branches, setBranches] = useState<Branch[]>([]); 52 const [models, setModels] = useState<Model[]>([]); 53 const [selectedModel, setSelectedModel] = useState<string | null>(null); 54 55 const loadBranches = useCallback(async () => { 56 if (!activeSessionId) return; 57 try { 58 const data = await api.get<{ branches: Branch[] }>( 59 `/sessions/${activeSessionId}/branches`, 60 ); 61 setBranches(data.branches ?? []); 62 } catch { 63 setBranches([]); 64 } 65 }, [activeSessionId]); 66 67 const loadModels = useCallback(async () => { 68 try { 69 const data = await api.get<{ models: Model[] }>("/models"); 70 setModels(data.models ?? []); 71 } catch { 72 setModels([]); 73 } 74 }, []); 75 76 // Load messages when the active session changes 77 useEffect(() => { 78 if (!activeSessionId) { 79 setMessages([]); 80 return; 81 } 82 setMessages([]); 83 api 84 .get<{ messages: Message[]; attachments: AttachmentInfo[] }>( 85 `/sessions/${activeSessionId}/messages`, 86 ) 87 .then((data) => { 88 const msgs = data.messages ?? []; 89 const atts = data.attachments ?? []; 90 if (atts.length > 0) { 91 matchAttachmentsToMessages(msgs, atts); 92 } 93 // Strip file paths from user message content when attachments are shown 94 for (const msg of msgs) { 95 if (msg.role === "user" && msg.attachments && msg.attachments.length > 0) { 96 msg.content = stripAttachmentPaths(msg.content); 97 } 98 } 99 setMessages(msgs); 100 }) 101 .catch(() => { 102 // ignore load errors 103 }); 104 }, [activeSessionId, setMessages]); 105 106 useEffect(() => { 107 loadBranches(); 108 loadModels(); 109 }, [loadBranches, loadModels]); 110 111 const handleFork = useCallback(async () => { 112 if (!activeSessionId) return; 113 try { 114 const result = await api.post<{ id: string }>( 115 `/sessions/${activeSessionId}/fork`, 116 ); 117 await loadBranches(); 118 return result.id; 119 } catch { 120 return undefined; 121 } 122 }, [activeSessionId, loadBranches]); 123 124 const handleForkMessage = useCallback(async () => { 125 await handleFork(); 126 }, [handleFork]); 127 128 const handleSwitchBranch = useCallback(async (branchSessionId: string) => { 129 setMessages([]); 130 try { 131 const data = await api.get<{ messages: Message[] }>( 132 `/sessions/${branchSessionId}/messages`, 133 ); 134 setMessages(data.messages ?? []); 135 } catch { 136 // ignore load errors 137 } 138 }, [setMessages]); 139 140 const handleWsMessage = useCallback( 141 (msg: WsMessage) => { 142 // Connection status handshake 143 if (msg.type === "status" && (msg.data as Record<string, unknown>)?.connected) { 144 setConnectionState("connected"); 145 return; 146 } 147 148 const data = msg.data as Record<string, unknown> | undefined; 149 switch (msg.type) { 150 case "run.started": 151 setThinkingSessionId(null); 152 setStreaming(true); 153 break; 154 case "message.started": { 155 if (data && typeof data === "object" && "id" in data && "role" in data) { 156 addMessage({ 157 id: data.id as string, 158 role: (data.role as Message["role"]) ?? "assistant", 159 content: "", 160 toolCalls: [], 161 timestamp: Date.now(), 162 model: (data.model as string) ?? undefined, 163 }); 164 } 165 break; 166 } 167 case "assistant.delta": { 168 if (data && typeof data === "object" && "id" in data && "content" in data) { 169 const msgId = data.id as string; 170 const content = data.content as string; 171 const existing = useChatStore.getState().messages.find((m) => m.id === msgId); 172 if (existing) { 173 updateMessageContent(msgId, existing.content + content); 174 } 175 } 176 break; 177 } 178 case "assistant.completed": { 179 if (data && typeof data === "object" && "id" in data) { 180 if ("content" in data) { 181 updateMessageContent(data.id as string, data.content as string); 182 } 183 } 184 break; 185 } 186 case "tool.started": { 187 if (data && typeof data === "object" && "messageId" in data && "toolCall" in data) { 188 const tc = data.toolCall as { id: string; name: string; args: string }; 189 addToolCall(data.messageId as string, { 190 id: tc.id, 191 name: tc.name, 192 args: tc.args, 193 result: null, 194 status: "running", 195 }); 196 } 197 break; 198 } 199 case "tool.completed": { 200 if (data && typeof data === "object" && "messageId" in data && "toolCallId" in data) { 201 updateToolCall(data.messageId as string, data.toolCallId as string, { 202 status: "complete", 203 result: (data.result as string) ?? null, 204 }); 205 } 206 break; 207 } 208 case "tool.failed": { 209 if (data && typeof data === "object" && "messageId" in data && "toolCallId" in data) { 210 updateToolCall(data.messageId as string, data.toolCallId as string, { 211 status: "error", 212 result: (data.error as string) ?? "Tool failed", 213 }); 214 } 215 break; 216 } 217 case "tool.progress": { 218 if (data && typeof data === "object" && "messageId" in data && "toolCallId" in data) { 219 updateToolCall(data.messageId as string, data.toolCallId as string, { 220 result: (data.progress as string) ?? null, 221 }); 222 } 223 break; 224 } 225 case "run.completed": { 226 setStreaming(false); 227 // Link tool results from turn messages included in run.completed. 228 // The Hermes agent's tool.completed SSE event doesn't include the 229 // result (preview=None in tool_executor callback), but run.completed 230 // carries the full turn transcript with tool results. 231 const turnMessages = data?.messages; 232 if (Array.isArray(turnMessages)) { 233 const store = useChatStore.getState(); 234 for (const turnMsg of turnMessages) { 235 if ( 236 turnMsg && 237 typeof turnMsg === "object" && 238 (turnMsg as Record<string, unknown>).role === "tool" && 239 (turnMsg as Record<string, unknown>).tool_call_id 240 ) { 241 const tcId = (turnMsg as Record<string, unknown>).tool_call_id as string; 242 const content = (turnMsg as Record<string, unknown>).content as string | undefined; 243 for (const chatMsg of store.messages) { 244 const tc = chatMsg.toolCalls.find((t) => t.id === tcId); 245 if (tc && !tc.result) { 246 updateToolCall(chatMsg.id, tc.id, { 247 result: content ?? null, 248 }); 249 } 250 } 251 } 252 } 253 } 254 break; 255 } 256 default: 257 break; 258 } 259 }, 260 [addMessage, updateMessageContent, addToolCall, updateToolCall, setStreaming, setThinkingSessionId], 261 ); 262 263 useEffect(() => { 264 const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; 265 const ws = new WsClient(`${protocol}//${window.location.host}/ws/chat`); 266 ws.onMessage(handleWsMessage); 267 ws.connect(); 268 wsRef.current = ws; 269 270 // Set error state if connection fails (onclose without destroy) 271 const origOnClose = ws["ws"]?.onclose; 272 if (ws["ws"]) { 273 ws["ws"]!.onclose = (ev) => { 274 setConnectionState("error"); 275 origOnClose?.call(ws["ws"]!, ev); 276 }; 277 } 278 279 return () => { 280 ws.destroy(); 281 }; 282 }, [handleWsMessage]); 283 284 const handleUpload = useCallback( 285 async (files: File[]): Promise<AttachmentResponse[]> => { 286 // Ensure session exists before uploading so attachments are stored 287 // with the correct session ID (not 'default'). 288 let sessionId = activeSessionId; 289 if (!sessionId) { 290 try { 291 const session = await createSession(); 292 if (!session) return []; 293 sessionId = session.id; 294 setActiveSession(sessionId); 295 migrateDraft(null, sessionId); 296 await listSessions(); 297 } catch { 298 return []; 299 } 300 } 301 const results: AttachmentResponse[] = []; 302 const errors = new Map<string, string>(); 303 for (const file of files) { 304 try { 305 const result: AttachmentResponse = await uploadAttachment(file, sessionId); 306 results.push(result); 307 } catch (err) { 308 const message = err instanceof Error ? err.message : "Upload failed"; 309 errors.set(file.name, message); 310 } 311 } 312 if (errors.size > 0) setUploadErrors(errors); 313 return results; 314 }, 315 [activeSessionId, createSession, listSessions, setActiveSession], 316 ); 317 318 const handleSend = useCallback( 319 async (text: string, uploads?: AttachmentResponse[]) => { 320 // Read activeSessionId from store directly (not closure) to avoid 321 // stale value when handleUpload just created a session. 322 let sessionId = useSessionStore.getState().activeSessionId; 323 if (!sessionId) { 324 if (uploads && uploads.length > 0) { 325 // Attachments were uploaded — session must exist already 326 return; 327 } 328 try { 329 const session = await createSession(); 330 if (!session) return; 331 sessionId = session.id; 332 setActiveSession(sessionId); 333 migrateDraft(null, sessionId); 334 await listSessions(); 335 } catch { 336 return; 337 } 338 } 339 340 const attachmentIds = uploads?.map((u) => u.id); 341 const attachments: AttachmentInfo[] | undefined = uploads?.map((u) => ({ 342 id: u.id, 343 filename: u.filename, 344 content_type: u.content_type, 345 created_at: u.created_at, 346 })); 347 348 addMessage({ 349 id: `user-${Date.now()}`, 350 role: "user", 351 content: text, 352 toolCalls: [], 353 timestamp: Date.now(), 354 attachments, 355 }); 356 wsRef.current?.send("chat", { 357 session_id: sessionId, 358 content: text, 359 attachments: attachmentIds, 360 model: selectedModel, 361 }); 362 setThinkingSessionId(sessionId); 363 incrementMessageCount(sessionId); 364 365 // Auto-name session from first message 366 const sessions = useSessionStore.getState().sessions; 367 const currentSession = sessions?.find(s => s.id === sessionId); 368 if (currentSession && !currentSession.name) { 369 const title = text.length > 50 ? text.slice(0, 50) + "…" : text; 370 updateSession(sessionId, { name: title }); 371 } 372 }, 373 [activeSessionId, addMessage, selectedModel, createSession, listSessions, updateSession, incrementMessageCount, setThinkingSessionId], 374 ); 375 376 const statusBanner = (() => { 377 switch (connectionState) { 378 case "connecting": 379 return ( 380 <div className="flex items-center justify-center gap-2 bg-yellow-900/30 px-4 py-1.5 text-xs text-yellow-400"> 381 <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-yellow-400" /> 382 Connecting to Hermes... 383 </div> 384 ); 385 case "error": 386 return ( 387 <div className="flex items-center justify-center gap-2 bg-red-900/30 px-4 py-1.5 text-xs text-red-400"> 388 <span className="inline-block h-2 w-2 rounded-full bg-red-400" /> 389 Disconnected — check Hermes is running 390 </div> 391 ); 392 case "connected": 393 return streaming ? ( 394 <div className="flex items-center justify-center gap-2 bg-blue-900/30 px-4 py-1.5 text-xs text-blue-400"> 395 <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-400" /> 396 Processing... 397 </div> 398 ) : null; 399 } 400 })(); 401 402 return ( 403 <div className="flex min-h-0 flex-1 flex-col"> 404 <div className="flex items-center gap-2 border-b border-gray-800 px-4 py-2"> 405 <BranchIndicator 406 branches={branches} 407 currentSessionId={activeSessionId ?? ""} 408 onSwitchBranch={handleSwitchBranch} 409 /> 410 <div className="ml-auto"> 411 <ModelPicker 412 models={models} 413 selectedModel={selectedModel} 414 onSelect={setSelectedModel} 415 /> 416 </div> 417 </div> 418 <MessageList 419 messages={messages} 420 onForkMessage={handleForkMessage} 421 showForkButton={!!activeSessionId} 422 thinking={thinkingSessionId !== null && thinkingSessionId === activeSessionId} 423 /> 424 {messages.length === 0 && !streaming && (thinkingSessionId === null || thinkingSessionId !== activeSessionId) && ( 425 <div className="flex flex-1 items-center justify-center"> 426 <p className="text-sm text-gray-600">Start a new conversation...</p> 427 </div> 428 )} 429 <Composer 430 onSend={handleSend} 431 onUpload={handleUpload} 432 disabled={streaming} 433 sessionId={activeSessionId} 434 /> 435 {statusBanner} 436 </div> 437 ); 438 }