'use client'; import { Message } from '@/components/ChatWindow'; import { Block } from '@/lib/types'; import { createContext, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import crypto from 'crypto'; import { useParams, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { getSuggestions } from '../actions'; import { MinimalProvider } from '../models/types'; import { getAutoMediaSearch } from '../config/clientRegistry'; import { applyPatch } from 'rfc6902'; import { Widget } from '@/components/ChatWindow'; export type Section = { message: Message; widgets: Widget[]; parsedTextBlocks: string[]; speechMessage: string; thinkingEnded: boolean; suggestions?: string[]; }; type ChatContext = { messages: Message[]; sections: Section[]; chatHistory: [string, string][]; files: File[]; fileIds: string[]; sources: string[]; chatId: string | undefined; optimizationMode: string; isMessagesLoaded: boolean; loading: boolean; notFound: boolean; messageAppeared: boolean; isReady: boolean; hasError: boolean; chatModelProvider: ChatModelProvider; embeddingModelProvider: EmbeddingModelProvider; researchEnded: boolean; setResearchEnded: (ended: boolean) => void; setOptimizationMode: (mode: string) => void; setSources: (sources: string[]) => void; setFiles: (files: File[]) => void; setFileIds: (fileIds: string[]) => void; sendMessage: ( message: string, messageId?: string, rewrite?: boolean, ) => Promise; rewrite: (messageId: string) => void; setChatModelProvider: (provider: ChatModelProvider) => void; setEmbeddingModelProvider: (provider: EmbeddingModelProvider) => void; }; export interface File { fileName: string; fileExtension: string; fileId: string; } interface ChatModelProvider { key: string; providerId: string; } interface EmbeddingModelProvider { key: string; providerId: string; } const checkConfig = async ( setChatModelProvider: (provider: ChatModelProvider) => void, setEmbeddingModelProvider: (provider: EmbeddingModelProvider) => void, setIsConfigReady: (ready: boolean) => void, setHasError: (hasError: boolean) => void, ) => { try { let chatModelKey = localStorage.getItem('chatModelKey'); let chatModelProviderId = localStorage.getItem('chatModelProviderId'); let embeddingModelKey = localStorage.getItem('embeddingModelKey'); let embeddingModelProviderId = localStorage.getItem( 'embeddingModelProviderId', ); const res = await fetch(`/api/providers`, { headers: { 'Content-Type': 'application/json', }, }); if (!res.ok) { throw new Error( `Provider fetching failed with status code ${res.status}`, ); } const data = await res.json(); const providers: MinimalProvider[] = data.providers; if (providers.length === 0) { throw new Error( 'No chat model providers found, please configure them in the settings page.', ); } const chatModelProvider = providers.find((p) => p.id === chatModelProviderId) ?? providers.find((p) => p.chatModels.length > 0); if (!chatModelProvider) { throw new Error( 'No chat models found, pleae configure them in the settings page.', ); } chatModelProviderId = chatModelProvider.id; const chatModel = chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ?? chatModelProvider.chatModels[0]; chatModelKey = chatModel.key; const embeddingModelProvider = providers.find((p) => p.id === embeddingModelProviderId) ?? providers.find((p) => p.embeddingModels.length > 0); if (!embeddingModelProvider) { throw new Error( 'No embedding models found, pleae configure them in the settings page.', ); } embeddingModelProviderId = embeddingModelProvider.id; const embeddingModel = embeddingModelProvider.embeddingModels.find( (m) => m.key === embeddingModelKey, ) ?? embeddingModelProvider.embeddingModels[0]; embeddingModelKey = embeddingModel.key; localStorage.setItem('chatModelKey', chatModelKey); localStorage.setItem('chatModelProviderId', chatModelProviderId); localStorage.setItem('embeddingModelKey', embeddingModelKey); localStorage.setItem('embeddingModelProviderId', embeddingModelProviderId); setChatModelProvider({ key: chatModelKey, providerId: chatModelProviderId, }); setEmbeddingModelProvider({ key: embeddingModelKey, providerId: embeddingModelProviderId, }); setIsConfigReady(true); } catch (err: any) { console.error('An error occurred while checking the configuration:', err); toast.error(err.message); setIsConfigReady(false); setHasError(true); } }; const loadMessages = async ( chatId: string, setMessages: (messages: Message[]) => void, setIsMessagesLoaded: (loaded: boolean) => void, setChatHistory: (history: [string, string][]) => void, setSources: (sources: string[]) => void, setNotFound: (notFound: boolean) => void, setFiles: (files: File[]) => void, setFileIds: (fileIds: string[]) => void, ) => { const res = await fetch(`/api/chats/${chatId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (res.status === 404) { setNotFound(true); setIsMessagesLoaded(true); return; } const data = await res.json(); const messages = data.messages as Message[]; setMessages(messages); const history: [string, string][] = []; messages.forEach((msg) => { history.push(['human', msg.query]); const textBlocks = msg.responseBlocks .filter( (block): block is Block & { type: 'text' } => block.type === 'text', ) .map((block) => block.data) .join('\n'); if (textBlocks) { history.push(['assistant', textBlocks]); } }); console.debug(new Date(), 'app:messages_loaded'); if (messages.length > 0) { document.title = messages[0].query; } const files = data.chat.files.map((file: any) => { return { fileName: file.name, fileExtension: file.name.split('.').pop(), fileId: file.fileId, }; }); setFiles(files); setFileIds(files.map((file: File) => file.fileId)); setChatHistory(history); setSources(data.chat.sources); setIsMessagesLoaded(true); }; export const chatContext = createContext({ chatHistory: [], chatId: '', fileIds: [], files: [], sources: [], hasError: false, isMessagesLoaded: false, isReady: false, loading: false, messageAppeared: false, messages: [], sections: [], notFound: false, optimizationMode: '', chatModelProvider: { key: '', providerId: '' }, embeddingModelProvider: { key: '', providerId: '' }, researchEnded: false, rewrite: () => {}, sendMessage: async () => {}, setFileIds: () => {}, setFiles: () => {}, setSources: () => {}, setOptimizationMode: () => {}, setChatModelProvider: () => {}, setEmbeddingModelProvider: () => {}, setResearchEnded: () => {}, }); export const ChatProvider = ({ children }: { children: React.ReactNode }) => { const params: { chatId: string } = useParams(); const searchParams = useSearchParams(); const initialMessage = searchParams.get('q'); const [chatId, setChatId] = useState(params.chatId); const [newChatCreated, setNewChatCreated] = useState(false); const [loading, setLoading] = useState(false); const [messageAppeared, setMessageAppeared] = useState(false); const [researchEnded, setResearchEnded] = useState(false); const [chatHistory, setChatHistory] = useState<[string, string][]>([]); const [messages, setMessages] = useState([]); const [files, setFiles] = useState([]); const [fileIds, setFileIds] = useState([]); const [sources, setSources] = useState(['web']); const [optimizationMode, setOptimizationMode] = useState('speed'); const [isMessagesLoaded, setIsMessagesLoaded] = useState(false); const [notFound, setNotFound] = useState(false); const [chatModelProvider, setChatModelProvider] = useState( { key: '', providerId: '', }, ); const [embeddingModelProvider, setEmbeddingModelProvider] = useState({ key: '', providerId: '', }); const [isConfigReady, setIsConfigReady] = useState(false); const [hasError, setHasError] = useState(false); const [isReady, setIsReady] = useState(false); const messagesRef = useRef([]); const sections = useMemo(() => { return messages.map((msg) => { const textBlocks: string[] = []; let speechMessage = ''; let thinkingEnded = false; let suggestions: string[] = []; const sourceBlocks = msg.responseBlocks.filter( (block): block is Block & { type: 'source' } => block.type === 'source', ); const sources = sourceBlocks.flatMap((block) => block.data); const widgetBlocks = msg.responseBlocks .filter((b) => b.type === 'widget') .map((b) => b.data) as Widget[]; msg.responseBlocks.forEach((block) => { if (block.type === 'text') { let processedText = block.data; const citationRegex = /\[([^\]]+)\]/g; const regex = /\[(\d+)\]/g; if (processedText.includes('')) { const openThinkTag = processedText.match(//g)?.length || 0; const closeThinkTag = processedText.match(/<\/think>/g)?.length || 0; if (openThinkTag && !closeThinkTag) { processedText += ' '; } } if (block.data.includes('')) { thinkingEnded = true; } if (sources.length > 0) { processedText = processedText.replace( citationRegex, (_, capturedContent: string) => { const numbers = capturedContent .split(',') .map((numStr) => numStr.trim()); const linksHtml = numbers .map((numStr) => { const number = parseInt(numStr); if (isNaN(number) || number <= 0) { return `[${numStr}]`; } const source = sources[number - 1]; const url = source?.metadata?.url; if (url) { return `${numStr}`; } else { return ``; } }) .join(''); return linksHtml; }, ); speechMessage += block.data.replace(regex, ''); } else { processedText = processedText.replace(regex, ''); speechMessage += block.data.replace(regex, ''); } textBlocks.push(processedText); } else if (block.type === 'suggestion') { suggestions = block.data; } }); return { message: msg, parsedTextBlocks: textBlocks, speechMessage, thinkingEnded, suggestions, widgets: widgetBlocks, }; }); }, [messages]); const checkReconnect = async () => { setIsReady(true); console.debug(new Date(), 'app:ready'); if (messages.length > 0) { const lastMsg = messages[messages.length - 1]; if (lastMsg.status === 'answering') { setLoading(true); setResearchEnded(false); setMessageAppeared(false); const res = await fetch(`/api/reconnect/${lastMsg.backendId}`, { method: 'POST', }); if (!res.body) throw new Error('No response body'); const reader = res.body?.getReader(); const decoder = new TextDecoder('utf-8'); let partialChunk = ''; const messageHandler = getMessageHandler(lastMsg); while (true) { const { value, done } = await reader.read(); if (done) break; partialChunk += decoder.decode(value, { stream: true }); try { const messages = partialChunk.split('\n'); for (const msg of messages) { if (!msg.trim()) continue; const json = JSON.parse(msg); messageHandler(json); } partialChunk = ''; } catch (error) { console.warn('Incomplete JSON, waiting for next chunk...'); } } } } }; useEffect(() => { checkConfig( setChatModelProvider, setEmbeddingModelProvider, setIsConfigReady, setHasError, ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (params.chatId && params.chatId !== chatId) { setChatId(params.chatId); setMessages([]); setChatHistory([]); setFiles([]); setFileIds([]); setIsMessagesLoaded(false); setNotFound(false); setNewChatCreated(false); } }, [params.chatId, chatId]); useEffect(() => { if ( chatId && !newChatCreated && !isMessagesLoaded && messages.length === 0 ) { loadMessages( chatId, setMessages, setIsMessagesLoaded, setChatHistory, setSources, setNotFound, setFiles, setFileIds, ); } else if (!chatId) { setNewChatCreated(true); setIsMessagesLoaded(true); setChatId(crypto.randomBytes(20).toString('hex')); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatId, isMessagesLoaded, newChatCreated, messages.length]); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { if (isMessagesLoaded && isConfigReady && newChatCreated) { setIsReady(true); console.debug(new Date(), 'app:ready'); } else if (isMessagesLoaded && isConfigReady && !newChatCreated) { checkReconnect(); } else { setIsReady(false); } }, [isMessagesLoaded, isConfigReady, newChatCreated]); const rewrite = (messageId: string) => { const index = messages.findIndex((msg) => msg.messageId === messageId); if (index === -1) return; setMessages((prev) => prev.slice(0, index)); setChatHistory((prev) => { return prev.slice(0, index * 2); }); const messageToRewrite = messages[index]; sendMessage(messageToRewrite.query, messageToRewrite.messageId, true); }; useEffect(() => { if (isReady && initialMessage && isConfigReady) { if (!isConfigReady) { toast.error('Cannot send message before the configuration is ready'); return; } sendMessage(initialMessage); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isConfigReady, isReady, initialMessage]); const getMessageHandler = (message: Message) => { const messageId = message.messageId; return async (data: any) => { if (data.type === 'error') { toast.error(data.data); setLoading(false); setMessages((prev) => prev.map((msg) => msg.messageId === messageId ? { ...msg, status: 'error' as const } : msg, ), ); return; } if (data.type === 'researchComplete') { setResearchEnded(true); if ( message.responseBlocks.find( (b) => b.type === 'source' && b.data.length > 0, ) ) { setMessageAppeared(true); } } if (data.type === 'block') { setMessages((prev) => prev.map((msg) => { if (msg.messageId === messageId) { const exists = msg.responseBlocks.findIndex( (b) => b.id === data.block.id, ); if (exists !== -1) { const existingBlocks = [...msg.responseBlocks]; existingBlocks[exists] = data.block; return { ...msg, responseBlocks: existingBlocks, }; } return { ...msg, responseBlocks: [...msg.responseBlocks, data.block], }; } return msg; }), ); if ( (data.block.type === 'source' && data.block.data.length > 0) || data.block.type === 'text' ) { setMessageAppeared(true); } } if (data.type === 'updateBlock') { setMessages((prev) => prev.map((msg) => { if (msg.messageId === messageId) { const updatedBlocks = msg.responseBlocks.map((block) => { if (block.id === data.blockId) { const updatedBlock = { ...block }; applyPatch(updatedBlock, data.patch); return updatedBlock; } return block; }); return { ...msg, responseBlocks: updatedBlocks }; } return msg; }), ); } if (data.type === 'messageEnd') { const currentMsg = messagesRef.current.find( (msg) => msg.messageId === messageId, ); const newHistory: [string, string][] = [ ...chatHistory, ['human', message.query], [ 'assistant', currentMsg?.responseBlocks.find((b) => b.type === 'text')?.data || '', ], ]; setChatHistory(newHistory); setMessages((prev) => prev.map((msg) => msg.messageId === messageId ? { ...msg, status: 'completed' as const } : msg, ), ); setLoading(false); const lastMsg = messagesRef.current[messagesRef.current.length - 1]; const autoMediaSearch = getAutoMediaSearch(); if (autoMediaSearch) { document .getElementById(`search-images-${lastMsg.messageId}`) ?.click(); document .getElementById(`search-videos-${lastMsg.messageId}`) ?.click(); } // Check if there are sources and no suggestions const hasSourceBlocks = currentMsg?.responseBlocks.some( (block) => block.type === 'source' && block.data.length > 0, ); const hasSuggestions = currentMsg?.responseBlocks.some( (block) => block.type === 'suggestion', ); if (hasSourceBlocks && !hasSuggestions) { const suggestions = await getSuggestions(newHistory); const suggestionBlock: Block = { id: crypto.randomBytes(7).toString('hex'), type: 'suggestion', data: suggestions, }; setMessages((prev) => prev.map((msg) => { if (msg.messageId === messageId) { return { ...msg, responseBlocks: [...msg.responseBlocks, suggestionBlock], }; } return msg; }), ); } } }; }; const sendMessage: ChatContext['sendMessage'] = async ( message, messageId, rewrite = false, ) => { if (loading || !message) return; setLoading(true); setResearchEnded(false); setMessageAppeared(false); if (messages.length <= 1) { window.history.replaceState(null, '', `/c/${chatId}`); } messageId = messageId ?? crypto.randomBytes(7).toString('hex'); const backendId = crypto.randomBytes(20).toString('hex'); const newMessage: Message = { messageId, chatId: chatId!, backendId, query: message, responseBlocks: [], status: 'answering', createdAt: new Date(), }; setMessages((prevMessages) => [...prevMessages, newMessage]); const messageIndex = messages.findIndex((m) => m.messageId === messageId); const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ content: message, message: { messageId: messageId, chatId: chatId!, content: message, }, chatId: chatId!, files: fileIds, sources: sources, optimizationMode: optimizationMode, history: rewrite ? chatHistory.slice(0, messageIndex === -1 ? undefined : messageIndex) : chatHistory, chatModel: { key: chatModelProvider.key, providerId: chatModelProvider.providerId, }, embeddingModel: { key: embeddingModelProvider.key, providerId: embeddingModelProvider.providerId, }, systemInstructions: localStorage.getItem('systemInstructions'), }), }); if (!res.body) throw new Error('No response body'); const reader = res.body?.getReader(); const decoder = new TextDecoder('utf-8'); let partialChunk = ''; const messageHandler = getMessageHandler(newMessage); while (true) { const { value, done } = await reader.read(); if (done) break; partialChunk += decoder.decode(value, { stream: true }); try { const messages = partialChunk.split('\n'); for (const msg of messages) { if (!msg.trim()) continue; const json = JSON.parse(msg); messageHandler(json); } partialChunk = ''; } catch (error) { console.warn('Incomplete JSON, waiting for next chunk...'); } } }; return ( {children} ); }; export const useChat = () => { const ctx = useContext(chatContext); return ctx; };