theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 }