diff --git a/README.md b/README.md index 7c0b09a..e45e80a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ - [Installation](#installation) - [Getting Started with Docker (Recommended)](#getting-started-with-docker-recommended) - [Non-Docker Installation](#non-docker-installation) + - [Ollama connection errors](#ollama-connection-errors) - [One-Click Deployment](#one-click-deployment) - [Upcoming Features](#upcoming-features) - [Support Us](#support-us) + - [Donations](#donations) - [Contribution](#contribution) - [Help and Support](#help-and-support) @@ -90,6 +92,16 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker. **Note**: Using Docker is recommended as it simplifies the setup process, especially for managing environment variables and dependencies. +### Ollama connection errors + +If you're facing an Ollama connection error, it is often related to the backend not being able to connect to Ollama's API. How can you fix it? You can fix it by updating your Ollama API URL in the settings menu to the following: + +On Windows: `http://host.docker.internal:11434`
+On Mac: `http://host.docker.internal:11434`
+On Linux: `http://private_ip_of_computer_hosting_ollama:11434` + +You need to edit the ports accordingly. + ## One-Click Deployment [![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=267) @@ -104,7 +116,15 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker. ## Support Us -If you find Perplexica useful, consider giving us a star on GitHub. This helps more people discover Perplexica and supports the development of new features. Your support is appreciated. +If you find Perplexica useful, consider giving us a star on GitHub. This helps more people discover Perplexica and supports the development of new features. Your support is greatly appreciated. + +### Donations + +We also accept donations to help sustain our project. If you would like to contribute, you can use the following button to make a donation in cryptocurrency. Thank you for your support! + + + Crypto donation button by NOWPayments + ## Contribution diff --git a/package.json b/package.json index 5afcb17..ca01c44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perplexica-backend", - "version": "1.2.1", + "version": "1.3.4", "license": "MIT", "author": "ItzCrazyKns", "scripts": { diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 87a0714..637e426 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -9,7 +9,7 @@ import { } from '../config'; import logger from '../utils/logger'; -export const getAvailableProviders = async () => { +export const getAvailableChatModelProviders = async () => { const openAIApiKey = getOpenaiApiKey(); const groqApiKey = getGroqApiKey(); const ollamaEndpoint = getOllamaApiEndpoint(); @@ -44,13 +44,6 @@ export const getAvailableProviders = async () => { modelName: 'gpt-4-turbo', temperature: 0.7, }), - embeddings: new OpenAIEmbeddings({ - openAIApiKey, - configuration: { - baseURL: openaiEndpoint, - }, - modelName: 'text-embedding-3-large', - }), }; } catch (err) { logger.error(`Error loading OpenAI models: ${err}`); @@ -100,10 +93,6 @@ export const getAvailableProviders = async () => { baseURL: 'https://api.groq.com/openai/v1', }, ), - embeddings: new OpenAIEmbeddings({ - openAIApiKey: openAIApiKey, - modelName: 'text-embedding-3-large', - }), }; } catch (err) { logger.error(`Error loading Groq models: ${err}`); @@ -112,7 +101,11 @@ export const getAvailableProviders = async () => { if (ollamaEndpoint) { try { - const response = await fetch(`${ollamaEndpoint}/api/tags`); + const response = await fetch(`${ollamaEndpoint}/api/tags`, { + headers: { + 'Content-Type': 'application/json', + }, + }); const { models: ollamaModels } = (await response.json()) as any; @@ -124,17 +117,60 @@ export const getAvailableProviders = async () => { }); return acc; }, {}); - - if (Object.keys(models['ollama']).length > 0) { - models['ollama']['embeddings'] = new OllamaEmbeddings({ - baseUrl: ollamaEndpoint, - model: models['ollama'][Object.keys(models['ollama'])[0]].model, - }); - } } catch (err) { logger.error(`Error loading Ollama models: ${err}`); } } + models['custom_openai'] = {}; + + return models; +}; + +export const getAvailableEmbeddingModelProviders = async () => { + const openAIApiKey = getOpenaiApiKey(); + const ollamaEndpoint = getOllamaApiEndpoint(); + + const models = {}; + + if (openAIApiKey) { + try { + models['openai'] = { + 'Text embedding 3 small': new OpenAIEmbeddings({ + openAIApiKey, + modelName: 'text-embedding-3-small', + }), + 'Text embedding 3 large': new OpenAIEmbeddings({ + openAIApiKey, + modelName: 'text-embedding-3-large', + }), + }; + } catch (err) { + logger.error(`Error loading OpenAI embeddings: ${err}`); + } + } + + if (ollamaEndpoint) { + try { + const response = await fetch(`${ollamaEndpoint}/api/tags`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + const { models: ollamaModels } = (await response.json()) as any; + + models['ollama'] = ollamaModels.reduce((acc, model) => { + acc[model.model] = new OllamaEmbeddings({ + baseUrl: ollamaEndpoint, + model: model.model, + }); + return acc; + }, {}); + } catch (err) { + logger.error(`Error loading Ollama embeddings: ${err}`); + } + } + return models; }; diff --git a/src/routes/config.ts b/src/routes/config.ts index 6cc6763..84d6ca1 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -1,5 +1,8 @@ import express from 'express'; -import { getAvailableProviders } from '../lib/providers'; +import { + getAvailableChatModelProviders, + getAvailableEmbeddingModelProviders, +} from '../lib/providers'; import { getGroqApiKey, getOllamaApiEndpoint, @@ -13,16 +16,24 @@ const router = express.Router(); router.get('/', async (_, res) => { const config = {}; - const providers = await getAvailableProviders(); + const [chatModelProviders, embeddingModelProviders] = await Promise.all([ + getAvailableChatModelProviders(), + getAvailableEmbeddingModelProviders(), + ]); - for (const provider in providers) { - delete providers[provider]['embeddings']; + config['chatModelProviders'] = {}; + config['embeddingModelProviders'] = {}; + + for (const provider in chatModelProviders) { + config['chatModelProviders'][provider] = Object.keys( + chatModelProviders[provider], + ); } - config['providers'] = {}; - - for (const provider in providers) { - config['providers'][provider] = Object.keys(providers[provider]); + for (const provider in embeddingModelProviders) { + config['embeddingModelProviders'][provider] = Object.keys( + embeddingModelProviders[provider], + ); } config['openaiApiKey'] = getOpenaiApiKey(); diff --git a/src/routes/images.ts b/src/routes/images.ts index 3906689..d8ad8e1 100644 --- a/src/routes/images.ts +++ b/src/routes/images.ts @@ -1,7 +1,7 @@ import express from 'express'; import handleImageSearch from '../agents/imageSearchAgent'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import { getAvailableProviders } from '../lib/providers'; +import { getAvailableChatModelProviders } from '../lib/providers'; import { HumanMessage, AIMessage } from '@langchain/core/messages'; import logger from '../utils/logger'; @@ -19,7 +19,7 @@ router.post('/', async (req, res) => { } }); - const chatModels = await getAvailableProviders(); + const chatModels = await getAvailableChatModelProviders(); const provider = chat_model_provider || Object.keys(chatModels)[0]; const chatModel = chat_model || Object.keys(chatModels[provider])[0]; diff --git a/src/routes/models.ts b/src/routes/models.ts index f2332f4..36df25a 100644 --- a/src/routes/models.ts +++ b/src/routes/models.ts @@ -1,14 +1,20 @@ import express from 'express'; import logger from '../utils/logger'; -import { getAvailableProviders } from '../lib/providers'; +import { + getAvailableChatModelProviders, + getAvailableEmbeddingModelProviders, +} from '../lib/providers'; const router = express.Router(); router.get('/', async (req, res) => { try { - const providers = await getAvailableProviders(); + const [chatModelProviders, embeddingModelProviders] = await Promise.all([ + getAvailableChatModelProviders(), + getAvailableEmbeddingModelProviders(), + ]); - res.status(200).json({ providers }); + res.status(200).json({ chatModelProviders, embeddingModelProviders }); } catch (err) { res.status(500).json({ message: 'An error has occurred.' }); logger.error(err.message); diff --git a/src/routes/videos.ts b/src/routes/videos.ts index fecd874..e117a5a 100644 --- a/src/routes/videos.ts +++ b/src/routes/videos.ts @@ -1,6 +1,6 @@ import express from 'express'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import { getAvailableProviders } from '../lib/providers'; +import { getAvailableChatModelProviders } from '../lib/providers'; import { HumanMessage, AIMessage } from '@langchain/core/messages'; import logger from '../utils/logger'; import handleVideoSearch from '../agents/videoSearchAgent'; @@ -19,7 +19,7 @@ router.post('/', async (req, res) => { } }); - const chatModels = await getAvailableProviders(); + const chatModels = await getAvailableChatModelProviders(); const provider = chat_model_provider || Object.keys(chatModels)[0]; const chatModel = chat_model || Object.keys(chatModels[provider])[0]; diff --git a/src/websocket/connectionManager.ts b/src/websocket/connectionManager.ts index c2f3798..5cb075b 100644 --- a/src/websocket/connectionManager.ts +++ b/src/websocket/connectionManager.ts @@ -1,47 +1,100 @@ import { WebSocket } from 'ws'; import { handleMessage } from './messageHandler'; -import { getAvailableProviders } from '../lib/providers'; +import { + getAvailableEmbeddingModelProviders, + getAvailableChatModelProviders, +} from '../lib/providers'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { Embeddings } from '@langchain/core/embeddings'; import type { IncomingMessage } from 'http'; import logger from '../utils/logger'; +import { ChatOpenAI } from '@langchain/openai'; export const handleConnection = async ( ws: WebSocket, request: IncomingMessage, ) => { - const searchParams = new URL(request.url, `http://${request.headers.host}`) - .searchParams; + try { + const searchParams = new URL(request.url, `http://${request.headers.host}`) + .searchParams; - const models = await getAvailableProviders(); - const provider = - searchParams.get('chatModelProvider') || Object.keys(models)[0]; - const chatModel = - searchParams.get('chatModel') || Object.keys(models[provider])[0]; + const [chatModelProviders, embeddingModelProviders] = await Promise.all([ + getAvailableChatModelProviders(), + getAvailableEmbeddingModelProviders(), + ]); - let llm: BaseChatModel | undefined; - let embeddings: Embeddings | undefined; + const chatModelProvider = + searchParams.get('chatModelProvider') || + Object.keys(chatModelProviders)[0]; + const chatModel = + searchParams.get('chatModel') || + Object.keys(chatModelProviders[chatModelProvider])[0]; - if (models[provider] && models[provider][chatModel]) { - llm = models[provider][chatModel] as BaseChatModel | undefined; - embeddings = models[provider].embeddings as Embeddings | undefined; - } + const embeddingModelProvider = + searchParams.get('embeddingModelProvider') || + Object.keys(embeddingModelProviders)[0]; + const embeddingModel = + searchParams.get('embeddingModel') || + Object.keys(embeddingModelProviders[embeddingModelProvider])[0]; - if (!llm || !embeddings) { + let llm: BaseChatModel | undefined; + let embeddings: Embeddings | undefined; + + if ( + chatModelProviders[chatModelProvider] && + chatModelProviders[chatModelProvider][chatModel] && + chatModelProvider != 'custom_openai' + ) { + llm = chatModelProviders[chatModelProvider][chatModel] as + | BaseChatModel + | undefined; + } else if (chatModelProvider == 'custom_openai') { + llm = new ChatOpenAI({ + modelName: chatModel, + openAIApiKey: searchParams.get('openAIApiKey'), + temperature: 0.7, + configuration: { + baseURL: searchParams.get('openAIBaseURL'), + }, + }); + } + + if ( + embeddingModelProviders[embeddingModelProvider] && + embeddingModelProviders[embeddingModelProvider][embeddingModel] + ) { + embeddings = embeddingModelProviders[embeddingModelProvider][ + embeddingModel + ] as Embeddings | undefined; + } + + if (!llm || !embeddings) { + ws.send( + JSON.stringify({ + type: 'error', + data: 'Invalid LLM or embeddings model selected, please refresh the page and try again.', + key: 'INVALID_MODEL_SELECTED', + }), + ); + ws.close(); + } + + ws.on( + 'message', + async (message) => + await handleMessage(message.toString(), ws, llm, embeddings), + ); + + ws.on('close', () => logger.debug('Connection closed')); + } catch (err) { ws.send( JSON.stringify({ type: 'error', - data: 'Invalid LLM or embeddings model selected', + data: 'Internal server error.', + key: 'INTERNAL_SERVER_ERROR', }), ); ws.close(); + logger.error(err); } - - ws.on( - 'message', - async (message) => - await handleMessage(message.toString(), ws, llm, embeddings), - ); - - ws.on('close', () => logger.debug('Connection closed')); }; diff --git a/src/websocket/messageHandler.ts b/src/websocket/messageHandler.ts index 537651f..98f67c2 100644 --- a/src/websocket/messageHandler.ts +++ b/src/websocket/messageHandler.ts @@ -57,7 +57,13 @@ const handleEmitterEvents = ( }); emitter.on('error', (data) => { const parsedData = JSON.parse(data); - ws.send(JSON.stringify({ type: 'error', data: parsedData.data })); + ws.send( + JSON.stringify({ + type: 'error', + data: parsedData.data, + key: 'CHAIN_ERROR', + }), + ); }); }; @@ -73,7 +79,11 @@ export const handleMessage = async ( if (!parsedMessage.content) return ws.send( - JSON.stringify({ type: 'error', data: 'Invalid message format' }), + JSON.stringify({ + type: 'error', + data: 'Invalid message format', + key: 'INVALID_FORMAT', + }), ); const history: BaseMessage[] = parsedMessage.history.map((msg) => { @@ -99,11 +109,23 @@ export const handleMessage = async ( ); handleEmitterEvents(emitter, ws, id); } else { - ws.send(JSON.stringify({ type: 'error', data: 'Invalid focus mode' })); + ws.send( + JSON.stringify({ + type: 'error', + data: 'Invalid focus mode', + key: 'INVALID_FOCUS_MODE', + }), + ); } } } catch (err) { - ws.send(JSON.stringify({ type: 'error', data: 'Invalid message format' })); + ws.send( + JSON.stringify({ + type: 'error', + data: 'Invalid message format', + key: 'INVALID_FORMAT', + }), + ); logger.error(`Failed to handle message: ${err}`); } }; diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx index cb670f5..b3f5005 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -3,6 +3,7 @@ import { Montserrat } from 'next/font/google'; import './globals.css'; import { cn } from '@/lib/utils'; import Sidebar from '@/components/Sidebar'; +import { Toaster } from 'sonner'; const montserrat = Montserrat({ weight: ['300', '400', '500', '700'], @@ -26,6 +27,15 @@ export default function RootLayout({ {children} + ); diff --git a/ui/components/ChatWindow.tsx b/ui/components/ChatWindow.tsx index 68a2ba0..6f58757 100644 --- a/ui/components/ChatWindow.tsx +++ b/ui/components/ChatWindow.tsx @@ -5,6 +5,7 @@ import { Document } from '@langchain/core/documents'; import Navbar from './Navbar'; import Chat from './Chat'; import EmptyChat from './EmptyChat'; +import { toast } from 'sonner'; export type Message = { id: string; @@ -22,39 +23,102 @@ const useSocket = (url: string) => { const connectWs = async () => { let chatModel = localStorage.getItem('chatModel'); let chatModelProvider = localStorage.getItem('chatModelProvider'); + let embeddingModel = localStorage.getItem('embeddingModel'); + let embeddingModelProvider = localStorage.getItem( + 'embeddingModelProvider', + ); - if (!chatModel || !chatModelProvider) { - const chatModelProviders = await fetch( + if ( + !chatModel || + !chatModelProvider || + !embeddingModel || + !embeddingModelProvider + ) { + const providers = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/models`, - ).then(async (res) => (await res.json())['providers']); + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ).then(async (res) => await res.json()); + + const chatModelProviders = providers.chatModelProviders; + const embeddingModelProviders = providers.embeddingModelProviders; if ( !chatModelProviders || Object.keys(chatModelProviders).length === 0 ) - return console.error('No chat models available'); + return toast.error('No chat models available'); + + if ( + !embeddingModelProviders || + Object.keys(embeddingModelProviders).length === 0 + ) + return toast.error('No embedding models available'); chatModelProvider = Object.keys(chatModelProviders)[0]; chatModel = Object.keys(chatModelProviders[chatModelProvider])[0]; + embeddingModelProvider = Object.keys(embeddingModelProviders)[0]; + embeddingModel = Object.keys( + embeddingModelProviders[embeddingModelProvider], + )[0]; + localStorage.setItem('chatModel', chatModel!); localStorage.setItem('chatModelProvider', chatModelProvider); + localStorage.setItem('embeddingModel', embeddingModel!); + localStorage.setItem( + 'embeddingModelProvider', + embeddingModelProvider, + ); } - const ws = new WebSocket( - `${url}?chatModel=${chatModel}&chatModelProvider=${chatModelProvider}`, - ); + const wsURL = new URL(url); + const searchParams = new URLSearchParams({}); + + searchParams.append('chatModel', chatModel!); + searchParams.append('chatModelProvider', chatModelProvider); + + if (chatModelProvider === 'custom_openai') { + searchParams.append( + 'openAIApiKey', + localStorage.getItem('openAIApiKey')!, + ); + searchParams.append( + 'openAIBaseURL', + localStorage.getItem('openAIBaseURL')!, + ); + } + + searchParams.append('embeddingModel', embeddingModel!); + searchParams.append('embeddingModelProvider', embeddingModelProvider); + + wsURL.search = searchParams.toString(); + + const ws = new WebSocket(wsURL.toString()); + ws.onopen = () => { console.log('[DEBUG] open'); setWs(ws); }; + + ws.onmessage = (e) => { + const parsedData = JSON.parse(e.data); + if (parsedData.type === 'error') { + toast.error(parsedData.data); + if (parsedData.key === 'INVALID_MODEL_SELECTED') { + localStorage.clear(); + } + } + }; }; connectWs(); } return () => { - 1; ws?.close(); console.log('[DEBUG] closed'); }; @@ -102,6 +166,12 @@ const ChatWindow = () => { const messageHandler = (e: MessageEvent) => { const data = JSON.parse(e.data); + if (data.type === 'error') { + toast.error(data.data); + setLoading(false); + return; + } + if (data.type === 'sources') { sources = data.data; if (!added) { diff --git a/ui/components/MessageBox.tsx b/ui/components/MessageBox.tsx index 9ee7245..9712a23 100644 --- a/ui/components/MessageBox.tsx +++ b/ui/components/MessageBox.tsx @@ -34,15 +34,13 @@ const MessageBox = ({ const [speechMessage, setSpeechMessage] = useState(message.content); useEffect(() => { + const regex = /\[(\d+)\]/g; + if ( message.role === 'assistant' && message?.sources && message.sources.length > 0 ) { - const regex = /\[(\d+)\]/g; - - setSpeechMessage(message.content.replace(regex, '')); - return setParsedMessage( message.content.replace( regex, @@ -51,6 +49,8 @@ const MessageBox = ({ ), ); } + + setSpeechMessage(message.content.replace(regex, '')); setParsedMessage(message.content); }, [message.content, message.sources, message.role]); @@ -95,7 +95,7 @@ const MessageBox = ({ {parsedMessage} - {!loading && ( + {loading && isLast ? null : (