diff --git a/README.md b/README.md index 721d41c..cf9e459 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # 🚀 Perplexica - An AI-powered search engine 🔎 +[![Discord](https://dcbadge.vercel.app/api/server/26aArMy8tT?style=flat&compact=true)](https://discord.gg/26aArMy8tT) + + ![preview](.assets/perplexica-screenshot.png?) ## Table of Contents diff --git a/src/lib/providers/anthropic.ts b/src/lib/providers/anthropic.ts index 90460c6..642a6cb 100644 --- a/src/lib/providers/anthropic.ts +++ b/src/lib/providers/anthropic.ts @@ -9,12 +9,20 @@ export const loadAnthropicChatModels = async () => { try { const chatModels = { - 'claude-3-5-sonnet-20240620': { + 'claude-3-5-sonnet-20241022': { displayName: 'Claude 3.5 Sonnet', model: new ChatAnthropic({ temperature: 0.7, anthropicApiKey: anthropicApiKey, - model: 'claude-3-5-sonnet-20240620', + model: 'claude-3-5-sonnet-20241022', + }), + }, + 'claude-3-5-haiku-20241022': { + displayName: 'Claude 3.5 Haiku', + model: new ChatAnthropic({ + temperature: 0.7, + anthropicApiKey: anthropicApiKey, + model: 'claude-3-5-haiku-20241022', }), }, 'claude-3-opus-20240229': { diff --git a/src/lib/providers/groq.ts b/src/lib/providers/groq.ts index 62ca2d7..41004ec 100644 --- a/src/lib/providers/groq.ts +++ b/src/lib/providers/groq.ts @@ -9,6 +9,19 @@ export const loadGroqChatModels = async () => { try { const chatModels = { + 'llama-3.3-70b-versatile': { + displayName: 'Llama 3.3 70B', + model: new ChatOpenAI( + { + openAIApiKey: groqApiKey, + modelName: 'llama-3.3-70b-versatile', + temperature: 0.7, + }, + { + baseURL: 'https://api.groq.com/openai/v1', + }, + ), + }, 'llama-3.2-3b-preview': { displayName: 'Llama 3.2 3B', model: new ChatOpenAI( @@ -48,19 +61,6 @@ export const loadGroqChatModels = async () => { }, ), }, - 'llama-3.1-70b-versatile': { - displayName: 'Llama 3.1 70B', - model: new ChatOpenAI( - { - openAIApiKey: groqApiKey, - modelName: 'llama-3.1-70b-versatile', - temperature: 0.7, - }, - { - baseURL: 'https://api.groq.com/openai/v1', - }, - ), - }, 'llama-3.1-8b-instant': { displayName: 'Llama 3.1 8B', model: new ChatOpenAI( @@ -113,19 +113,6 @@ export const loadGroqChatModels = async () => { }, ), }, - 'gemma-7b-it': { - displayName: 'Gemma 7B', - model: new ChatOpenAI( - { - openAIApiKey: groqApiKey, - modelName: 'gemma-7b-it', - temperature: 0.7, - }, - { - baseURL: 'https://api.groq.com/openai/v1', - }, - ), - }, 'gemma2-9b-it': { displayName: 'Gemma2 9B', model: new ChatOpenAI( diff --git a/src/lib/providers/ollama.ts b/src/lib/providers/ollama.ts index e23fb76..7277b27 100644 --- a/src/lib/providers/ollama.ts +++ b/src/lib/providers/ollama.ts @@ -2,6 +2,7 @@ import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama'; import { getKeepAlive, getOllamaApiEndpoint } from '../../config'; import logger from '../../utils/logger'; import { ChatOllama } from '@langchain/community/chat_models/ollama'; +import axios from 'axios'; export const loadOllamaChatModels = async () => { const ollamaEndpoint = getOllamaApiEndpoint(); @@ -10,13 +11,13 @@ export const loadOllamaChatModels = async () => { if (!ollamaEndpoint) return {}; try { - const response = await fetch(`${ollamaEndpoint}/api/tags`, { + const response = await axios.get(`${ollamaEndpoint}/api/tags`, { headers: { 'Content-Type': 'application/json', }, }); - const { models: ollamaModels } = (await response.json()) as any; + const { models: ollamaModels } = response.data; const chatModels = ollamaModels.reduce((acc, model) => { acc[model.model] = { @@ -45,13 +46,13 @@ export const loadOllamaEmbeddingsModels = async () => { if (!ollamaEndpoint) return {}; try { - const response = await fetch(`${ollamaEndpoint}/api/tags`, { + const response = await axios.get(`${ollamaEndpoint}/api/tags`, { headers: { 'Content-Type': 'application/json', }, }); - const { models: ollamaModels } = (await response.json()) as any; + const { models: ollamaModels } = response.data; const embeddingsModels = ollamaModels.reduce((acc, model) => { acc[model.model] = { diff --git a/src/search/metaSearchAgent.ts b/src/search/metaSearchAgent.ts index b1d8114..ee82c10 100644 --- a/src/search/metaSearchAgent.ts +++ b/src/search/metaSearchAgent.ts @@ -211,7 +211,11 @@ class MetaSearchAgent implements MetaSearchAgentType { const documents = res.results.map( (result) => new Document({ - pageContent: result.content, + pageContent: + result.content || + (this.config.activeEngines.includes('youtube') + ? result.title + : '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */, metadata: { title: result.title, url: result.url, @@ -414,7 +418,10 @@ class MetaSearchAgent implements MetaSearchAgentType { private processDocs(docs: Document[]) { return docs - .map((_, index) => `${index + 1}. ${docs[index].pageContent}`) + .map( + (_, index) => + `${index + 1}. ${docs[index].metadata.title} ${docs[index].pageContent}`, + ) .join('\n'); } diff --git a/src/websocket/messageHandler.ts b/src/websocket/messageHandler.ts index 6cb3ddf..395c0de 100644 --- a/src/websocket/messageHandler.ts +++ b/src/websocket/messageHandler.ts @@ -5,7 +5,7 @@ import type { Embeddings } from '@langchain/core/embeddings'; import logger from '../utils/logger'; import db from '../db'; import { chats, messages as messagesSchema } from '../db/schema'; -import { eq, asc, gt } from 'drizzle-orm'; +import { eq, asc, gt, and } from 'drizzle-orm'; import crypto from 'crypto'; import { getFileDetails } from '../utils/files'; import MetaSearchAgent, { @@ -238,7 +238,12 @@ export const handleMessage = async ( } else { await db .delete(messagesSchema) - .where(gt(messagesSchema.id, messageExists.id)) + .where( + and( + gt(messagesSchema.id, messageExists.id), + eq(messagesSchema.chatId, parsedMessage.chatId), + ), + ) .execute(); } } catch (err) { diff --git a/ui/components/ChatWindow.tsx b/ui/components/ChatWindow.tsx index fd3d0a6..b26573f 100644 --- a/ui/components/ChatWindow.tsx +++ b/ui/components/ChatWindow.tsx @@ -9,7 +9,9 @@ import crypto from 'crypto'; import { toast } from 'sonner'; import { useSearchParams } from 'next/navigation'; import { getSuggestions } from '@/lib/actions'; -import Error from 'next/error'; +import { Settings } from 'lucide-react'; +import SettingsDialog from './SettingsDialog'; +import NextError from 'next/error'; export type Message = { messageId: string; @@ -32,17 +34,38 @@ const useSocket = ( setIsWSReady: (ready: boolean) => void, setError: (error: boolean) => void, ) => { - const [ws, setWs] = useState(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(); + const retryCountRef = useRef(0); + const isCleaningUpRef = useRef(false); + const MAX_RETRIES = 3; + const INITIAL_BACKOFF = 1000; // 1 second + + const getBackoffDelay = (retryCount: number) => { + return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000); // Cap at 10 seconds + }; useEffect(() => { - if (!ws) { - const connectWs = async () => { + const connectWs = async () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.close(); + } + + try { let chatModel = localStorage.getItem('chatModel'); let chatModelProvider = localStorage.getItem('chatModelProvider'); let embeddingModel = localStorage.getItem('embeddingModel'); let embeddingModelProvider = localStorage.getItem( 'embeddingModelProvider', ); + let openAIBaseURL = + chatModelProvider === 'custom_openai' + ? localStorage.getItem('openAIBaseURL') + : null; + let openAIPIKey = + chatModelProvider === 'custom_openai' + ? localStorage.getItem('openAIApiKey') + : null; const providers = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/models`, @@ -51,7 +74,13 @@ const useSocket = ( 'Content-Type': 'application/json', }, }, - ).then(async (res) => await res.json()); + ).then(async (res) => { + if (!res.ok) + throw new Error( + `Failed to fetch models: ${res.status} ${res.statusText}`, + ); + return res.json(); + }); if ( !chatModel || @@ -62,16 +91,18 @@ const useSocket = ( if (!chatModel || !chatModelProvider) { const chatModelProviders = providers.chatModelProviders; - chatModelProvider = Object.keys(chatModelProviders)[0]; + chatModelProvider = + chatModelProvider || Object.keys(chatModelProviders)[0]; if (chatModelProvider === 'custom_openai') { toast.error( - 'Seems like you are using the custom OpenAI provider, please open the settings and configure the API key and base URL', + 'Seems like you are using the custom OpenAI provider, please open the settings and enter a model name to use.', ); setError(true); return; } else { chatModel = Object.keys(chatModelProviders[chatModelProvider])[0]; + if ( !chatModelProviders || Object.keys(chatModelProviders).length === 0 @@ -108,18 +139,42 @@ const useSocket = ( if ( Object.keys(chatModelProviders).length > 0 && - !chatModelProviders[chatModelProvider] + (((!openAIBaseURL || !openAIPIKey) && + chatModelProvider === 'custom_openai') || + !chatModelProviders[chatModelProvider]) ) { - chatModelProvider = Object.keys(chatModelProviders)[0]; + const chatModelProvidersKeys = Object.keys(chatModelProviders); + chatModelProvider = + chatModelProvidersKeys.find( + (key) => Object.keys(chatModelProviders[key]).length > 0, + ) || chatModelProvidersKeys[0]; + + if ( + chatModelProvider === 'custom_openai' && + (!openAIBaseURL || !openAIPIKey) + ) { + toast.error( + 'Seems like you are using the custom OpenAI provider, please open the settings and configure the API key and base URL', + ); + setError(true); + return; + } + localStorage.setItem('chatModelProvider', chatModelProvider); } if ( chatModelProvider && - chatModelProvider != 'custom_openai' && + (!openAIBaseURL || !openAIPIKey) && !chatModelProviders[chatModelProvider][chatModel] ) { - chatModel = Object.keys(chatModelProviders[chatModelProvider])[0]; + chatModel = Object.keys( + chatModelProviders[ + Object.keys(chatModelProviders[chatModelProvider]).length > 0 + ? chatModelProvider + : Object.keys(chatModelProviders)[0] + ], + )[0]; localStorage.setItem('chatModel', chatModel); } @@ -168,6 +223,7 @@ const useSocket = ( wsURL.search = searchParams.toString(); const ws = new WebSocket(wsURL.toString()); + wsRef.current = ws; const timeoutId = setTimeout(() => { if (ws.readyState !== 1) { @@ -183,11 +239,16 @@ const useSocket = ( const interval = setInterval(() => { if (ws.readyState === 1) { setIsWSReady(true); + setError(false); + if (retryCountRef.current > 0) { + toast.success('Connection restored.'); + } + retryCountRef.current = 0; clearInterval(interval); } }, 5); clearTimeout(timeoutId); - console.log('[DEBUG] opened'); + console.debug(new Date(), 'ws:connected'); } if (data.type === 'error') { toast.error(data.data); @@ -196,24 +257,68 @@ const useSocket = ( ws.onerror = () => { clearTimeout(timeoutId); - setError(true); + setIsWSReady(false); toast.error('WebSocket connection error.'); }; ws.onclose = () => { clearTimeout(timeoutId); - setError(true); - console.log('[DEBUG] closed'); + setIsWSReady(false); + console.debug(new Date(), 'ws:disconnected'); + if (!isCleaningUpRef.current) { + toast.error('Connection lost. Attempting to reconnect...'); + attemptReconnect(); + } }; + } catch (error) { + console.debug(new Date(), 'ws:error', error); + setIsWSReady(false); + attemptReconnect(); + } + }; - setWs(ws); - }; + const attemptReconnect = () => { + retryCountRef.current += 1; - connectWs(); - } - }, [ws, url, setIsWSReady, setError]); + if (retryCountRef.current > MAX_RETRIES) { + console.debug(new Date(), 'ws:max_retries'); + setError(true); + toast.error( + 'Unable to connect to server after multiple attempts. Please refresh the page to try again.', + ); + return; + } - return ws; + const backoffDelay = getBackoffDelay(retryCountRef.current); + console.debug( + new Date(), + `ws:retry attempt=${retryCountRef.current}/${MAX_RETRIES} delay=${backoffDelay}ms`, + ); + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = setTimeout(() => { + connectWs(); + }, backoffDelay); + }; + + connectWs(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.close(); + isCleaningUpRef.current = true; + console.debug(new Date(), 'ws:cleanup'); + } + }; + }, [url, setIsWSReady, setError]); + + return wsRef.current; }; const loadMessages = async ( @@ -257,7 +362,7 @@ const loadMessages = async ( return [msg.role, msg.content]; }) as [string, string][]; - console.log('[DEBUG] messages loaded'); + console.debug(new Date(), 'app:messages_loaded'); document.title = messages[0].content; @@ -310,6 +415,8 @@ const ChatWindow = ({ id }: { id?: string }) => { const [notFound, setNotFound] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + useEffect(() => { if ( chatId && @@ -339,7 +446,7 @@ const ChatWindow = ({ id }: { id?: string }) => { return () => { if (ws?.readyState === 1) { ws.close(); - console.log('[DEBUG] closed'); + console.debug(new Date(), 'ws:cleanup'); } }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -354,12 +461,18 @@ const ChatWindow = ({ id }: { id?: string }) => { useEffect(() => { if (isMessagesLoaded && isWSReady) { setIsReady(true); - console.log('[DEBUG] ready'); + console.debug(new Date(), 'app:ready'); + } else { + setIsReady(false); } }, [isMessagesLoaded, isWSReady]); const sendMessage = async (message: string, messageId?: string) => { if (loading) return; + if (!ws || ws.readyState !== WebSocket.OPEN) { + toast.error('Cannot send message while disconnected'); + return; + } setLoading(true); setMessageAppeared(false); @@ -370,7 +483,7 @@ const ChatWindow = ({ id }: { id?: string }) => { messageId = messageId ?? crypto.randomBytes(7).toString('hex'); - ws?.send( + ws.send( JSON.stringify({ type: 'message', message: { @@ -514,17 +627,26 @@ const ChatWindow = ({ id }: { id?: string }) => { if (hasError) { return ( -
-

