diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000000000000000000000000000000000..c47dc91d22169d7db51beb9444e2393f60f13321
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,27 @@
+---
+name: Bug report
+about: Create an issue to help us fix bugs
+title: ''
+labels: bug
+assignees: ''
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md
new file mode 100644
index 0000000000000000000000000000000000000000..f4a0d36bf195bfea2d5926f3b6610c51260019a9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/custom.md
@@ -0,0 +1,7 @@
+---
+name: Custom issue template
+about: Describe this issue template's purpose here.
+title: ''
+labels: ''
+assignees: ''
+---
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000000000000000000000000000000000..7d8f2ab754dd0118fd7dd798dbf10a232cd28617
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,19 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: ''
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1e5a5c3a94ad72780a62f47febfb12f1327f6de3
--- /dev/null
+++ b/docs/architecture/README.md
@@ -0,0 +1,11 @@
+## Perplexica's Architecture
+
+Perplexica's architecture consists of the following key components:
+
+1. **User Interface**: A web-based interface that allows users to interact with Perplexica for searching images, videos, and much more.
+2. **Agent/Chains**: These components predict Perplexica's next actions, understand user queries, and decide whether a web search is necessary.
+3. **SearXNG**: A metadata search engine used by Perplexica to search the web for sources.
+4. **LLMs (Large Language Models)**: Utilized by agents and chains for tasks like understanding content, writing responses, and citing sources. Examples include Claude, GPTs, etc.
+5. **Embedding Models**: To improve the accuracy of search results, embedding models re-rank the results using similarity search algorithms such as cosine similarity and dot product distance.
+
+For a more detailed explanation of how these components work together, see [WORKING.md](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/WORKING.md).
diff --git a/docs/architecture/WORKING.md b/docs/architecture/WORKING.md
new file mode 100644
index 0000000000000000000000000000000000000000..c0f2f5715f8524bfc721a3a5cde0292bf95317f9
--- /dev/null
+++ b/docs/architecture/WORKING.md
@@ -0,0 +1,19 @@
+## How does Perplexica work?
+
+Curious about how Perplexica works? Don't worry, we'll cover it here. Before we begin, make sure you've read about the architecture of Perplexica to ensure you understand what it's made up of. Haven't read it? You can read it [here](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/README.md).
+
+We'll understand how Perplexica works by taking an example of a scenario where a user asks: "How does an A.C. work?". We'll break down the process into steps to make it easier to understand. The steps are as follows:
+
+1. The message is sent via WS to the backend server where it invokes the chain. The chain will depend on your focus mode. For this example, let's assume we use the "webSearch" focus mode.
+2. The chain is now invoked; first, the message is passed to another chain where it first predicts (using the chat history and the question) whether there is a need for sources or searching the web. If there is, it will generate a query (in accordance with the chat history) for searching the web that we'll take up later. If not, the chain will end there, and then the answer generator chain, also known as the response generator, will be started.
+3. The query returned by the first chain is passed to SearXNG to search the web for information.
+4. After the information is retrieved, it is based on keyword-based search. We then convert the information into embeddings and the query as well, then we perform a similarity search to find the most relevant sources to answer the query.
+5. After all this is done, the sources are passed to the response generator. This chain takes all the chat history, the query, and the sources. It generates a response that is streamed to the UI.
+
+### How are the answers cited?
+
+The LLMs are prompted to do so. We've prompted them so well that they cite the answers themselves, and using some UI magic, we display it to the user.
+
+### Image and Video Search
+
+Image and video searches are conducted in a similar manner. A query is always generated first, then we search the web for images and videos that match the query. These results are then returned to the user.
diff --git a/src/Perplexica - Shortcut.lnk b/src/Perplexica - Shortcut.lnk
new file mode 100644
index 0000000000000000000000000000000000000000..79a46f511443e39a9f9c7dd90864ab953dc03926
Binary files /dev/null and b/src/Perplexica - Shortcut.lnk differ
diff --git a/src/agents/academicSearchAgent.ts b/src/agents/academicSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..39e33a73bdc8172d8eb7fc2b105322a23a7af5a8
--- /dev/null
+++ b/src/agents/academicSearchAgent.ts
@@ -0,0 +1,265 @@
+import { BaseMessage } from '@langchain/core/messages';
+import {
+ PromptTemplate,
+ ChatPromptTemplate,
+ MessagesPlaceholder,
+} from '@langchain/core/prompts';
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { Document } from '@langchain/core/documents';
+import { searchSearxng } from '../lib/searxng';
+import type { StreamEvent } from '@langchain/core/tracers/log_stream';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import eventEmitter from 'events';
+import computeSimilarity from '../utils/computeSimilarity';
+import logger from '../utils/logger';
+
+const basicAcademicSearchRetrieverPrompt = `
+You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
+If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
+
+Example:
+1. Follow up question: How does stable diffusion work?
+Rephrased: Stable diffusion working
+
+2. Follow up question: What is linear algebra?
+Rephrased: Linear algebra
+
+3. Follow up question: What is the third law of thermodynamics?
+Rephrased: Third law of thermodynamics
+
+Conversation:
+{chat_history}
+
+Follow up question: {query}
+Rephrased question:
+`;
+
+const basicAcademicSearchResponsePrompt = `
+ You are Perplexica, an AI model who is expert at searching the web and answering user's queries. You are set on focus mode 'Academic', this means you will be searching for academic papers and articles on the web.
+
+ Generate a response that is informative and relevant to the user's query based on provided context (the context consits of search results containg a brief description of the content of that page).
+ You must use this context to answer the user's query in the best way possible. Use an unbaised and journalistic tone in your response. Do not repeat the text.
+ You must not tell the user to open any link or visit any website to get the answer. You must provide the answer in the response itself. If the user asks for links you can provide them.
+ Your responses should be medium to long in length be informative and relevant to the user's query. You can use markdowns to format your response. You should use bullet points to list the information. Make sure the answer is not short and is informative.
+ You have to cite the answer using [number] notation. You must cite the sentences with their relevent context number. You must cite each and every part of the answer so the user can know where the information is coming from.
+ Place these citations at the end of that particular sentence. You can cite the same sentence multiple times if it is relevant to the user's query like [number1][number2].
+ However you do not need to cite it using the same number. You can use different numbers to cite the same sentence multiple times. The number refers to the number of the search result (passed in the context) used to generate that part of the answer.
+
+ Aything inside the following \`context\` HTML block provided below is for your knowledge returned by the search engine and is not shared by the user. You have to answer question on the basis of it and cite the relevant information from it but you do not have to
+ talk about the context in your response.
+
+
+ {context}
+
+
+ If you think there's nothing relevant in the search results, you can say that 'Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?'.
+ Anything between the \`context\` is retrieved from a search engine and is not a part of the conversation with the user. Today's date is ${new Date().toISOString()}
+`;
+
+const strParser = new StringOutputParser();
+
+const handleStream = async (
+ stream: AsyncGenerator,
+ emitter: eventEmitter,
+) => {
+ for await (const event of stream) {
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalSourceRetriever'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'sources', data: event.data.output }),
+ );
+ }
+ if (
+ event.event === 'on_chain_stream' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'response', data: event.data.chunk }),
+ );
+ }
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit('end');
+ }
+ }
+};
+
+type BasicChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const createBasicAcademicSearchRetrieverChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ PromptTemplate.fromTemplate(basicAcademicSearchRetrieverPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ if (input === 'not_needed') {
+ return { query: '', docs: [] };
+ }
+
+ const res = await searchSearxng(input, {
+ language: 'en',
+ engines: [
+ 'arxiv',
+ 'google scholar',
+ 'internetarchivescholar',
+ 'pubmed',
+ ],
+ });
+
+ const documents = res.results.map(
+ (result) =>
+ new Document({
+ pageContent: result.content,
+ metadata: {
+ title: result.title,
+ url: result.url,
+ ...(result.img_src && { img_src: result.img_src }),
+ },
+ }),
+ );
+
+ return { query: input, docs: documents };
+ }),
+ ]);
+};
+
+const createBasicAcademicSearchAnsweringChain = (
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const basicAcademicSearchRetrieverChain =
+ createBasicAcademicSearchRetrieverChain(llm);
+
+ const processDocs = async (docs: Document[]) => {
+ return docs
+ .map((_, index) => `${index + 1}. ${docs[index].pageContent}`)
+ .join('\n');
+ };
+
+ const rerankDocs = async ({
+ query,
+ docs,
+ }: {
+ query: string;
+ docs: Document[];
+ }) => {
+ if (docs.length === 0) {
+ return docs;
+ }
+
+ const docsWithContent = docs.filter(
+ (doc) => doc.pageContent && doc.pageContent.length > 0,
+ );
+
+ const [docEmbeddings, queryEmbedding] = await Promise.all([
+ embeddings.embedDocuments(docsWithContent.map((doc) => doc.pageContent)),
+ embeddings.embedQuery(query),
+ ]);
+
+ const similarity = docEmbeddings.map((docEmbedding, i) => {
+ const sim = computeSimilarity(queryEmbedding, docEmbedding);
+
+ return {
+ index: i,
+ similarity: sim,
+ };
+ });
+
+ const sortedDocs = similarity
+ .sort((a, b) => b.similarity - a.similarity)
+ .slice(0, 15)
+ .map((sim) => docsWithContent[sim.index]);
+
+ return sortedDocs;
+ };
+
+ return RunnableSequence.from([
+ RunnableMap.from({
+ query: (input: BasicChainInput) => input.query,
+ chat_history: (input: BasicChainInput) => input.chat_history,
+ context: RunnableSequence.from([
+ (input) => ({
+ query: input.query,
+ chat_history: formatChatHistoryAsString(input.chat_history),
+ }),
+ basicAcademicSearchRetrieverChain
+ .pipe(rerankDocs)
+ .withConfig({
+ runName: 'FinalSourceRetriever',
+ })
+ .pipe(processDocs),
+ ]),
+ }),
+ ChatPromptTemplate.fromMessages([
+ ['system', basicAcademicSearchResponsePrompt],
+ new MessagesPlaceholder('chat_history'),
+ ['user', '{query}'],
+ ]),
+ llm,
+ strParser,
+ ]).withConfig({
+ runName: 'FinalResponseGenerator',
+ });
+};
+
+const basicAcademicSearch = (
+ query: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = new eventEmitter();
+
+ try {
+ const basicAcademicSearchAnsweringChain =
+ createBasicAcademicSearchAnsweringChain(llm, embeddings);
+
+ const stream = basicAcademicSearchAnsweringChain.streamEvents(
+ {
+ chat_history: history,
+ query: query,
+ },
+ {
+ version: 'v1',
+ },
+ );
+
+ handleStream(stream, emitter);
+ } catch (err) {
+ emitter.emit(
+ 'error',
+ JSON.stringify({ data: 'An error has occurred please try again later' }),
+ );
+ logger.error(`Error in academic search: ${err}`);
+ }
+
+ return emitter;
+};
+
+const handleAcademicSearch = (
+ message: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = basicAcademicSearch(message, history, llm, embeddings);
+ return emitter;
+};
+
+export default handleAcademicSearch;
diff --git a/src/agents/imageSearchAgent.ts b/src/agents/imageSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..05211d166d5be7b851f2801a732133ebc73597e2
--- /dev/null
+++ b/src/agents/imageSearchAgent.ts
@@ -0,0 +1,84 @@
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { PromptTemplate } from '@langchain/core/prompts';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import { BaseMessage } from '@langchain/core/messages';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { searchSearxng } from '../lib/searxng';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+
+const imageSearchChainPrompt = `
+You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search the web for images.
+You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
+
+Example:
+1. Follow up question: What is a cat?
+Rephrased: A cat
+
+2. Follow up question: What is a car? How does it works?
+Rephrased: Car working
+
+3. Follow up question: How does an AC work?
+Rephrased: AC working
+
+Conversation:
+{chat_history}
+
+Follow up question: {query}
+Rephrased question:
+`;
+
+type ImageSearchChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const strParser = new StringOutputParser();
+
+const createImageSearchChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ RunnableMap.from({
+ chat_history: (input: ImageSearchChainInput) => {
+ return formatChatHistoryAsString(input.chat_history);
+ },
+ query: (input: ImageSearchChainInput) => {
+ return input.query;
+ },
+ }),
+ PromptTemplate.fromTemplate(imageSearchChainPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ const res = await searchSearxng(input, {
+ engines: ['bing images', 'google images'],
+ });
+
+ const images = [];
+
+ res.results.forEach((result) => {
+ if (result.img_src && result.url && result.title) {
+ images.push({
+ img_src: result.img_src,
+ url: result.url,
+ title: result.title,
+ });
+ }
+ });
+
+ return images.slice(0, 10);
+ }),
+ ]);
+};
+
+const handleImageSearch = (
+ input: ImageSearchChainInput,
+ llm: BaseChatModel,
+) => {
+ const imageSearchChain = createImageSearchChain(llm);
+ return imageSearchChain.invoke(input);
+};
+
+export default handleImageSearch;
diff --git a/src/agents/redditSearchAgent.ts b/src/agents/redditSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..34b83978850bbca154b73b7904bf19c7e2a67394
--- /dev/null
+++ b/src/agents/redditSearchAgent.ts
@@ -0,0 +1,260 @@
+import { BaseMessage } from '@langchain/core/messages';
+import {
+ PromptTemplate,
+ ChatPromptTemplate,
+ MessagesPlaceholder,
+} from '@langchain/core/prompts';
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { Document } from '@langchain/core/documents';
+import { searchSearxng } from '../lib/searxng';
+import type { StreamEvent } from '@langchain/core/tracers/log_stream';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import eventEmitter from 'events';
+import computeSimilarity from '../utils/computeSimilarity';
+import logger from '../utils/logger';
+
+const basicRedditSearchRetrieverPrompt = `
+You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
+If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
+
+Example:
+1. Follow up question: Which company is most likely to create an AGI
+Rephrased: Which company is most likely to create an AGI
+
+2. Follow up question: Is Earth flat?
+Rephrased: Is Earth flat?
+
+3. Follow up question: Is there life on Mars?
+Rephrased: Is there life on Mars?
+
+Conversation:
+{chat_history}
+
+Follow up question: {query}
+Rephrased question:
+`;
+
+const basicRedditSearchResponsePrompt = `
+ You are Perplexica, an AI model who is expert at searching the web and answering user's queries. You are set on focus mode 'Reddit', this means you will be searching for information, opinions and discussions on the web using Reddit.
+
+ Generate a response that is informative and relevant to the user's query based on provided context (the context consits of search results containg a brief description of the content of that page).
+ You must use this context to answer the user's query in the best way possible. Use an unbaised and journalistic tone in your response. Do not repeat the text.
+ You must not tell the user to open any link or visit any website to get the answer. You must provide the answer in the response itself. If the user asks for links you can provide them.
+ Your responses should be medium to long in length be informative and relevant to the user's query. You can use markdowns to format your response. You should use bullet points to list the information. Make sure the answer is not short and is informative.
+ You have to cite the answer using [number] notation. You must cite the sentences with their relevent context number. You must cite each and every part of the answer so the user can know where the information is coming from.
+ Place these citations at the end of that particular sentence. You can cite the same sentence multiple times if it is relevant to the user's query like [number1][number2].
+ However you do not need to cite it using the same number. You can use different numbers to cite the same sentence multiple times. The number refers to the number of the search result (passed in the context) used to generate that part of the answer.
+
+ Aything inside the following \`context\` HTML block provided below is for your knowledge returned by Reddit and is not shared by the user. You have to answer question on the basis of it and cite the relevant information from it but you do not have to
+ talk about the context in your response.
+
+
+ {context}
+
+
+ If you think there's nothing relevant in the search results, you can say that 'Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?'.
+ Anything between the \`context\` is retrieved from Reddit and is not a part of the conversation with the user. Today's date is ${new Date().toISOString()}
+`;
+
+const strParser = new StringOutputParser();
+
+const handleStream = async (
+ stream: AsyncGenerator,
+ emitter: eventEmitter,
+) => {
+ for await (const event of stream) {
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalSourceRetriever'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'sources', data: event.data.output }),
+ );
+ }
+ if (
+ event.event === 'on_chain_stream' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'response', data: event.data.chunk }),
+ );
+ }
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit('end');
+ }
+ }
+};
+
+type BasicChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const createBasicRedditSearchRetrieverChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ PromptTemplate.fromTemplate(basicRedditSearchRetrieverPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ if (input === 'not_needed') {
+ return { query: '', docs: [] };
+ }
+
+ const res = await searchSearxng(input, {
+ language: 'en',
+ engines: ['reddit'],
+ });
+
+ const documents = res.results.map(
+ (result) =>
+ new Document({
+ pageContent: result.content ? result.content : result.title,
+ metadata: {
+ title: result.title,
+ url: result.url,
+ ...(result.img_src && { img_src: result.img_src }),
+ },
+ }),
+ );
+
+ return { query: input, docs: documents };
+ }),
+ ]);
+};
+
+const createBasicRedditSearchAnsweringChain = (
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const basicRedditSearchRetrieverChain =
+ createBasicRedditSearchRetrieverChain(llm);
+
+ const processDocs = async (docs: Document[]) => {
+ return docs
+ .map((_, index) => `${index + 1}. ${docs[index].pageContent}`)
+ .join('\n');
+ };
+
+ const rerankDocs = async ({
+ query,
+ docs,
+ }: {
+ query: string;
+ docs: Document[];
+ }) => {
+ if (docs.length === 0) {
+ return docs;
+ }
+
+ const docsWithContent = docs.filter(
+ (doc) => doc.pageContent && doc.pageContent.length > 0,
+ );
+
+ const [docEmbeddings, queryEmbedding] = await Promise.all([
+ embeddings.embedDocuments(docsWithContent.map((doc) => doc.pageContent)),
+ embeddings.embedQuery(query),
+ ]);
+
+ const similarity = docEmbeddings.map((docEmbedding, i) => {
+ const sim = computeSimilarity(queryEmbedding, docEmbedding);
+
+ return {
+ index: i,
+ similarity: sim,
+ };
+ });
+
+ const sortedDocs = similarity
+ .sort((a, b) => b.similarity - a.similarity)
+ .slice(0, 15)
+ .filter((sim) => sim.similarity > 0.3)
+ .map((sim) => docsWithContent[sim.index]);
+
+ return sortedDocs;
+ };
+
+ return RunnableSequence.from([
+ RunnableMap.from({
+ query: (input: BasicChainInput) => input.query,
+ chat_history: (input: BasicChainInput) => input.chat_history,
+ context: RunnableSequence.from([
+ (input) => ({
+ query: input.query,
+ chat_history: formatChatHistoryAsString(input.chat_history),
+ }),
+ basicRedditSearchRetrieverChain
+ .pipe(rerankDocs)
+ .withConfig({
+ runName: 'FinalSourceRetriever',
+ })
+ .pipe(processDocs),
+ ]),
+ }),
+ ChatPromptTemplate.fromMessages([
+ ['system', basicRedditSearchResponsePrompt],
+ new MessagesPlaceholder('chat_history'),
+ ['user', '{query}'],
+ ]),
+ llm,
+ strParser,
+ ]).withConfig({
+ runName: 'FinalResponseGenerator',
+ });
+};
+
+const basicRedditSearch = (
+ query: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = new eventEmitter();
+
+ try {
+ const basicRedditSearchAnsweringChain =
+ createBasicRedditSearchAnsweringChain(llm, embeddings);
+ const stream = basicRedditSearchAnsweringChain.streamEvents(
+ {
+ chat_history: history,
+ query: query,
+ },
+ {
+ version: 'v1',
+ },
+ );
+
+ handleStream(stream, emitter);
+ } catch (err) {
+ emitter.emit(
+ 'error',
+ JSON.stringify({ data: 'An error has occurred please try again later' }),
+ );
+ logger.error(`Error in RedditSearch: ${err}`);
+ }
+
+ return emitter;
+};
+
+const handleRedditSearch = (
+ message: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = basicRedditSearch(message, history, llm, embeddings);
+ return emitter;
+};
+
+export default handleRedditSearch;
diff --git a/src/agents/videoSearchAgent.ts b/src/agents/videoSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22e0e1e7c1050c5de72e910cf87d3c226da72a97
--- /dev/null
+++ b/src/agents/videoSearchAgent.ts
@@ -0,0 +1,90 @@
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { PromptTemplate } from '@langchain/core/prompts';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import { BaseMessage } from '@langchain/core/messages';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { searchSearxng } from '../lib/searxng';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+
+const VideoSearchChainPrompt = `
+ You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
+ You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
+
+ Example:
+ 1. Follow up question: How does a car work?
+ Rephrased: How does a car work?
+
+ 2. Follow up question: What is the theory of relativity?
+ Rephrased: What is theory of relativity
+
+ 3. Follow up question: How does an AC work?
+ Rephrased: How does an AC work
+
+ Conversation:
+ {chat_history}
+
+ Follow up question: {query}
+ Rephrased question:
+ `;
+
+type VideoSearchChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const strParser = new StringOutputParser();
+
+const createVideoSearchChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ RunnableMap.from({
+ chat_history: (input: VideoSearchChainInput) => {
+ return formatChatHistoryAsString(input.chat_history);
+ },
+ query: (input: VideoSearchChainInput) => {
+ return input.query;
+ },
+ }),
+ PromptTemplate.fromTemplate(VideoSearchChainPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ const res = await searchSearxng(input, {
+ engines: ['youtube'],
+ });
+
+ const videos = [];
+
+ res.results.forEach((result) => {
+ if (
+ result.thumbnail &&
+ result.url &&
+ result.title &&
+ result.iframe_src
+ ) {
+ videos.push({
+ img_src: result.thumbnail,
+ url: result.url,
+ title: result.title,
+ iframe_src: result.iframe_src,
+ });
+ }
+ });
+
+ return videos.slice(0, 10);
+ }),
+ ]);
+};
+
+const handleVideoSearch = (
+ input: VideoSearchChainInput,
+ llm: BaseChatModel,
+) => {
+ const VideoSearchChain = createVideoSearchChain(llm);
+ return VideoSearchChain.invoke(input);
+};
+
+export default handleVideoSearch;
diff --git a/src/agents/webSearchAgent.ts b/src/agents/webSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3c43063bb2225c14a7f01a313dcf35c962d6896f
--- /dev/null
+++ b/src/agents/webSearchAgent.ts
@@ -0,0 +1,261 @@
+import { BaseMessage } from '@langchain/core/messages';
+import {
+ PromptTemplate,
+ ChatPromptTemplate,
+ MessagesPlaceholder,
+} from '@langchain/core/prompts';
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { Document } from '@langchain/core/documents';
+import { searchSearxng } from '../lib/searxng';
+import type { StreamEvent } from '@langchain/core/tracers/log_stream';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import eventEmitter from 'events';
+import computeSimilarity from '../utils/computeSimilarity';
+import logger from '../utils/logger';
+
+const basicSearchRetrieverPrompt = `
+You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
+If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
+
+Example:
+1. Follow up question: What is the capital of France?
+Rephrased: Capital of france
+
+2. Follow up question: What is the population of New York City?
+Rephrased: Population of New York City
+
+3. Follow up question: What is Docker?
+Rephrased: What is Docker
+
+Conversation:
+{chat_history}
+
+Follow up question: {query}
+Rephrased question:
+`;
+
+const basicWebSearchResponsePrompt = `
+ You are Perplexica, an AI model who is expert at searching the web and answering user's queries.
+
+ Generate a response that is informative and relevant to the user's query based on provided context (the context consits of search results containg a brief description of the content of that page).
+ You must use this context to answer the user's query in the best way possible. Use an unbaised and journalistic tone in your response. Do not repeat the text.
+ You must not tell the user to open any link or visit any website to get the answer. You must provide the answer in the response itself. If the user asks for links you can provide them.
+ Your responses should be medium to long in length be informative and relevant to the user's query. You can use markdowns to format your response. You should use bullet points to list the information. Make sure the answer is not short and is informative.
+ You have to cite the answer using [number] notation. You must cite the sentences with their relevent context number. You must cite each and every part of the answer so the user can know where the information is coming from.
+ Place these citations at the end of that particular sentence. You can cite the same sentence multiple times if it is relevant to the user's query like [number1][number2].
+ However you do not need to cite it using the same number. You can use different numbers to cite the same sentence multiple times. The number refers to the number of the search result (passed in the context) used to generate that part of the answer.
+
+ Aything inside the following \`context\` HTML block provided below is for your knowledge returned by the search engine and is not shared by the user. You have to answer question on the basis of it and cite the relevant information from it but you do not have to
+ talk about the context in your response.
+
+
+ {context}
+
+
+ If you think there's nothing relevant in the search results, you can say that 'Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?'.
+ Anything between the \`context\` is retrieved from a search engine and is not a part of the conversation with the user. Today's date is ${new Date().toISOString()}
+`;
+
+const strParser = new StringOutputParser();
+
+const handleStream = async (
+ stream: AsyncGenerator,
+ emitter: eventEmitter,
+) => {
+ for await (const event of stream) {
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalSourceRetriever'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'sources', data: event.data.output }),
+ );
+ }
+ if (
+ event.event === 'on_chain_stream' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'response', data: event.data.chunk }),
+ );
+ }
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit('end');
+ }
+ }
+};
+
+type BasicChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const createBasicWebSearchRetrieverChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ PromptTemplate.fromTemplate(basicSearchRetrieverPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ if (input === 'not_needed') {
+ return { query: '', docs: [] };
+ }
+
+ const res = await searchSearxng(input, {
+ language: 'en',
+ });
+
+ const documents = res.results.map(
+ (result) =>
+ new Document({
+ pageContent: result.content,
+ metadata: {
+ title: result.title,
+ url: result.url,
+ ...(result.img_src && { img_src: result.img_src }),
+ },
+ }),
+ );
+
+ return { query: input, docs: documents };
+ }),
+ ]);
+};
+
+const createBasicWebSearchAnsweringChain = (
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const basicWebSearchRetrieverChain = createBasicWebSearchRetrieverChain(llm);
+
+ const processDocs = async (docs: Document[]) => {
+ return docs
+ .map((_, index) => `${index + 1}. ${docs[index].pageContent}`)
+ .join('\n');
+ };
+
+ const rerankDocs = async ({
+ query,
+ docs,
+ }: {
+ query: string;
+ docs: Document[];
+ }) => {
+ if (docs.length === 0) {
+ return docs;
+ }
+
+ const docsWithContent = docs.filter(
+ (doc) => doc.pageContent && doc.pageContent.length > 0,
+ );
+
+ const [docEmbeddings, queryEmbedding] = await Promise.all([
+ embeddings.embedDocuments(docsWithContent.map((doc) => doc.pageContent)),
+ embeddings.embedQuery(query),
+ ]);
+
+ const similarity = docEmbeddings.map((docEmbedding, i) => {
+ const sim = computeSimilarity(queryEmbedding, docEmbedding);
+
+ return {
+ index: i,
+ similarity: sim,
+ };
+ });
+
+ const sortedDocs = similarity
+ .sort((a, b) => b.similarity - a.similarity)
+ .filter((sim) => sim.similarity > 0.5)
+ .slice(0, 15)
+ .map((sim) => docsWithContent[sim.index]);
+
+ return sortedDocs;
+ };
+
+ return RunnableSequence.from([
+ RunnableMap.from({
+ query: (input: BasicChainInput) => input.query,
+ chat_history: (input: BasicChainInput) => input.chat_history,
+ context: RunnableSequence.from([
+ (input) => ({
+ query: input.query,
+ chat_history: formatChatHistoryAsString(input.chat_history),
+ }),
+ basicWebSearchRetrieverChain
+ .pipe(rerankDocs)
+ .withConfig({
+ runName: 'FinalSourceRetriever',
+ })
+ .pipe(processDocs),
+ ]),
+ }),
+ ChatPromptTemplate.fromMessages([
+ ['system', basicWebSearchResponsePrompt],
+ new MessagesPlaceholder('chat_history'),
+ ['user', '{query}'],
+ ]),
+ llm,
+ strParser,
+ ]).withConfig({
+ runName: 'FinalResponseGenerator',
+ });
+};
+
+const basicWebSearch = (
+ query: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = new eventEmitter();
+
+ try {
+ const basicWebSearchAnsweringChain = createBasicWebSearchAnsweringChain(
+ llm,
+ embeddings,
+ );
+
+ const stream = basicWebSearchAnsweringChain.streamEvents(
+ {
+ chat_history: history,
+ query: query,
+ },
+ {
+ version: 'v1',
+ },
+ );
+
+ handleStream(stream, emitter);
+ } catch (err) {
+ emitter.emit(
+ 'error',
+ JSON.stringify({ data: 'An error has occurred please try again later' }),
+ );
+ logger.error(`Error in websearch: ${err}`);
+ }
+
+ return emitter;
+};
+
+const handleWebSearch = (
+ message: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = basicWebSearch(message, history, llm, embeddings);
+ return emitter;
+};
+
+export default handleWebSearch;
diff --git a/src/agents/wolframAlphaSearchAgent.ts b/src/agents/wolframAlphaSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8faff5d5efcd68f9adf260becf97f8fb1ca47263
--- /dev/null
+++ b/src/agents/wolframAlphaSearchAgent.ts
@@ -0,0 +1,219 @@
+import { BaseMessage } from '@langchain/core/messages';
+import {
+ PromptTemplate,
+ ChatPromptTemplate,
+ MessagesPlaceholder,
+} from '@langchain/core/prompts';
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { Document } from '@langchain/core/documents';
+import { searchSearxng } from '../lib/searxng';
+import type { StreamEvent } from '@langchain/core/tracers/log_stream';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import eventEmitter from 'events';
+import logger from '../utils/logger';
+
+const basicWolframAlphaSearchRetrieverPrompt = `
+You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
+If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
+
+Example:
+1. Follow up question: What is the atomic radius of S?
+Rephrased: Atomic radius of S
+
+2. Follow up question: What is linear algebra?
+Rephrased: Linear algebra
+
+3. Follow up question: What is the third law of thermodynamics?
+Rephrased: Third law of thermodynamics
+
+Conversation:
+{chat_history}
+
+Follow up question: {query}
+Rephrased question:
+`;
+
+const basicWolframAlphaSearchResponsePrompt = `
+ You are Perplexica, an AI model who is expert at searching the web and answering user's queries. You are set on focus mode 'Wolfram Alpha', this means you will be searching for information on the web using Wolfram Alpha. It is a computational knowledge engine that can answer factual queries and perform computations.
+
+ Generate a response that is informative and relevant to the user's query based on provided context (the context consits of search results containg a brief description of the content of that page).
+ You must use this context to answer the user's query in the best way possible. Use an unbaised and journalistic tone in your response. Do not repeat the text.
+ You must not tell the user to open any link or visit any website to get the answer. You must provide the answer in the response itself. If the user asks for links you can provide them.
+ Your responses should be medium to long in length be informative and relevant to the user's query. You can use markdowns to format your response. You should use bullet points to list the information. Make sure the answer is not short and is informative.
+ You have to cite the answer using [number] notation. You must cite the sentences with their relevent context number. You must cite each and every part of the answer so the user can know where the information is coming from.
+ Place these citations at the end of that particular sentence. You can cite the same sentence multiple times if it is relevant to the user's query like [number1][number2].
+ However you do not need to cite it using the same number. You can use different numbers to cite the same sentence multiple times. The number refers to the number of the search result (passed in the context) used to generate that part of the answer.
+
+ Aything inside the following \`context\` HTML block provided below is for your knowledge returned by Wolfram Alpha and is not shared by the user. You have to answer question on the basis of it and cite the relevant information from it but you do not have to
+ talk about the context in your response.
+
+
+ {context}
+
+
+ If you think there's nothing relevant in the search results, you can say that 'Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?'.
+ Anything between the \`context\` is retrieved from Wolfram Alpha and is not a part of the conversation with the user. Today's date is ${new Date().toISOString()}
+`;
+
+const strParser = new StringOutputParser();
+
+const handleStream = async (
+ stream: AsyncGenerator,
+ emitter: eventEmitter,
+) => {
+ for await (const event of stream) {
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalSourceRetriever'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'sources', data: event.data.output }),
+ );
+ }
+ if (
+ event.event === 'on_chain_stream' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'response', data: event.data.chunk }),
+ );
+ }
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit('end');
+ }
+ }
+};
+
+type BasicChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const createBasicWolframAlphaSearchRetrieverChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ PromptTemplate.fromTemplate(basicWolframAlphaSearchRetrieverPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ if (input === 'not_needed') {
+ return { query: '', docs: [] };
+ }
+
+ const res = await searchSearxng(input, {
+ language: 'en',
+ engines: ['wolframalpha'],
+ });
+
+ const documents = res.results.map(
+ (result) =>
+ new Document({
+ pageContent: result.content,
+ metadata: {
+ title: result.title,
+ url: result.url,
+ ...(result.img_src && { img_src: result.img_src }),
+ },
+ }),
+ );
+
+ return { query: input, docs: documents };
+ }),
+ ]);
+};
+
+const createBasicWolframAlphaSearchAnsweringChain = (llm: BaseChatModel) => {
+ const basicWolframAlphaSearchRetrieverChain =
+ createBasicWolframAlphaSearchRetrieverChain(llm);
+
+ const processDocs = (docs: Document[]) => {
+ return docs
+ .map((_, index) => `${index + 1}. ${docs[index].pageContent}`)
+ .join('\n');
+ };
+
+ return RunnableSequence.from([
+ RunnableMap.from({
+ query: (input: BasicChainInput) => input.query,
+ chat_history: (input: BasicChainInput) => input.chat_history,
+ context: RunnableSequence.from([
+ (input) => ({
+ query: input.query,
+ chat_history: formatChatHistoryAsString(input.chat_history),
+ }),
+ basicWolframAlphaSearchRetrieverChain
+ .pipe(({ query, docs }) => {
+ return docs;
+ })
+ .withConfig({
+ runName: 'FinalSourceRetriever',
+ })
+ .pipe(processDocs),
+ ]),
+ }),
+ ChatPromptTemplate.fromMessages([
+ ['system', basicWolframAlphaSearchResponsePrompt],
+ new MessagesPlaceholder('chat_history'),
+ ['user', '{query}'],
+ ]),
+ llm,
+ strParser,
+ ]).withConfig({
+ runName: 'FinalResponseGenerator',
+ });
+};
+
+const basicWolframAlphaSearch = (
+ query: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+) => {
+ const emitter = new eventEmitter();
+
+ try {
+ const basicWolframAlphaSearchAnsweringChain =
+ createBasicWolframAlphaSearchAnsweringChain(llm);
+ const stream = basicWolframAlphaSearchAnsweringChain.streamEvents(
+ {
+ chat_history: history,
+ query: query,
+ },
+ {
+ version: 'v1',
+ },
+ );
+
+ handleStream(stream, emitter);
+ } catch (err) {
+ emitter.emit(
+ 'error',
+ JSON.stringify({ data: 'An error has occurred please try again later' }),
+ );
+ logger.error(`Error in WolframAlphaSearch: ${err}`);
+ }
+
+ return emitter;
+};
+
+const handleWolframAlphaSearch = (
+ message: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = basicWolframAlphaSearch(message, history, llm);
+ return emitter;
+};
+
+export default handleWolframAlphaSearch;
diff --git a/src/agents/writingAssistant.ts b/src/agents/writingAssistant.ts
new file mode 100644
index 0000000000000000000000000000000000000000..efcfcef2d1d9bb2384b29b84c3a79834d49053b5
--- /dev/null
+++ b/src/agents/writingAssistant.ts
@@ -0,0 +1,90 @@
+import { BaseMessage } from '@langchain/core/messages';
+import {
+ ChatPromptTemplate,
+ MessagesPlaceholder,
+} from '@langchain/core/prompts';
+import { RunnableSequence } from '@langchain/core/runnables';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import type { StreamEvent } from '@langchain/core/tracers/log_stream';
+import eventEmitter from 'events';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import logger from '../utils/logger';
+
+const writingAssistantPrompt = `
+You are Perplexica, an AI model who is expert at searching the web and answering user's queries. You are currently set on focus mode 'Writing Assistant', this means you will be helping the user write a response to a given query.
+Since you are a writing assistant, you would not perform web searches. If you think you lack information to answer the query, you can ask the user for more information or suggest them to switch to a different focus mode.
+`;
+
+const strParser = new StringOutputParser();
+
+const handleStream = async (
+ stream: AsyncGenerator,
+ emitter: eventEmitter,
+) => {
+ for await (const event of stream) {
+ if (
+ event.event === 'on_chain_stream' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'response', data: event.data.chunk }),
+ );
+ }
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit('end');
+ }
+ }
+};
+
+const createWritingAssistantChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ ChatPromptTemplate.fromMessages([
+ ['system', writingAssistantPrompt],
+ new MessagesPlaceholder('chat_history'),
+ ['user', '{query}'],
+ ]),
+ llm,
+ strParser,
+ ]).withConfig({
+ runName: 'FinalResponseGenerator',
+ });
+};
+
+const handleWritingAssistant = (
+ query: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = new eventEmitter();
+
+ try {
+ const writingAssistantChain = createWritingAssistantChain(llm);
+ const stream = writingAssistantChain.streamEvents(
+ {
+ chat_history: history,
+ query: query,
+ },
+ {
+ version: 'v1',
+ },
+ );
+
+ handleStream(stream, emitter);
+ } catch (err) {
+ emitter.emit(
+ 'error',
+ JSON.stringify({ data: 'An error has occurred please try again later' }),
+ );
+ logger.error(`Error in writing assistant: ${err}`);
+ }
+
+ return emitter;
+};
+
+export default handleWritingAssistant;
diff --git a/src/agents/youtubeSearchAgent.ts b/src/agents/youtubeSearchAgent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5dcdfceba0eb52f58fc86d5982fb3ae709923b1e
--- /dev/null
+++ b/src/agents/youtubeSearchAgent.ts
@@ -0,0 +1,261 @@
+import { BaseMessage } from '@langchain/core/messages';
+import {
+ PromptTemplate,
+ ChatPromptTemplate,
+ MessagesPlaceholder,
+} from '@langchain/core/prompts';
+import {
+ RunnableSequence,
+ RunnableMap,
+ RunnableLambda,
+} from '@langchain/core/runnables';
+import { StringOutputParser } from '@langchain/core/output_parsers';
+import { Document } from '@langchain/core/documents';
+import { searchSearxng } from '../lib/searxng';
+import type { StreamEvent } from '@langchain/core/tracers/log_stream';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import formatChatHistoryAsString from '../utils/formatHistory';
+import eventEmitter from 'events';
+import computeSimilarity from '../utils/computeSimilarity';
+import logger from '../utils/logger';
+
+const basicYoutubeSearchRetrieverPrompt = `
+You will be given a conversation below and a follow up question. You need to rephrase the follow-up question if needed so it is a standalone question that can be used by the LLM to search the web for information.
+If it is a writing task or a simple hi, hello rather than a question, you need to return \`not_needed\` as the response.
+
+Example:
+1. Follow up question: How does an A.C work?
+Rephrased: A.C working
+
+2. Follow up question: Linear algebra explanation video
+Rephrased: What is linear algebra?
+
+3. Follow up question: What is theory of relativity?
+Rephrased: What is theory of relativity?
+
+Conversation:
+{chat_history}
+
+Follow up question: {query}
+Rephrased question:
+`;
+
+const basicYoutubeSearchResponsePrompt = `
+ You are Perplexica, an AI model who is expert at searching the web and answering user's queries. You are set on focus mode 'Youtube', this means you will be searching for videos on the web using Youtube and providing information based on the video's transcript.
+
+ Generate a response that is informative and relevant to the user's query based on provided context (the context consits of search results containg a brief description of the content of that page).
+ You must use this context to answer the user's query in the best way possible. Use an unbaised and journalistic tone in your response. Do not repeat the text.
+ You must not tell the user to open any link or visit any website to get the answer. You must provide the answer in the response itself. If the user asks for links you can provide them.
+ Your responses should be medium to long in length be informative and relevant to the user's query. You can use markdowns to format your response. You should use bullet points to list the information. Make sure the answer is not short and is informative.
+ You have to cite the answer using [number] notation. You must cite the sentences with their relevent context number. You must cite each and every part of the answer so the user can know where the information is coming from.
+ Place these citations at the end of that particular sentence. You can cite the same sentence multiple times if it is relevant to the user's query like [number1][number2].
+ However you do not need to cite it using the same number. You can use different numbers to cite the same sentence multiple times. The number refers to the number of the search result (passed in the context) used to generate that part of the answer.
+
+ Aything inside the following \`context\` HTML block provided below is for your knowledge returned by Youtube and is not shared by the user. You have to answer question on the basis of it and cite the relevant information from it but you do not have to
+ talk about the context in your response.
+
+
+ {context}
+
+
+ If you think there's nothing relevant in the search results, you can say that 'Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?'.
+ Anything between the \`context\` is retrieved from Youtube and is not a part of the conversation with the user. Today's date is ${new Date().toISOString()}
+`;
+
+const strParser = new StringOutputParser();
+
+const handleStream = async (
+ stream: AsyncGenerator,
+ emitter: eventEmitter,
+) => {
+ for await (const event of stream) {
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalSourceRetriever'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'sources', data: event.data.output }),
+ );
+ }
+ if (
+ event.event === 'on_chain_stream' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit(
+ 'data',
+ JSON.stringify({ type: 'response', data: event.data.chunk }),
+ );
+ }
+ if (
+ event.event === 'on_chain_end' &&
+ event.name === 'FinalResponseGenerator'
+ ) {
+ emitter.emit('end');
+ }
+ }
+};
+
+type BasicChainInput = {
+ chat_history: BaseMessage[];
+ query: string;
+};
+
+const createBasicYoutubeSearchRetrieverChain = (llm: BaseChatModel) => {
+ return RunnableSequence.from([
+ PromptTemplate.fromTemplate(basicYoutubeSearchRetrieverPrompt),
+ llm,
+ strParser,
+ RunnableLambda.from(async (input: string) => {
+ if (input === 'not_needed') {
+ return { query: '', docs: [] };
+ }
+
+ const res = await searchSearxng(input, {
+ language: 'en',
+ engines: ['youtube'],
+ });
+
+ const documents = res.results.map(
+ (result) =>
+ new Document({
+ pageContent: result.content ? result.content : result.title,
+ metadata: {
+ title: result.title,
+ url: result.url,
+ ...(result.img_src && { img_src: result.img_src }),
+ },
+ }),
+ );
+
+ return { query: input, docs: documents };
+ }),
+ ]);
+};
+
+const createBasicYoutubeSearchAnsweringChain = (
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const basicYoutubeSearchRetrieverChain =
+ createBasicYoutubeSearchRetrieverChain(llm);
+
+ const processDocs = async (docs: Document[]) => {
+ return docs
+ .map((_, index) => `${index + 1}. ${docs[index].pageContent}`)
+ .join('\n');
+ };
+
+ const rerankDocs = async ({
+ query,
+ docs,
+ }: {
+ query: string;
+ docs: Document[];
+ }) => {
+ if (docs.length === 0) {
+ return docs;
+ }
+
+ const docsWithContent = docs.filter(
+ (doc) => doc.pageContent && doc.pageContent.length > 0,
+ );
+
+ const [docEmbeddings, queryEmbedding] = await Promise.all([
+ embeddings.embedDocuments(docsWithContent.map((doc) => doc.pageContent)),
+ embeddings.embedQuery(query),
+ ]);
+
+ const similarity = docEmbeddings.map((docEmbedding, i) => {
+ const sim = computeSimilarity(queryEmbedding, docEmbedding);
+
+ return {
+ index: i,
+ similarity: sim,
+ };
+ });
+
+ const sortedDocs = similarity
+ .sort((a, b) => b.similarity - a.similarity)
+ .slice(0, 15)
+ .filter((sim) => sim.similarity > 0.3)
+ .map((sim) => docsWithContent[sim.index]);
+
+ return sortedDocs;
+ };
+
+ return RunnableSequence.from([
+ RunnableMap.from({
+ query: (input: BasicChainInput) => input.query,
+ chat_history: (input: BasicChainInput) => input.chat_history,
+ context: RunnableSequence.from([
+ (input) => ({
+ query: input.query,
+ chat_history: formatChatHistoryAsString(input.chat_history),
+ }),
+ basicYoutubeSearchRetrieverChain
+ .pipe(rerankDocs)
+ .withConfig({
+ runName: 'FinalSourceRetriever',
+ })
+ .pipe(processDocs),
+ ]),
+ }),
+ ChatPromptTemplate.fromMessages([
+ ['system', basicYoutubeSearchResponsePrompt],
+ new MessagesPlaceholder('chat_history'),
+ ['user', '{query}'],
+ ]),
+ llm,
+ strParser,
+ ]).withConfig({
+ runName: 'FinalResponseGenerator',
+ });
+};
+
+const basicYoutubeSearch = (
+ query: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = new eventEmitter();
+
+ try {
+ const basicYoutubeSearchAnsweringChain =
+ createBasicYoutubeSearchAnsweringChain(llm, embeddings);
+
+ const stream = basicYoutubeSearchAnsweringChain.streamEvents(
+ {
+ chat_history: history,
+ query: query,
+ },
+ {
+ version: 'v1',
+ },
+ );
+
+ handleStream(stream, emitter);
+ } catch (err) {
+ emitter.emit(
+ 'error',
+ JSON.stringify({ data: 'An error has occurred please try again later' }),
+ );
+ logger.error(`Error in youtube search: ${err}`);
+ }
+
+ return emitter;
+};
+
+const handleYoutubeSearch = (
+ message: string,
+ history: BaseMessage[],
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ const emitter = basicYoutubeSearch(message, history, llm, embeddings);
+ return emitter;
+};
+
+export default handleYoutubeSearch;
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3084ad0c785a51a003b6b656d43f810c8ef0f435
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,30 @@
+import { startWebSocketServer } from './websocket';
+import express from 'express';
+import cors from 'cors';
+import http from 'http';
+import routes from './routes';
+import { getPort } from './config';
+import logger from './utils/logger';
+
+const port = getPort();
+
+const app = express();
+const server = http.createServer(app);
+
+const corsOptions = {
+ origin: '*',
+};
+
+app.use(cors(corsOptions));
+app.use(express.json());
+
+app.use('/api', routes);
+app.get('/api', (_, res) => {
+ res.status(200).json({ status: 'ok' });
+});
+
+server.listen(port, () => {
+ logger.info(`Server is running on port ${port}`);
+});
+
+startWebSocketServer(server);
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d11fb8e407a47ac8542d678f4af6c2f2b8b95d63
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,69 @@
+import fs from 'fs';
+import path from 'path';
+import toml from '@iarna/toml';
+
+const configFileName = 'config.toml';
+
+interface Config {
+ GENERAL: {
+ PORT: number;
+ SIMILARITY_MEASURE: string;
+ };
+ API_KEYS: {
+ OPENAI: string;
+ GROQ: string;
+ };
+ API_ENDPOINTS: {
+ SEARXNG: string;
+ OLLAMA: string;
+ };
+}
+
+type RecursivePartial = {
+ [P in keyof T]?: RecursivePartial;
+};
+
+const loadConfig = () =>
+ toml.parse(
+ fs.readFileSync(path.join(__dirname, `../${configFileName}`), 'utf-8'),
+ ) as any as Config;
+
+export const getPort = () => loadConfig().GENERAL.PORT;
+
+export const getSimilarityMeasure = () =>
+ loadConfig().GENERAL.SIMILARITY_MEASURE;
+
+export const getOpenaiApiKey = () => loadConfig().API_KEYS.OPENAI;
+
+export const getGroqApiKey = () => loadConfig().API_KEYS.GROQ;
+
+export const getSearxngApiEndpoint = () => loadConfig().API_ENDPOINTS.SEARXNG;
+
+export const getOllamaApiEndpoint = () => loadConfig().API_ENDPOINTS.OLLAMA;
+
+export const updateConfig = (config: RecursivePartial) => {
+ const currentConfig = loadConfig();
+
+ for (const key in currentConfig) {
+ if (!config[key]) config[key] = {};
+
+ if (typeof currentConfig[key] === 'object' && currentConfig[key] !== null) {
+ for (const nestedKey in currentConfig[key]) {
+ if (
+ !config[key][nestedKey] &&
+ currentConfig[key][nestedKey] &&
+ config[key][nestedKey] !== ''
+ ) {
+ config[key][nestedKey] = currentConfig[key][nestedKey];
+ }
+ }
+ } else if (currentConfig[key] && config[key] !== '') {
+ config[key] = currentConfig[key];
+ }
+ }
+
+ fs.writeFileSync(
+ path.join(__dirname, `../${configFileName}`),
+ toml.stringify(config),
+ );
+};
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..01aa50b51440d721faea1ea6604dd3b87ae7dce6
--- /dev/null
+++ b/src/lib/providers.ts
@@ -0,0 +1,157 @@
+import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
+import { ChatOllama } from '@langchain/community/chat_models/ollama';
+import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
+import {
+ getGroqApiKey,
+ getOllamaApiEndpoint,
+ getOpenaiApiKey,
+} from '../config';
+import logger from '../utils/logger';
+
+export const getAvailableChatModelProviders = async () => {
+ const openAIApiKey = getOpenaiApiKey();
+ const groqApiKey = getGroqApiKey();
+ const ollamaEndpoint = getOllamaApiEndpoint();
+
+ const models = {};
+
+ if (openAIApiKey) {
+ try {
+ models['openai'] = {
+ 'GPT-3.5 turbo': new ChatOpenAI({
+ openAIApiKey,
+ modelName: 'gpt-3.5-turbo',
+ temperature: 0.7,
+ }),
+ 'GPT-4': new ChatOpenAI({
+ openAIApiKey,
+ modelName: 'gpt-4',
+ temperature: 0.7,
+ }),
+ 'GPT-4 turbo': new ChatOpenAI({
+ openAIApiKey,
+ modelName: 'gpt-4-turbo',
+ temperature: 0.7,
+ }),
+ };
+ } catch (err) {
+ logger.error(`Error loading OpenAI models: ${err}`);
+ }
+ }
+
+ if (groqApiKey) {
+ try {
+ models['groq'] = {
+ 'LLaMA3 8b': new ChatOpenAI(
+ {
+ openAIApiKey: groqApiKey,
+ modelName: 'llama3-8b-8192',
+ temperature: 0.7,
+ },
+ {
+ baseURL: 'https://api.groq.com/openai/v1',
+ },
+ ),
+ 'LLaMA3 70b': new ChatOpenAI(
+ {
+ openAIApiKey: groqApiKey,
+ modelName: 'llama3-70b-8192',
+ temperature: 0.7,
+ },
+ {
+ baseURL: 'https://api.groq.com/openai/v1',
+ },
+ ),
+ 'Mixtral 8x7b': new ChatOpenAI(
+ {
+ openAIApiKey: groqApiKey,
+ modelName: 'mixtral-8x7b-32768',
+ temperature: 0.7,
+ },
+ {
+ baseURL: 'https://api.groq.com/openai/v1',
+ },
+ ),
+ 'Gemma 7b': new ChatOpenAI(
+ {
+ openAIApiKey: groqApiKey,
+ modelName: 'gemma-7b-it',
+ temperature: 0.7,
+ },
+ {
+ baseURL: 'https://api.groq.com/openai/v1',
+ },
+ ),
+ };
+ } catch (err) {
+ logger.error(`Error loading Groq models: ${err}`);
+ }
+ }
+
+ if (ollamaEndpoint) {
+ try {
+ const response = await fetch(`${ollamaEndpoint}/api/tags`);
+
+ const { models: ollamaModels } = (await response.json()) as any;
+
+ models['ollama'] = ollamaModels.reduce((acc, model) => {
+ acc[model.model] = new ChatOllama({
+ baseUrl: ollamaEndpoint,
+ model: model.model,
+ temperature: 0.7,
+ });
+ return acc;
+ }, {});
+ } 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`);
+
+ 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/lib/searxng.ts b/src/lib/searxng.ts
new file mode 100644
index 0000000000000000000000000000000000000000..54e714e391ee7d0b88aca9387262b32f594d2bd9
--- /dev/null
+++ b/src/lib/searxng.ts
@@ -0,0 +1,47 @@
+import axios from 'axios';
+import { getSearxngApiEndpoint } from '../config';
+
+interface SearxngSearchOptions {
+ categories?: string[];
+ engines?: string[];
+ language?: string;
+ pageno?: number;
+}
+
+interface SearxngSearchResult {
+ title: string;
+ url: string;
+ img_src?: string;
+ thumbnail_src?: string;
+ thumbnail?: string;
+ content?: string;
+ author?: string;
+ iframe_src?: string;
+}
+
+export const searchSearxng = async (
+ query: string,
+ opts?: SearxngSearchOptions,
+) => {
+ const searxngURL = getSearxngApiEndpoint();
+
+ const url = new URL(`${searxngURL}/search?format=json`);
+ url.searchParams.append('q', query);
+
+ if (opts) {
+ Object.keys(opts).forEach((key) => {
+ if (Array.isArray(opts[key])) {
+ url.searchParams.append(key, opts[key].join(','));
+ return;
+ }
+ url.searchParams.append(key, opts[key]);
+ });
+ }
+
+ const res = await axios.get(url.toString());
+
+ const results: SearxngSearchResult[] = res.data.results;
+ const suggestions: string[] = res.data.suggestions;
+
+ return { results, suggestions };
+};
diff --git a/src/routes/config.ts b/src/routes/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ac8104d443e9a4d4a08627301823afa1c872d532
--- /dev/null
+++ b/src/routes/config.ts
@@ -0,0 +1,63 @@
+import express from 'express';
+import {
+ getAvailableChatModelProviders,
+ getAvailableEmbeddingModelProviders,
+} from '../lib/providers';
+import {
+ getGroqApiKey,
+ getOllamaApiEndpoint,
+ getOpenaiApiKey,
+ updateConfig,
+} from '../config';
+
+const router = express.Router();
+
+router.get('/', async (_, res) => {
+ const config = {};
+
+ const [chatModelProviders, embeddingModelProviders] = await Promise.all([
+ getAvailableChatModelProviders(),
+ getAvailableEmbeddingModelProviders(),
+ ]);
+
+ config['chatModelProviders'] = {};
+ config['embeddingModelProviders'] = {};
+
+ for (const provider in chatModelProviders) {
+ config['chatModelProviders'][provider] = Object.keys(
+ chatModelProviders[provider],
+ );
+ }
+
+ for (const provider in embeddingModelProviders) {
+ config['embeddingModelProviders'][provider] = Object.keys(
+ embeddingModelProviders[provider],
+ );
+ }
+
+ config['openaiApiKey'] = getOpenaiApiKey();
+ config['ollamaApiUrl'] = getOllamaApiEndpoint();
+ config['groqApiKey'] = getGroqApiKey();
+
+ res.status(200).json(config);
+});
+
+router.post('/', async (req, res) => {
+ const config = req.body;
+
+ const updatedConfig = {
+ API_KEYS: {
+ OPENAI: config.openaiApiKey,
+ GROQ: config.groqApiKey,
+ },
+ API_ENDPOINTS: {
+ OLLAMA: config.ollamaApiUrl,
+ },
+ };
+
+ updateConfig(updatedConfig);
+
+ res.status(200).json({ message: 'Config updated' });
+});
+
+export default router;
diff --git a/src/routes/images.ts b/src/routes/images.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cc98bd7dcaa986d0dc10a7e045987442e3e3813f
--- /dev/null
+++ b/src/routes/images.ts
@@ -0,0 +1,46 @@
+import express from 'express';
+import handleImageSearch from '../agents/imageSearchAgent';
+import { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import { getAvailableChatModelProviders } from '../lib/providers';
+import { HumanMessage, AIMessage } from '@langchain/core/messages';
+import logger from '../utils/logger';
+
+const router = express.Router();
+
+router.post('/', async (req, res) => {
+ try {
+ let { query, chat_history, chat_model_provider, chat_model } = req.body;
+
+ chat_history = chat_history.map((msg: any) => {
+ if (msg.role === 'user') {
+ return new HumanMessage(msg.content);
+ } else if (msg.role === 'assistant') {
+ return new AIMessage(msg.content);
+ }
+ });
+
+ const chatModels = await getAvailableChatModelProviders();
+ const provider = chat_model_provider || Object.keys(chatModels)[0];
+ const chatModel = chat_model || Object.keys(chatModels[provider])[0];
+
+ let llm: BaseChatModel | undefined;
+
+ if (chatModels[provider] && chatModels[provider][chatModel]) {
+ llm = chatModels[provider][chatModel] as BaseChatModel | undefined;
+ }
+
+ if (!llm) {
+ res.status(500).json({ message: 'Invalid LLM model selected' });
+ return;
+ }
+
+ const images = await handleImageSearch({ query, chat_history }, llm);
+
+ res.status(200).json({ images });
+ } catch (err) {
+ res.status(500).json({ message: 'An error has occurred.' });
+ logger.error(`Error in image search: ${err.message}`);
+ }
+});
+
+export default router;
diff --git a/src/routes/index.ts b/src/routes/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dd75a3f54d137bb4a0817715b3a5aae86a26c2a1
--- /dev/null
+++ b/src/routes/index.ts
@@ -0,0 +1,14 @@
+import express from 'express';
+import imagesRouter from './images';
+import videosRouter from './videos';
+import configRouter from './config';
+import modelsRouter from './models';
+
+const router = express.Router();
+
+router.use('/images', imagesRouter);
+router.use('/videos', videosRouter);
+router.use('/config', configRouter);
+router.use('/models', modelsRouter);
+
+export default router;
diff --git a/src/routes/models.ts b/src/routes/models.ts
new file mode 100644
index 0000000000000000000000000000000000000000..47a33cf1fdbb726e156740b25632d2c32ab3a18d
--- /dev/null
+++ b/src/routes/models.ts
@@ -0,0 +1,24 @@
+import express from 'express';
+import logger from '../utils/logger';
+import {
+ getAvailableChatModelProviders,
+ getAvailableEmbeddingModelProviders,
+} from '../lib/providers';
+
+const router = express.Router();
+
+router.get('/', async (req, res) => {
+ try {
+ const [chatModelProviders, embeddingModelProviders] = await Promise.all([
+ getAvailableChatModelProviders(),
+ getAvailableEmbeddingModelProviders(),
+ ]);
+
+ res.status(200).json({ chatModelProviders, embeddingModelProviders });
+ } catch (err) {
+ res.status(500).json({ message: 'An error has occurred.' });
+ logger.error(err.message);
+ }
+});
+
+export default router;
diff --git a/src/routes/videos.ts b/src/routes/videos.ts
new file mode 100644
index 0000000000000000000000000000000000000000..682d1a5bf2eb0fd456cb0478b1bcedfee1012ddb
--- /dev/null
+++ b/src/routes/videos.ts
@@ -0,0 +1,46 @@
+import express from 'express';
+import { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import { getAvailableChatModelProviders } from '../lib/providers';
+import { HumanMessage, AIMessage } from '@langchain/core/messages';
+import logger from '../utils/logger';
+import handleVideoSearch from '../agents/videoSearchAgent';
+
+const router = express.Router();
+
+router.post('/', async (req, res) => {
+ try {
+ let { query, chat_history, chat_model_provider, chat_model } = req.body;
+
+ chat_history = chat_history.map((msg: any) => {
+ if (msg.role === 'user') {
+ return new HumanMessage(msg.content);
+ } else if (msg.role === 'assistant') {
+ return new AIMessage(msg.content);
+ }
+ });
+
+ const chatModels = await getAvailableChatModelProviders();
+ const provider = chat_model_provider || Object.keys(chatModels)[0];
+ const chatModel = chat_model || Object.keys(chatModels[provider])[0];
+
+ let llm: BaseChatModel | undefined;
+
+ if (chatModels[provider] && chatModels[provider][chatModel]) {
+ llm = chatModels[provider][chatModel] as BaseChatModel | undefined;
+ }
+
+ if (!llm) {
+ res.status(500).json({ message: 'Invalid LLM model selected' });
+ return;
+ }
+
+ const videos = await handleVideoSearch({ chat_history, query }, llm);
+
+ res.status(200).json({ videos });
+ } catch (err) {
+ res.status(500).json({ message: 'An error has occurred.' });
+ logger.error(`Error in video search: ${err.message}`);
+ }
+});
+
+export default router;
diff --git a/src/utils/computeSimilarity.ts b/src/utils/computeSimilarity.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4b093447ce6f27d41f75a4866df688efb7097bf2
--- /dev/null
+++ b/src/utils/computeSimilarity.ts
@@ -0,0 +1,17 @@
+import dot from 'compute-dot';
+import cosineSimilarity from 'compute-cosine-similarity';
+import { getSimilarityMeasure } from '../config';
+
+const computeSimilarity = (x: number[], y: number[]): number => {
+ const similarityMeasure = getSimilarityMeasure();
+
+ if (similarityMeasure === 'cosine') {
+ return cosineSimilarity(x, y);
+ } else if (similarityMeasure === 'dot') {
+ return dot(x, y);
+ }
+
+ throw new Error('Invalid similarity measure');
+};
+
+export default computeSimilarity;
diff --git a/src/utils/formatHistory.ts b/src/utils/formatHistory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1285389b4801aeb6c42dcc1f1f3c039082fc2a54
--- /dev/null
+++ b/src/utils/formatHistory.ts
@@ -0,0 +1,9 @@
+import { BaseMessage } from '@langchain/core/messages';
+
+const formatChatHistoryAsString = (history: BaseMessage[]) => {
+ return history
+ .map((message) => `${message._getType()}: ${message.content}`)
+ .join('\n');
+};
+
+export default formatChatHistoryAsString;
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7e15d0703bf1e23a61d9a8fc8853d734fd25c137
--- /dev/null
+++ b/src/utils/logger.ts
@@ -0,0 +1,22 @@
+import winston from 'winston';
+
+const logger = winston.createLogger({
+ level: 'info',
+ transports: [
+ new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.colorize(),
+ winston.format.simple(),
+ ),
+ }),
+ new winston.transports.File({
+ filename: 'app.log',
+ format: winston.format.combine(
+ winston.format.timestamp(),
+ winston.format.json(),
+ ),
+ }),
+ ],
+});
+
+export default logger;
diff --git a/src/websocket/connectionManager.ts b/src/websocket/connectionManager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f42e000557b9de6065923b4ed1155b28b73cf3a8
--- /dev/null
+++ b/src/websocket/connectionManager.ts
@@ -0,0 +1,86 @@
+import { WebSocket } from 'ws';
+import { handleMessage } from './messageHandler';
+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;
+
+ const [chatModelProviders, embeddingModelProviders] = await Promise.all([
+ getAvailableChatModelProviders(),
+ getAvailableEmbeddingModelProviders(),
+ ]);
+
+ const chatModelProvider =
+ searchParams.get('chatModelProvider') || Object.keys(chatModelProviders)[0];
+ const chatModel =
+ searchParams.get('chatModel') ||
+ Object.keys(chatModelProviders[chatModelProvider])[0];
+
+ const embeddingModelProvider =
+ searchParams.get('embeddingModelProvider') ||
+ Object.keys(embeddingModelProviders)[0];
+ const embeddingModel =
+ searchParams.get('embeddingModel') ||
+ Object.keys(embeddingModelProviders[embeddingModelProvider])[0];
+
+ 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',
+ }),
+ );
+ ws.close();
+ }
+
+ ws.on(
+ 'message',
+ async (message) =>
+ await handleMessage(message.toString(), ws, llm, embeddings),
+ );
+
+ ws.on('close', () => logger.debug('Connection closed'));
+};
diff --git a/src/websocket/index.ts b/src/websocket/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..087fd34d02e737fbe22c5ce6c30580064e82007f
--- /dev/null
+++ b/src/websocket/index.ts
@@ -0,0 +1,8 @@
+import { initServer } from './websocketServer';
+import http from 'http';
+
+export const startWebSocketServer = (
+ server: http.Server,
+) => {
+ initServer(server);
+};
diff --git a/src/websocket/messageHandler.ts b/src/websocket/messageHandler.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e9d8b6b32244a363709f77d0af1ed9c36e102650
--- /dev/null
+++ b/src/websocket/messageHandler.ts
@@ -0,0 +1,109 @@
+import { EventEmitter, WebSocket } from 'ws';
+import { BaseMessage, AIMessage, HumanMessage } from '@langchain/core/messages';
+import handleWebSearch from '../agents/webSearchAgent';
+import handleAcademicSearch from '../agents/academicSearchAgent';
+import handleWritingAssistant from '../agents/writingAssistant';
+import handleWolframAlphaSearch from '../agents/wolframAlphaSearchAgent';
+import handleYoutubeSearch from '../agents/youtubeSearchAgent';
+import handleRedditSearch from '../agents/redditSearchAgent';
+import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
+import type { Embeddings } from '@langchain/core/embeddings';
+import logger from '../utils/logger';
+
+type Message = {
+ type: string;
+ content: string;
+ copilot: boolean;
+ focusMode: string;
+ history: Array<[string, string]>;
+};
+
+const searchHandlers = {
+ webSearch: handleWebSearch,
+ academicSearch: handleAcademicSearch,
+ writingAssistant: handleWritingAssistant,
+ wolframAlphaSearch: handleWolframAlphaSearch,
+ youtubeSearch: handleYoutubeSearch,
+ redditSearch: handleRedditSearch,
+};
+
+const handleEmitterEvents = (
+ emitter: EventEmitter,
+ ws: WebSocket,
+ id: string,
+) => {
+ emitter.on('data', (data) => {
+ const parsedData = JSON.parse(data);
+ if (parsedData.type === 'response') {
+ ws.send(
+ JSON.stringify({
+ type: 'message',
+ data: parsedData.data,
+ messageId: id,
+ }),
+ );
+ } else if (parsedData.type === 'sources') {
+ ws.send(
+ JSON.stringify({
+ type: 'sources',
+ data: parsedData.data,
+ messageId: id,
+ }),
+ );
+ }
+ });
+ emitter.on('end', () => {
+ ws.send(JSON.stringify({ type: 'messageEnd', messageId: id }));
+ });
+ emitter.on('error', (data) => {
+ const parsedData = JSON.parse(data);
+ ws.send(JSON.stringify({ type: 'error', data: parsedData.data }));
+ });
+};
+
+export const handleMessage = async (
+ message: string,
+ ws: WebSocket,
+ llm: BaseChatModel,
+ embeddings: Embeddings,
+) => {
+ try {
+ const parsedMessage = JSON.parse(message) as Message;
+ const id = Math.random().toString(36).substring(7);
+
+ if (!parsedMessage.content)
+ return ws.send(
+ JSON.stringify({ type: 'error', data: 'Invalid message format' }),
+ );
+
+ const history: BaseMessage[] = parsedMessage.history.map((msg) => {
+ if (msg[0] === 'human') {
+ return new HumanMessage({
+ content: msg[1],
+ });
+ } else {
+ return new AIMessage({
+ content: msg[1],
+ });
+ }
+ });
+
+ if (parsedMessage.type === 'message') {
+ const handler = searchHandlers[parsedMessage.focusMode];
+ if (handler) {
+ const emitter = handler(
+ parsedMessage.content,
+ history,
+ llm,
+ embeddings,
+ );
+ handleEmitterEvents(emitter, ws, id);
+ } else {
+ ws.send(JSON.stringify({ type: 'error', data: 'Invalid focus mode' }));
+ }
+ }
+ } catch (err) {
+ ws.send(JSON.stringify({ type: 'error', data: 'Invalid message format' }));
+ logger.error(`Failed to handle message: ${err}`);
+ }
+};
diff --git a/src/websocket/websocketServer.ts b/src/websocket/websocketServer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e839817f9ae4650d549898734a27bbae1f9f3d34
--- /dev/null
+++ b/src/websocket/websocketServer.ts
@@ -0,0 +1,16 @@
+import { WebSocketServer } from 'ws';
+import { handleConnection } from './connectionManager';
+import http from 'http';
+import { getPort } from '../config';
+import logger from '../utils/logger';
+
+export const initServer = (
+ server: http.Server,
+) => {
+ const port = getPort();
+ const wss = new WebSocketServer({ server });
+
+ wss.on('connection', handleConnection);
+
+ logger.info(`WebSocket server started on port ${port}`);
+};
diff --git a/ui/.env.example b/ui/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..35baef6704c74ae103141fbd0e69a1d1da1b8454
--- /dev/null
+++ b/ui/.env.example
@@ -0,0 +1,2 @@
+NEXT_PUBLIC_WS_URL=ws://localhost:3001
+NEXT_PUBLIC_API_URL=http://localhost:3001/api
\ No newline at end of file
diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..ea782d70ab03aa5bb6423f4b092faf80d407eede
--- /dev/null
+++ b/ui/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
diff --git a/ui/.gitignore b/ui/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..0d54d0a85efba9fafe61ba24a7d3b8455d682931
--- /dev/null
+++ b/ui/.gitignore
@@ -0,0 +1,34 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/ui/.prettierrc.js b/ui/.prettierrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..17ae91c5048ab46d72875aa831448ffc25597f52
--- /dev/null
+++ b/ui/.prettierrc.js
@@ -0,0 +1,11 @@
+/** @type {import("prettier").Config} */
+
+const config = {
+ printWidth: 80,
+ trailingComma: 'all',
+ endOfLine: 'auto',
+ singleQuote: true,
+ tabWidth: 2,
+};
+
+module.exports = config;
diff --git a/ui/app/discover/page.tsx b/ui/app/discover/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3fe4e2e44b4b99d38134aaeb0a41ac9dce6587f6
--- /dev/null
+++ b/ui/app/discover/page.tsx
@@ -0,0 +1,5 @@
+const Page = () => {
+ return
page
;
+};
+
+export default Page;
diff --git a/ui/app/favicon.ico b/ui/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c
Binary files /dev/null and b/ui/app/favicon.ico differ
diff --git a/ui/app/globals.css b/ui/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..d0e006969d66efb2aa570823bc3b1db6177ae399
--- /dev/null
+++ b/ui/app/globals.css
@@ -0,0 +1,13 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ .overflow-hidden-scrollable {
+ -ms-overflow-style: none;
+ }
+
+ .overflow-hidden-scrollable::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dbb00adabbe6ae6189a74cae3e8f83b986994d73
--- /dev/null
+++ b/ui/app/layout.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from 'next';
+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'],
+ subsets: ['latin'],
+ display: 'swap',
+ fallback: ['Arial', 'sans-serif'],
+});
+
+export const metadata: Metadata = {
+ title: 'Perplexica - Chat with the internet',
+ description:
+ 'Perplexica is an AI powered chatbot that is connected to the internet.',
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/ui/app/page.tsx b/ui/app/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..88fe9f781b3b658845c5b98e3cf8c00b1577ce70
--- /dev/null
+++ b/ui/app/page.tsx
@@ -0,0 +1,17 @@
+import ChatWindow from '@/components/ChatWindow';
+import { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: 'Chat - Perplexica',
+ description: 'Chat with the internet, chat with Perplexica.',
+};
+
+const Home = () => {
+ return (
+