diff love/epi/src/hooks/useChatWebsocket.ts @ 38:cf9caa4abc3e

[Love] FE and BE. Can chat and render images. Also created MCP for powerpoint generations.
author MrJuneJune <me@mrjunejune.com>
date Mon, 01 Dec 2025 20:35:56 -0800
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/love/epi/src/hooks/useChatWebsocket.ts	Mon Dec 01 20:35:56 2025 -0800
@@ -0,0 +1,177 @@
+import { wsUrl } from '@/utils';
+import { useEffect } from 'react';
+
+export type Payload = {
+  chatId: string;
+  content: string;
+  action: 'append' | 'done';
+} | {
+  chatId: string;
+  title: string;
+  action:'title_updated';
+} | {
+  chatId: string;
+  url: string;
+  action:'image';
+}
+
+export type OnMessage = (payload: Payload) => void;
+
+type WebSocketEvent = {
+  chatId: string;
+  payload: Payload;
+};
+
+// TODO: Make this into class so we can mock for test.
+const wsSingleton = new Map<string, WebSocket>();
+const listeners = new Map<string, Set<OnMessage>>();
+const pendingMessages = new Map<string, Array<{ content: string; resolve: () => void }>>();
+
+function getOrCreateWebSocket(chatId: string): WebSocket {
+  let ws = wsSingleton.get(chatId);
+  
+  if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+    return ws;
+  }
+
+  ws = new WebSocket(wsUrl(`/chats/${chatId}/ws`));
+
+  ws.onopen = () => {
+    console.log(`[WS] Connected for chat ${chatId}`);
+    const queue = pendingMessages.get(chatId);
+    if (queue) {
+      queue.forEach(({ content }) => {
+        ws!.send(JSON.stringify({ role: 'user', content }));
+      });
+      pendingMessages.delete(chatId);
+    }
+  };
+
+  // ADD IT HERE:
+  ws.onmessage = (event) => {
+    const payload = JSON.parse(event.data);
+    console.log('[WS] Received message:', payload);
+    
+    wsEventTarget.dispatchEvent(
+      new CustomEvent('message', {
+        detail: { chatId, payload },
+      }),
+    );
+  };
+
+  ws.onerror = (error) => {
+    console.error(`[WS] Error for chat ${chatId}:`, error);
+  };
+
+  ws.onclose = () => {
+    console.log(`[WS] Closed for chat ${chatId}`);
+    wsSingleton.delete(chatId);
+  };
+
+  wsSingleton.set(chatId, ws);
+  return ws;
+}
+
+// TODO: This could be not done rather cancel?
+export function broadcastDone(chatId: string) {
+  const set = listeners.get(chatId);
+  // TODO: This does not update the histroy to be canceled on so there is decrepency but it should be fine for now.
+  if (set) {
+    set.forEach((cb) => cb({ chatId, content: '', action: 'done' }));
+  }
+}
+
+export function useChatWebSocket(
+  chatId: string | null,
+  onMessage: OnMessage,
+  onLoadingChange: (loading: boolean) => void,
+) {
+
+  useEffect(() => {
+    if (!chatId) return;
+
+    // Only register listener to given chatId as chatId websocket has not been made yet. 
+    let set = listeners.get(chatId);
+    if (!set) {
+      set = new Set();
+      listeners.set(chatId, set);
+    }
+    set.add(onMessage);
+
+    return () => {
+      if (!chatId) return;
+      const set = listeners.get(chatId);
+      if (set) {
+        set.delete(onMessage);
+        if (set.size === 0) {
+          listeners.delete(chatId);
+          const ws = wsSingleton.get(chatId);
+          if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+            ws.close();
+          }
+        }
+      }
+    };
+  }, [chatId, onMessage]);
+
+  const sendMessage = (content: string) => {
+    if (!chatId) return;
+
+    const ws = getOrCreateWebSocket(chatId);
+
+    onLoadingChange(true);
+
+    if (ws.readyState === WebSocket.OPEN) {
+      ws.send(JSON.stringify({ role: 'user', content }));
+    } else if (ws.readyState === WebSocket.CONNECTING) {
+      // Queue message until open
+      let queue = pendingMessages.get(chatId);
+      if (!queue) {
+        queue = [];
+        pendingMessages.set(chatId, queue);
+      }
+      console.log('Queue: ', queue);
+      if (queue.length > 1) return;
+      queue.push({ content, resolve: () => {} });
+    } else {
+      console.warn('[WS] WebSocket not in usable state', ws.readyState);
+      onLoadingChange(false);
+    }
+  };
+
+  return { sendMessage };
+}
+
+const wsEventTarget = new EventTarget();
+
+export function subscribeToChat(chatId: string, callback: (payload: Payload) => void) {
+  const handler = (event: Event) => {
+    const { chatId: eventChatId, payload } = (event as CustomEvent<WebSocketEvent>).detail;
+    if (eventChatId === chatId) {
+      callback(payload);
+    }
+  };
+  
+  wsEventTarget.addEventListener('message', handler);
+  
+  return () => wsEventTarget.removeEventListener('message', handler);
+}
+
+export function sendMessageChatId(content: string, chatId: string): void {
+  if (!content.trim() || !chatId) return;
+  
+  const ws = getOrCreateWebSocket(chatId);
+  
+  if (ws.readyState === WebSocket.OPEN) {
+    ws.send(JSON.stringify({ role: 'user', content }));
+  } else if (ws.readyState === WebSocket.CONNECTING) {
+    let queue = pendingMessages.get(chatId);
+    if (!queue) {
+      queue = [];
+      pendingMessages.set(chatId, queue);
+    }
+    if (!queue.some(m => m.content === content)) {
+      queue.push({ content, resolve: () => {} });
+    }
+  }
+}