- Failed to connect to the server. Please try again later. -

+
+
+ setIsSettingsOpen(true)} + /> +
+
+

+ Failed to connect to the server. Please try again later. +

+
+
); } return isReady ? ( notFound ? ( - + ) : (
{messages.length > 0 ? ( diff --git a/ui/components/SearchVideos.tsx b/ui/components/SearchVideos.tsx index 2d820ef..170df61 100644 --- a/ui/components/SearchVideos.tsx +++ b/ui/components/SearchVideos.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ import { PlayCircle, PlayIcon, PlusIcon, VideoIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox'; import 'yet-another-react-lightbox/styles.css'; import { Message } from './ChatWindow'; @@ -35,6 +35,8 @@ const Searchvideos = ({ const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [slides, setSlides] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]); return ( <> @@ -182,18 +184,39 @@ const Searchvideos = ({ open={open} close={() => setOpen(false)} slides={slides} + index={currentIndex} + on={{ + view: ({ index }) => { + const previousIframe = videoRefs.current[currentIndex]; + if (previousIframe?.contentWindow) { + previousIframe.contentWindow.postMessage( + '{"event":"command","func":"pauseVideo","args":""}', + '*', + ); + } + + setCurrentIndex(index); + }, + }} render={{ - slide: ({ slide }) => - slide.type === 'video-slide' ? ( + slide: ({ slide }) => { + const index = slides.findIndex((s) => s === slide); + return slide.type === 'video-slide' ? (