Spaces:
Paused
Paused
| "use client"; | |
| import { ChatData, ChatState, Conversation, Message, ResearchOptions, ResearchResults, ResearchTree, StatusUpdate } from "@/lib/types"; | |
| import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; | |
| import { v4 as uuidv4 } from "uuid"; | |
| import { disconnectSocket, getSocket, initializeSocket, initializeSse, closeSse } from "@/lib/socket"; | |
| // Utility functions for local storage | |
| const saveToStorage = (data: ChatData) => { | |
| if (typeof window !== "undefined") { | |
| localStorage.setItem("chatData", JSON.stringify(data)); | |
| } | |
| }; | |
| const loadFromStorage = (): ChatData => { | |
| if (typeof window === "undefined") { | |
| return { conversations: [], currentConversationId: null }; | |
| } | |
| const data = localStorage.getItem("chatData"); | |
| if (!data) { | |
| return { conversations: [], currentConversationId: null }; | |
| } | |
| try { | |
| const parsed = JSON.parse(data); | |
| return { | |
| conversations: Array.isArray(parsed.conversations) | |
| ? parsed.conversations.map((conv: Conversation) => ({ | |
| ...conv, | |
| messages: Array.isArray(conv.messages) | |
| ? conv.messages.map((msg: Message) => ({ | |
| ...msg, | |
| // Ensure media property is preserved if it exists | |
| media: msg.media || undefined, | |
| })) | |
| : [], | |
| })) | |
| : [], | |
| currentConversationId: parsed.currentConversationId, | |
| }; | |
| } catch (e) { | |
| return { conversations: [], currentConversationId: null }; | |
| } | |
| }; | |
| // Define the context type | |
| interface ChatContextType { | |
| // State | |
| chatState: ChatState; | |
| conversations: Conversation[]; | |
| currentConversationId: string | null; | |
| researchOptions: ResearchOptions; | |
| userInputRef: React.RefObject<HTMLTextAreaElement>; | |
| connectionMode: "agent" | "workflow"; | |
| // Actions | |
| setResearchOptions: (options: ResearchOptions) => void; | |
| sendMessage: (content: string) => void; | |
| newConversation: () => void; | |
| selectConversation: (id: string) => void; | |
| deleteConversation: (id: string) => void; | |
| deleteAllConversations: () => void; | |
| abortResearch: () => void; // New function to abort research | |
| setConnectionMode: (mode: "agent" | "workflow") => void; | |
| } | |
| // Create the context with a default value | |
| const ChatContext = createContext<ChatContextType | undefined>(undefined); | |
| // Provider component | |
| export const ChatProvider = ({ children }: { children: ReactNode }) => { | |
| const [chatState, setChatState] = useState<ChatState>({ messages: [], isLoading: false, error: null }); | |
| const [conversations, setConversations] = useState<Conversation[]>([]); | |
| const [currentConversationId, setCurrentConversationId] = useState<string | null>(null); | |
| const [connectionMode, setConnectionMode] = useState<"agent" | "workflow">("agent"); | |
| const [researchOptions, setResearchOptions] = useState<ResearchOptions>({ | |
| depth: "basic", | |
| sources: true, | |
| citations: false, | |
| max_depth: 1, | |
| num_sites_per_query: 3, | |
| create_report: false, | |
| }); | |
| const userInputRef = useRef<HTMLTextAreaElement>(null); | |
| // Focus management | |
| useEffect(() => { | |
| const focusInput = () => { | |
| setTimeout(() => { | |
| userInputRef.current?.focus(); | |
| }, 100); | |
| }; | |
| focusInput(); | |
| }, [currentConversationId]); | |
| // Load initial data | |
| useEffect(() => { | |
| const data = loadFromStorage(); | |
| setConversations(data.conversations); | |
| setCurrentConversationId(data.currentConversationId); | |
| if (data.currentConversationId) { | |
| const conversation = data.conversations.find((c) => c.id === data.currentConversationId); | |
| if (conversation) { | |
| // Check if any loaded message has isProgress true | |
| let messages = conversation.messages; | |
| if (messages.some((msg) => msg.isProgress === true)) { | |
| // Convert any progress messages to error messages | |
| messages = messages.map((msg) => (msg.isProgress === true ? { ...msg, content: "Connection error", isProgress: false } : msg)); | |
| } | |
| setChatState((prev) => ({ ...prev, messages, isLoading: false })); | |
| } | |
| } | |
| }, []); | |
| // Socket initialization - REMOVED as we are using HTTP/SSE for both modes | |
| // useEffect(() => { | |
| // if (connectionMode === "workflow") { | |
| // const socket = initializeSocket(); | |
| // socket.on("connect", () => { | |
| // console.log("Connected to research server"); | |
| // }); | |
| // socket.on("disconnect", () => { | |
| // console.log("Disconnected from research server"); | |
| // // When socket disconnects, update any progress messages to show connection error | |
| // setChatState((prevState) => { | |
| // const updatedMessages = prevState.messages.map((msg) => (msg.isProgress === true ? { ...msg, content: "Connection error", isProgress: false } : msg)); | |
| // return { | |
| // ...prevState, | |
| // messages: updatedMessages, | |
| // isLoading: false, | |
| // error: "Lost connection to research server", | |
| // }; | |
| // }); | |
| // }); | |
| // socket.on("status", (data: StatusUpdate) => { | |
| // setChatState((prevState) => { | |
| // const messages = [...prevState.messages]; | |
| // const progressText = data.message; | |
| // const progress = data.progress; | |
| // // Find the last assistant message that is a progress update | |
| // const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true); | |
| // if (lastProgressIndex !== -1) { | |
| // // Update existing progress message with research_tree data | |
| // messages[lastProgressIndex] = { | |
| // ...messages[lastProgressIndex], | |
| // content: progressText, | |
| // progress: progress, | |
| // timestamp: new Date(), | |
| // research_tree: data.research_tree, // Update the research_tree in real-time | |
| // }; | |
| // } else { | |
| // // Add new progress message with research_tree | |
| // messages.push({ | |
| // id: uuidv4(), | |
| // content: progressText, | |
| // role: "assistant", | |
| // timestamp: new Date(), | |
| // progress: progress, | |
| // isProgress: true, | |
| // research_tree: data.research_tree, // Include the research_tree | |
| // media: {}, // Initialize empty media object | |
| // }); | |
| // } | |
| // return { | |
| // ...prevState, | |
| // messages, | |
| // isLoading: true, | |
| // }; | |
| // }); | |
| // }); | |
| // socket.on("research_complete", (results: ResearchResults) => { | |
| // setChatState((prevState) => { | |
| // const messages = [...prevState.messages]; | |
| // // Remove the last progress message if it exists | |
| // const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true); | |
| // if (lastProgressIndex !== -1) { | |
| // messages.splice(lastProgressIndex, 1); | |
| // } | |
| // const newMessages = [ | |
| // ...messages, | |
| // { | |
| // id: uuidv4(), | |
| // content: results.content || "Error: No content available", | |
| // role: "assistant" as const, | |
| // timestamp: new Date(results.timestamp), | |
| // media: results.media, | |
| // research_tree: results.research_tree, | |
| // }, | |
| // ]; | |
| // // Save updated messages to localStorage | |
| // const updatedState = { | |
| // ...prevState, | |
| // isLoading: false, | |
| // messages: newMessages, | |
| // }; | |
| // // Update localStorage with the new messages | |
| // const updatedData: ChatData = { | |
| // conversations: conversations.map((conv) => ({ | |
| // ...conv, | |
| // messages: conv.id === currentConversationId ? newMessages : conv.messages || [], | |
| // lastUpdated: conv.id === currentConversationId ? new Date().toISOString() : conv.lastUpdated, | |
| // })), | |
| // currentConversationId, | |
| // }; | |
| // saveToStorage(updatedData); | |
| // return updatedState; | |
| // }); | |
| // }); | |
| // socket.on("research_aborted", () => { | |
| // setChatState((prevState) => { | |
| // const messages = [...prevState.messages]; | |
| // const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true); | |
| // if (lastProgressIndex !== -1) { | |
| // messages.splice(lastProgressIndex, 1); | |
| // } | |
| // // Add a message indicating the research was canceled | |
| // messages.push({ | |
| // id: uuidv4(), | |
| // content: "Research has been canceled.", | |
| // role: "assistant", | |
| // timestamp: new Date(), | |
| // }); | |
| // return { | |
| // ...prevState, | |
| // isLoading: false, | |
| // messages, | |
| // }; | |
| // }); | |
| // }); | |
| // socket.on("error", (error: { message: string }) => { | |
| // setChatState((prevState) => ({ | |
| // ...prevState, | |
| // error: error.message, | |
| // isLoading: false, | |
| // })); | |
| // }); | |
| // return () => { | |
| // disconnectSocket(); | |
| // }; | |
| // } | |
| // }, [connectionMode]); | |
| // Save data whenever conversations or messages change | |
| useEffect(() => { | |
| const data: ChatData = { | |
| conversations: conversations.map((conv) => ({ | |
| ...conv, | |
| messages: conv.id === currentConversationId ? chatState.messages : conv.messages || [], | |
| })), | |
| currentConversationId, | |
| }; | |
| saveToStorage(data); | |
| }, [conversations, currentConversationId, chatState.messages]); | |
| // Action handlers | |
| const sendMessage = useCallback( | |
| async (content: string) => { | |
| if (!content.trim()) return; | |
| let conversationId = currentConversationId; | |
| const newMessage: Message = { | |
| id: uuidv4(), | |
| content, | |
| role: "user", | |
| timestamp: new Date(), | |
| }; | |
| const newLoadingMessage: Message = { | |
| id: uuidv4(), | |
| content: "Loading...", | |
| role: "assistant", | |
| timestamp: new Date(), | |
| isProgress: true, | |
| }; | |
| // Create a new conversation if none exists | |
| if (!conversationId) { | |
| conversationId = uuidv4(); | |
| setCurrentConversationId(conversationId); | |
| setConversations((prev) => [ | |
| { | |
| id: conversationId as string, | |
| title: content.length > 30 ? `${content.substring(0, 30)}...` : content, | |
| lastUpdated: new Date().toISOString(), | |
| messages: [newMessage], | |
| active: true, | |
| }, | |
| ...prev.map((c) => ({ ...c, active: false })), | |
| ]); | |
| } else { | |
| // Update the existing conversation | |
| setConversations((prev) => | |
| prev.map((conv) => ({ | |
| ...conv, | |
| lastUpdated: conv.id === conversationId ? new Date().toISOString() : conv.lastUpdated, | |
| active: conv.id === conversationId, | |
| messages: conv.id === conversationId ? [...(conv.messages || []), newMessage] : conv.messages || [], | |
| })) | |
| ); | |
| } | |
| // Add loading message immediately for workflow mode | |
| const messagesToAdd = connectionMode === "workflow" ? [newMessage, newLoadingMessage] : [newMessage]; | |
| setChatState((prevState) => ({ | |
| ...prevState, | |
| messages: [...prevState.messages, ...messagesToAdd], | |
| isLoading: true, | |
| error: null, | |
| })); | |
| try { | |
| const sseUrl = process.env.NEXT_PUBLIC_LANGGRAPH_BACKEND || "http://127.0.0.1:5000"; | |
| const endpoint = connectionMode === "agent" ? "/chat" : "/start_research"; | |
| const body = | |
| connectionMode === "agent" | |
| ? { | |
| message: content, | |
| thread_id: currentConversationId, | |
| create_report: researchOptions.create_report, | |
| } | |
| : { | |
| topic: content, | |
| max_depth: researchOptions.max_depth, | |
| num_sites_per_query: researchOptions.num_sites_per_query, | |
| session_id: currentConversationId, | |
| }; | |
| const response = await fetch(`${sseUrl}${endpoint}`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!response.body) { | |
| return; | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let done = false; | |
| while (!done) { | |
| const { value, done: readerDone } = await reader.read(); | |
| done = readerDone; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| const lines = chunk.split("\n\n"); | |
| for (const line of lines) { | |
| if (line.startsWith("data: ")) { | |
| const jsonStr = line.substring(6); | |
| if (jsonStr) { | |
| try { | |
| const data = JSON.parse(jsonStr); | |
| if (data.event === "progress") { | |
| setChatState((prevState) => { | |
| const messages = [...prevState.messages]; | |
| if (connectionMode === "agent") { | |
| const eventData = data.data[0]; // Assuming data.data is an array with one element | |
| if (eventData.type === "ai_msg" || eventData.type === "ai_msg_report") { | |
| messages.push({ | |
| id: uuidv4(), | |
| content: eventData.content, | |
| role: "assistant", | |
| timestamp: new Date(), | |
| tool_calls: eventData.tool_calls, // Store tool calls | |
| }); | |
| } else if (eventData.type === "tool_resp") { | |
| // Do not display tool responses directly in chat | |
| return prevState; | |
| } | |
| } else { | |
| // Workflow mode progress | |
| const eventData = data.data; | |
| const progress = eventData.progress; | |
| const progressText = eventData.message; | |
| // Find the last progress message | |
| const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true); | |
| if (lastProgressIndex !== -1) { | |
| // Update existing progress message with research_tree data | |
| messages[lastProgressIndex] = { | |
| ...messages[lastProgressIndex], | |
| content: progressText, | |
| progress: progress, | |
| timestamp: new Date(), | |
| research_tree: eventData.research_tree, // Update the research_tree in real-time | |
| }; | |
| } else { | |
| // Add new progress message with research_tree | |
| messages.push({ | |
| id: uuidv4(), | |
| content: progressText, | |
| role: "assistant", | |
| timestamp: new Date(), | |
| progress: progress, | |
| isProgress: true, | |
| research_tree: eventData.research_tree, // Include the research_tree | |
| media: {}, // Initialize empty media object | |
| }); | |
| } | |
| } | |
| return { | |
| ...prevState, | |
| messages, | |
| isLoading: true, // Keep loading until stream ends | |
| }; | |
| }); | |
| } else if (data.event === "result") { | |
| setChatState((prevState) => { | |
| const messages = [...prevState.messages]; | |
| // Remove the last progress message if it exists | |
| const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true); | |
| if (lastProgressIndex !== -1) { | |
| messages.splice(lastProgressIndex, 1); | |
| } | |
| const resultData = connectionMode === "agent" ? data.data : data.data; // Both seem to be object in result event? | |
| // Wait, agent mode result handling was: | |
| // content: data.data.content || "Error: No content available", | |
| // media: data.data.media, | |
| // research_tree: data.data.research_tree, | |
| // | |
| // Workflow mode result handling was: | |
| // content: results.content || "Error: No content available", | |
| // media: results.media, | |
| // research_tree: results.research_tree, | |
| // | |
| // They look compatible. | |
| const newMessages = [ | |
| ...messages, | |
| { | |
| id: uuidv4(), | |
| content: resultData.content || "Error: No content available", | |
| role: "assistant" as const, | |
| timestamp: new Date(resultData.timestamp || new Date()), | |
| media: resultData.media, | |
| research_tree: resultData.research_tree, | |
| }, | |
| ]; | |
| // Save updated messages to localStorage | |
| const updatedState = { | |
| ...prevState, | |
| isLoading: false, | |
| messages: newMessages, | |
| }; | |
| // Update localStorage with the new messages | |
| const updatedData: ChatData = { | |
| conversations: conversations.map((conv) => ({ | |
| ...conv, | |
| messages: conv.id === currentConversationId ? newMessages : conv.messages || [], | |
| lastUpdated: conv.id === currentConversationId ? new Date().toISOString() : conv.lastUpdated, | |
| })), | |
| currentConversationId, | |
| }; | |
| saveToStorage(updatedData); | |
| return updatedState; | |
| }); | |
| } | |
| } catch (e) { | |
| console.error("Failed to parse SSE data", e); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| setChatState((prevState) => ({ | |
| ...prevState, | |
| error: "Failed to connect to server", | |
| isLoading: false, | |
| })); | |
| } | |
| }, | |
| [currentConversationId, conversations, researchOptions, connectionMode] | |
| ); | |
| const newConversation = useCallback(() => { | |
| userInputRef.current?.focus(); | |
| setCurrentConversationId(null); | |
| setChatState(() => ({ | |
| messages: [], | |
| isLoading: false, | |
| error: null, | |
| })); | |
| }, []); | |
| const selectConversation = useCallback((id: string) => { | |
| const data = loadFromStorage(); | |
| const conversation = data.conversations.find((c) => c.id === id); | |
| setCurrentConversationId(id); | |
| setChatState((prev) => ({ | |
| ...prev, | |
| messages: conversation?.messages || [], | |
| isLoading: false, | |
| error: null, | |
| })); | |
| setConversations((prev) => | |
| prev.map((conv) => ({ | |
| ...conv, | |
| active: conv.id === id, | |
| })) | |
| ); | |
| }, []); | |
| const deleteConversation = useCallback( | |
| (id: string) => { | |
| setConversations((prev) => prev.filter((conv) => conv.id !== id)); | |
| if (currentConversationId === id) { | |
| newConversation(); | |
| } | |
| }, | |
| [currentConversationId, newConversation] | |
| ); | |
| const deleteAllConversations = useCallback(() => { | |
| setConversations([]); | |
| newConversation(); | |
| }, [newConversation]); | |
| const abortResearch = useCallback(async () => { | |
| try { | |
| const sseUrl = process.env.NEXT_PUBLIC_LANGGRAPH_BACKEND || "http://127.0.0.1:5000"; | |
| await fetch(`${sseUrl}/abort_research`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ session_id: currentConversationId }), | |
| }); | |
| setChatState((prevState) => { | |
| const messages = [...prevState.messages]; | |
| const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true); | |
| if (lastProgressIndex !== -1) { | |
| messages.splice(lastProgressIndex, 1); | |
| } | |
| // Add a message indicating the research was canceled | |
| messages.push({ | |
| id: uuidv4(), | |
| content: "Research has been canceled.", | |
| role: "assistant", | |
| timestamp: new Date(), | |
| }); | |
| return { | |
| ...prevState, | |
| isLoading: false, | |
| messages, | |
| }; | |
| }); | |
| } catch (error) { | |
| console.error("Failed to abort research:", error); | |
| setChatState((prevState) => ({ | |
| ...prevState, | |
| error: "Failed to abort research", | |
| })); | |
| } | |
| }, [currentConversationId]); | |
| // Keyboard shortcuts | Ctrl + I to new chat | |
| useEffect(() => { | |
| const handleKeyDown = (event: KeyboardEvent) => { | |
| if (event.ctrlKey && event.key === "i") { | |
| event.preventDefault(); | |
| newConversation(); | |
| } | |
| }; | |
| window.addEventListener("keydown", handleKeyDown); | |
| return () => { | |
| window.removeEventListener("keydown", handleKeyDown); | |
| }; | |
| }, []); | |
| const value = { | |
| chatState, | |
| conversations, | |
| currentConversationId, | |
| researchOptions, | |
| userInputRef, | |
| connectionMode, | |
| setResearchOptions, | |
| sendMessage, | |
| newConversation, | |
| selectConversation, | |
| deleteConversation, | |
| deleteAllConversations, | |
| abortResearch, | |
| setConnectionMode, | |
| }; | |
| return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>; | |
| }; | |
| // Custom hook for using the chat context | |
| export const useChatContext = () => { | |
| const context = useContext(ChatContext); | |
| if (context === undefined) { | |
| throw new Error("useChatContext must be used within a ChatProvider"); | |
| } | |
| return context; | |
| }; | |