Initial commit

This commit is contained in:
ItzCrazyKns 2024-04-09 16:21:05 +05:30
commit d1c74c861e
No known key found for this signature in database
GPG Key ID: 8162927C7CCE3065
57 changed files with 4568 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
PORT=3001
OPENAI_API_KEY=
SIMILARITY_MEASURE=cosine # cosine or dot
SEARXNG_API_URL= # no need to fill this if using docker

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Node.js
node_modules/
npm-debug.log
yarn-error.log
# Build output
/.next/
/out/
# IDE/Editor specific
.vscode/
.idea/
*.iml
# Dependency lock files
package-lock.json
yarn.lock
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Log files
logs/
*.log
# Testing
/coverage/
# Miscellaneous
.DS_Store
Thumbs.db

12
.prettierrc.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import("prettier").Config} */
const config = {
printWidth: 80,
trailingComma: 'all',
endOfLine: 'auto',
singleQuote: true,
tabWidth: 2,
semi: true,
};
module.exports = config;

74
README.md Normal file
View File

@ -0,0 +1,74 @@
# 🚀 Perplexica - An AI-powered search engine 🔎
![preview](.assets/perplexica-screenshot.png)
## Overview
Perplexica is an open-source AI-powered searching tool or an AI-powered search engine that goes deep into the internet to find answers. Inspired by Perplexity AI, it's an open-source option that not just searches the web but understands your questions. It uses advanced machine learning algorithms like similarity searching and embeddings to refine results and provides clear answers with sources cited.
## Preview
![video-preview](.assets/perplexica-preview.gif)
## Features
- **Two Main Modes:**
- **Copilot Mode:** (In development) Boosts search by generating different queries to find more relevant internet sources. Like normal search instead of just using the context by SearxNG, it visits the top matches and tries to find relevant sources to the user's query directly from the page.
- **Normal Mode:** Processes your query and performs a web search.
- **Focus Modes:** (In development) special modes to better answer specific types of questions.
- **Current Information:** Some search tools might give you outdated info because they use data from crawling bots and convert them into embeddings and store them in a index (its like converting the web into embeddings which is quite expensive.). Unlike them, Perplexica uses SearxNG, a metasearch engine to get the results and rerank and get the most relevent source out of it, ensuring you always get the latest information without the overhead of daily data updates.
It has many more features like image and video search. Some of the planned features are mentioned in [upcoming features](#upcoming-features).
## Installation
There are mainly 2 ways of installing Perplexica - With Docker, Without Docker. Using Docker is highly recommended.
### Getting Started with Docker (Recommended)
1. Make sure Docker is installed and running on your system.
2. Clone the Perplexica repository:
```bash
git clone https://github.com/ItzCrazyKns/Perplexica.git
```
3. After cloning, rename the `.env.example` file to `.env` in the root directory. For Docker setups, you only need to fill these fields:
- `OPENAI_API_KEY`
- `SIMILARITY_MEASURE` (Its filled by default, you can leave it if you do not know about it.)
4. Navigate to the directory containing `docker-compose.yaml` and execute:
```bash
docker compose up
```
5. Wait a few minutes for the setup to complete. Access Perplexica at `http://localhost:3001` in your web browser.
### Non-Docker Installation
For setups without Docker:
1. Follow the initial steps to clone the repository and rename the `.env.example` file to `.env` in the root directory. You will need to fill in all the fields in this file.
2. Additionally, rename the `.env.example` file to `.env` in the `ui` folder and complete all fields.
3. The non-Docker setup requires manual configuration of both the backend and frontend.
**Note**: Using Docker is recommended as it simplifies the setup process, especially for managing environment variables and dependencies.
## Upcoming Features
- Finalizing Copilot Mode
- Adding support for multiple local LLMs and LLM providers such as Anthropic, Google, etc.
- Adding Discover and History Saving features
- Introducing various Focus Modes
- Continuous bug fixing
## Contribution
Perplexica is built on the idea that AI and large language models should be easy for everyone to use. If you find bugs or have ideas, please share them in via GitHub Issues. Details on how to contribute will be shared soon.
## Acknowledgements
Inspired by Perplexity AI, Perplexica aims to provide a similar service but always up-to-date and fully open source, thanks to SearxNG.
If you have any queries you can reach me via my Discord - `itzcrazykns`. Thanks for checking out Perplexica.

15
app.dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:alpine
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
WORKDIR /home/perplexica
COPY ui /home/perplexica/
RUN yarn install
RUN yarn build
CMD ["yarn", "start"]

17
backend.dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:alpine
ARG SEARXNG_API_URL
ENV SEARXNG_API_URL=${SEARXNG_API_URL}
WORKDIR /home/perplexica
COPY src /home/perplexica/src
COPY tsconfig.json /home/perplexica/
COPY .env /home/perplexica/
COPY package.json /home/perplexica/
COPY yarn.lock /home/perplexica/
RUN yarn install
RUN yarn build
CMD ["yarn", "start"]

44
docker-compose.yaml Normal file
View File

@ -0,0 +1,44 @@
services:
searxng:
build:
context: .
dockerfile: searxng.dockerfile
expose:
- 4000
ports:
- 4000:8080
networks:
- perplexica-network
perplexica-backend:
build:
context: .
dockerfile: backend.dockerfile
args:
- SEARXNG_API_URL=http://searxng:8080
depends_on:
- searxng
expose:
- 3001
ports:
- 3001:3001
networks:
- perplexica-network
perplexica-frontend:
build:
context: .
dockerfile: app.dockerfile
args:
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api
- NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001
depends_on:
- perplexica-backend
expose:
- 3000
ports:
- 3000:3000
networks:
- perplexica-network
networks:
perplexica-network:

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "perplexica-backend",
"version": "1.0.0",
"license": "MIT",
"author": "ItzCrazyKns",
"scripts": {
"start": "node --env-file=.env dist/app.js",
"build": "tsc",
"dev": "nodemon --env-file=.env src/app.ts",
"format": "prettier . --check",
"format:write": "prettier . --write"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/readable-stream": "^4.0.11",
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.3"
},
"dependencies": {
"@langchain/openai": "^0.0.25",
"axios": "^1.6.8",
"compute-cosine-similarity": "^1.1.0",
"compute-dot": "^1.1.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"langchain": "^0.1.30",
"ws": "^8.16.0",
"zod": "^3.22.4"
}
}

2380
searxng-settings.yml Normal file

File diff suppressed because it is too large Load Diff

3
searxng.dockerfile Normal file
View File

@ -0,0 +1,3 @@
FROM searxng/searxng
COPY searxng-settings.yml /etc/searxng/settings.yml

View File

@ -0,0 +1,80 @@
import {
RunnableSequence,
RunnableMap,
RunnableLambda,
} from '@langchain/core/runnables';
import { PromptTemplate } from '@langchain/core/prompts';
import { OpenAI } from '@langchain/openai';
import formatChatHistoryAsString from '../utils/formatHistory';
import { BaseMessage } from '@langchain/core/messages';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { searchSearxng } from '../core/searxng';
const llm = new OpenAI({
temperature: 0,
modelName: 'gpt-3.5-turbo',
});
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 imageSearchChain = 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, {
categories: ['images'],
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);
}),
]);
export default imageSearchChain;

View File

@ -0,0 +1,250 @@
import { BaseMessage } from '@langchain/core/messages';
import {
PromptTemplate,
ChatPromptTemplate,
MessagesPlaceholder,
} from '@langchain/core/prompts';
import {
RunnableSequence,
RunnableMap,
RunnableLambda,
} from '@langchain/core/runnables';
import { ChatOpenAI, OpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { Document } from '@langchain/core/documents';
import { searchSearxng } from '../core/searxng';
import type { StreamEvent } from '@langchain/core/tracers/log_stream';
import formatChatHistoryAsString from '../utils/formatHistory';
import eventEmitter from 'events';
import computeSimilarity from '../utils/computeSimilarity';
const chatLLM = new ChatOpenAI({
modelName: 'gpt-3.5-turbo',
temperature: 0.7,
});
const llm = new OpenAI({
temperature: 0,
modelName: 'gpt-3.5-turbo',
});
const embeddings = new OpenAIEmbeddings({
modelName: 'text-embedding-3-large',
});
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>
{context}
</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<StreamEvent, any, unknown>,
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');
}
}
};
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 = await embeddings.embedDocuments(
docsWithContent.map((doc) => doc.pageContent),
);
const queryEmbedding = await 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;
};
type BasicChainInput = {
chat_history: BaseMessage[];
query: string;
};
const basicWebSearchRetrieverChain = 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 basicWebSearchAnsweringChain = 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}'],
]),
chatLLM,
strParser,
]).withConfig({
runName: 'FinalResponseGenerator',
});
const basicWebSearch = (query: string, history: BaseMessage[]) => {
const emitter = new eventEmitter();
try {
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' }),
);
console.error(err);
}
return emitter;
};
const handleWebSearch = (message: string, history: BaseMessage[]) => {
const emitter = basicWebSearch(message, history);
return emitter;
};
export default handleWebSearch;

26
src/app.ts Normal file
View File

@ -0,0 +1,26 @@
import { startWebSocketServer } from './websocket';
import express from 'express';
import cors from 'cors';
import http from 'http';
import routes from './routes';
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(process.env.PORT!, () => {
console.log(`API server started on port ${process.env.PORT}`);
});
startWebSocketServer(server);

69
src/core/agentPicker.ts Normal file
View File

@ -0,0 +1,69 @@
import { z } from 'zod';
import { OpenAI } from '@langchain/openai';
import { RunnableSequence } from '@langchain/core/runnables';
import { StructuredOutputParser } from 'langchain/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
const availableAgents = [
{
name: 'webSearch',
description:
'It is expert is searching the web for information and answer user queries',
},
/* {
name: 'academicSearch',
description:
'It is expert is searching the academic databases for information and answer user queries. It is particularly good at finding research papers and articles on topics like science, engineering, and technology. Use this instead of wolframAlphaSearch if the user query is not mathematical or scientific in nature',
},
{
name: 'youtubeSearch',
description:
'This model is expert at finding videos on youtube based on user queries',
},
{
name: 'wolframAlphaSearch',
description:
'This model is expert at finding answers to mathematical and scientific questions based on user queries.',
},
{
name: 'redditSearch',
description:
'This model is expert at finding posts and discussions on reddit based on user queries',
},
{
name: 'writingAssistant',
description:
'If there is no need for searching, this model is expert at generating text based on user queries',
}, */
];
const parser = StructuredOutputParser.fromZodSchema(
z.object({
agent: z.string().describe('The name of the selected agent'),
}),
);
const prompt = `
You are an AI model who is expert at finding suitable agents for user queries. The available agents are:
${availableAgents.map((agent) => `- ${agent.name}: ${agent.description}`).join('\n')}
Your task is to find the most suitable agent for the following query: {query}
{format_instructions}
`;
const chain = RunnableSequence.from([
PromptTemplate.fromTemplate(prompt),
new OpenAI({ temperature: 0 }),
parser,
]);
const pickSuitableAgent = async (query: string) => {
const res = await chain.invoke({
query,
format_instructions: parser.getFormatInstructions(),
});
return res.agent;
};
export default pickSuitableAgent;

42
src/core/searxng.ts Normal file
View File

@ -0,0 +1,42 @@
import axios from 'axios';
interface SearxngSearchOptions {
categories?: string[];
engines?: string[];
language?: string;
pageno?: number;
}
interface SearxngSearchResult {
title: string;
url: string;
img_src?: string;
thumbnail_src?: string;
content?: string;
author?: string;
}
export const searchSearxng = async (
query: string,
opts?: SearxngSearchOptions,
) => {
const url = new URL(`${process.env.SEARXNG_API_URL}/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 };
};

22
src/routes/images.ts Normal file
View File

@ -0,0 +1,22 @@
import express from 'express';
import imageSearchChain from '../agents/imageSearchAgent';
const router = express.Router();
router.post('/', async (req, res) => {
try {
const { query, chat_history } = req.body;
const images = await imageSearchChain.invoke({
query,
chat_history,
});
res.status(200).json({ images });
} catch (err) {
res.status(500).json({ message: 'An error has occurred.' });
console.log(err.message);
}
});
export default router;

8
src/routes/index.ts Normal file
View File

@ -0,0 +1,8 @@
import express from 'express';
import imagesRouter from './images';
const router = express.Router();
router.use('/images', imagesRouter);
export default router;

View File

@ -0,0 +1,14 @@
import dot from 'compute-dot';
import cosineSimilarity from 'compute-cosine-similarity';
const computeSimilarity = (x: number[], y: number[]): number => {
if (process.env.SIMILARITY_MEASURE === 'cosine') {
return cosineSimilarity(x, y);
} else if (process.env.SIMILARITY_MEASURE === 'dot') {
return dot(x, y);
}
throw new Error('Invalid similarity measure');
};
export default computeSimilarity;

View File

@ -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;

View File

@ -0,0 +1,11 @@
import { WebSocket } from 'ws';
import { handleMessage } from './messageHandler';
export const handleConnection = (ws: WebSocket) => {
ws.on(
'message',
async (message) => await handleMessage(message.toString(), ws),
);
ws.on('close', () => console.log('Connection closed'));
};

8
src/websocket/index.ts Normal file
View File

@ -0,0 +1,8 @@
import { initServer } from './websocketServer';
import http from 'http';
export const startWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
initServer(server);
};

View File

@ -0,0 +1,81 @@
import { WebSocket } from 'ws';
import pickSuitableAgent from '../core/agentPicker';
import handleWebSearch from '../agents/webSearchAgent';
import { BaseMessage, AIMessage, HumanMessage } from '@langchain/core/messages';
type Message = {
type: string;
content: string;
copilot: boolean;
focus: string;
history: Array<[string, string]>;
};
export const handleMessage = async (message: string, ws: WebSocket) => {
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') {
/* if (!parsedMessage.focus) {
const agent = await pickSuitableAgent(parsedMessage.content);
parsedMessage.focus = agent;
} */
parsedMessage.focus = 'webSearch';
switch (parsedMessage.focus) {
case 'webSearch': {
const emitter = handleWebSearch(parsedMessage.content, history);
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 }));
});
}
}
}
} catch (error) {
console.error('Failed to handle message', error);
ws.send(JSON.stringify({ type: 'error', data: 'Invalid message format' }));
}
};

View File

@ -0,0 +1,15 @@
import { WebSocketServer } from 'ws';
import { handleConnection } from './connectionManager';
import http from 'http';
export const initServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
handleConnection(ws);
});
console.log(`WebSocket server started on port ${process.env.PORT}`);
};

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "commonjs",
"target": "ESNext",
"outDir": "dist",
"sourceMap": false,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

2
ui/.env.example Normal file
View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_WS_URL=ws://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001/api

3
ui/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

34
ui/.gitignore vendored Normal file
View File

@ -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

11
ui/.prettierrc.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import("prettier").Config} */
const config = {
printWidth: 80,
trailingComma: 'all',
endOfLine: 'auto',
singleQuote: true,
tabWidth: 2,
};
module.exports = config;

BIN
ui/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

13
ui/app/globals.css Normal file
View File

@ -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;
}
}

32
ui/app/layout.tsx Normal file
View File

@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import { Montserrat } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
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 (
<html className="h-full" lang="en">
<body className={cn('h-full', montserrat.className)}>
<Sidebar>{children}</Sidebar>
</body>
</html>
);
}

17
ui/app/page.tsx Normal file
View File

@ -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 (
<div>
<ChatWindow />
</div>
);
};
export default Home;

87
ui/components/Chat.tsx Normal file
View File

@ -0,0 +1,87 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import MessageInput from './MessageInput';
import { Message } from './ChatWindow';
import MessageBox from './MessageBox';
import MessageBoxLoading from './MessageBoxLoading';
const Chat = ({
loading,
messages,
sendMessage,
messageAppeared,
rewrite,
}: {
messages: Message[];
sendMessage: (message: string) => void;
loading: boolean;
messageAppeared: boolean;
rewrite: (messageId: string) => void;
}) => {
const [dividerWidth, setDividerWidth] = useState(0);
const dividerRef = useRef<HTMLDivElement | null>(null);
const messageEnd = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const updateDividerWidth = () => {
if (dividerRef.current) {
setDividerWidth(dividerRef.current.scrollWidth);
}
};
updateDividerWidth();
window.addEventListener('resize', updateDividerWidth);
return () => {
window.removeEventListener('resize', updateDividerWidth);
};
});
useEffect(() => {
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
if (messages.length === 1) {
document.title = `${messages[0].content.substring(0, 30)} - Perplexica`;
}
}, [messages]);
return (
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-32 sm:mx-4 md:mx-8">
{messages.map((msg, i) => {
const isLast = i === messages.length - 1;
return (
<>
<MessageBox
key={i}
message={msg}
messageIndex={i}
history={messages}
loading={loading}
dividerRef={isLast ? dividerRef : undefined}
isLast={isLast}
rewrite={rewrite}
/>
{!isLast && msg.role === 'assistant' && (
<div className="h-px w-full bg-[#1C1C1C]" />
)}
</>
);
})}
{loading && !messageAppeared && <MessageBoxLoading />}
<div ref={messageEnd} className="h-0" />
{dividerWidth > 0 && (
<div
className="bottom-24 lg:bottom-10 fixed z-40"
style={{ width: dividerWidth }}
>
<MessageInput sendMessage={sendMessage} />
</div>
)}
</div>
);
};
export default Chat;

View File

@ -0,0 +1,170 @@
'use client';
import { useEffect, useState } from 'react';
import { Document } from '@langchain/core/documents';
import Navbar from './Navbar';
import Chat from './Chat';
import EmptyChat from './EmptyChat';
export type Message = {
id: string;
createdAt?: Date;
content: string;
role: 'user' | 'assistant';
sources?: Document[];
};
const useSocket = (url: string) => {
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
if (!ws) {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('[DEBUG] open');
setWs(ws);
};
}
return () => {
ws?.close();
console.log('[DEBUG] closed');
};
}, [ws, url]);
return ws;
};
const ChatWindow = () => {
const ws = useSocket(process.env.NEXT_PUBLIC_WS_URL!);
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [messageAppeared, setMessageAppeared] = useState(false);
const sendMessage = async (message: string) => {
if (loading) return;
setLoading(true);
setMessageAppeared(false);
let sources: Document[] | undefined = undefined;
let recievedMessage = '';
let added = false;
ws?.send(
JSON.stringify({
type: 'message',
content: message,
history: [...chatHistory, ['human', message]],
}),
);
setMessages((prevMessages) => [
...prevMessages,
{
content: message,
id: Math.random().toString(36).substring(7),
role: 'user',
},
]);
const messageHandler = (e: MessageEvent) => {
const data = JSON.parse(e.data);
if (data.type === 'sources') {
sources = data.data;
if (!added) {
setMessages((prevMessages) => [
...prevMessages,
{
content: '',
id: data.messageId,
role: 'assistant',
sources: sources,
},
]);
added = true;
}
setMessageAppeared(true);
}
if (data.type === 'message') {
if (!added) {
setMessages((prevMessages) => [
...prevMessages,
{
content: data.data,
id: data.messageId,
role: 'assistant',
sources: sources,
},
]);
added = true;
}
setMessages((prev) =>
prev.map((message) => {
if (message.id === data.messageId) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
recievedMessage += data.data;
setMessageAppeared(true);
}
if (data.type === 'messageEnd') {
setChatHistory((prevHistory) => [
...prevHistory,
['human', message],
['assistant', recievedMessage],
]);
ws?.removeEventListener('message', messageHandler);
setLoading(false);
}
};
ws?.addEventListener('message', messageHandler);
};
const rewrite = (messageId: string) => {
const index = messages.findIndex((msg) => msg.id === messageId);
if (index === -1) return;
const message = messages[index - 1];
setMessages((prev) => {
return [...prev.slice(0, messages.length > 2 ? index - 1 : 0)];
});
setChatHistory((prev) => {
return [...prev.slice(0, messages.length > 2 ? index - 1 : 0)];
});
sendMessage(message.content);
};
return (
<div>
{messages.length > 0 ? (
<>
<Navbar />
<Chat
loading={loading}
messages={messages}
sendMessage={sendMessage}
messageAppeared={messageAppeared}
rewrite={rewrite}
/>
</>
) : (
<EmptyChat sendMessage={sendMessage} />
)}
</div>
);
};
export default ChatWindow;

View File

@ -0,0 +1,18 @@
import EmptyChatMessageInput from './EmptyChatMessageInput';
const EmptyChat = ({
sendMessage,
}: {
sendMessage: (message: string) => void;
}) => {
return (
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-8">
<h2 className="text-white/70 text-3xl font-medium -mt-8">
Research begins here.
</h2>
<EmptyChatMessageInput sendMessage={sendMessage} />
</div>
);
};
export default EmptyChat;

View File

@ -0,0 +1,61 @@
import { ArrowRight } from 'lucide-react';
import { useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { Attach, CopilotToggle, Focus } from './MessageInputActions';
const EmptyChatMessageInput = ({
sendMessage,
}: {
sendMessage: (message: string) => void;
}) => {
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className="w-full"
>
<div className="flex flex-col bg-[#111111] px-5 pt-5 pb-2 rounded-lg w-full border border-[#1C1C1C]">
<TextareaAutosize
value={message}
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="bg-transparent placeholder:text-white/50 text-sm text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder="Ask anything..."
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center space-x-1 -mx-2">
<Focus />
<Attach />
</div>
<div className="flex flex-row items-center space-x-4 -mx-2">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
<button
disabled={message.trim().length === 0}
className="bg-[#24A0ED] text-white disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowRight className="bg-background" size={17} />
</button>
</div>
</div>
</div>
</form>
);
};
export default EmptyChatMessageInput;

9
ui/components/Layout.tsx Normal file
View File

@ -0,0 +1,9 @@
const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<main className="lg:pl-20 bg-[#0A0A0A] min-h-screen">
<div className="max-w-screen-lg lg:mx-auto mx-4">{children}</div>
</main>
);
};
export default Layout;

View File

@ -0,0 +1,29 @@
import { Check, ClipboardList } from 'lucide-react';
import { Message } from '../ChatWindow';
import { useState } from 'react';
const Copy = ({
message,
initialMessage,
}: {
message: Message;
initialMessage: string;
}) => {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
const contentToCopy = `${initialMessage}${message.sources && message.sources.length > 0 && `\n\nCitations:\n${message.sources?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`).join(`\n`)}`}`;
navigator.clipboard.writeText(contentToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}
className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white"
>
{copied ? <Check size={18} /> : <ClipboardList size={18} />}
</button>
);
};
export default Copy;

View File

@ -0,0 +1,20 @@
import { ArrowLeftRight } from 'lucide-react';
const Rewrite = ({
rewrite,
messageId,
}: {
rewrite: (messageId: string) => void;
messageId: string;
}) => {
return (
<button
onClick={() => rewrite(messageId)}
className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white"
>
<ArrowLeftRight size={18} />
</button>
);
};
export default Rewrite;

View File

@ -0,0 +1,134 @@
/* eslint-disable @next/next/no-img-element */
import React, { MutableRefObject, useEffect, useState } from 'react';
import { Message } from './ChatWindow';
import { cn } from '@/lib/utils';
import {
BookCopy,
Disc3,
FilePen,
PlusIcon,
Share,
ThumbsDown,
VideoIcon,
} from 'lucide-react';
import Markdown from 'markdown-to-jsx';
import Copy from './MessageActions/Copy';
import Rewrite from './MessageActions/Rewrite';
import MessageSources from './MessageSources';
import SearchImages from './SearchImages';
const MessageBox = ({
message,
messageIndex,
history,
loading,
dividerRef,
isLast,
rewrite,
}: {
message: Message;
messageIndex: number;
history: Message[];
loading: boolean;
dividerRef?: MutableRefObject<HTMLDivElement | null>;
isLast: boolean;
rewrite: (messageId: string) => void;
}) => {
const [parsedMessage, setParsedMessage] = useState(message.content);
useEffect(() => {
if (
message.role === 'assistant' &&
message?.sources &&
message.sources.length > 0
) {
const regex = /\[(\d+)\]/g;
return setParsedMessage(
message.content.replace(
regex,
(_, number) =>
`<a href="${message.sources?.[number - 1]?.metadata?.url}" target="_blank" className="bg-[#1C1C1C] px-1 rounded ml-1 no-underline text-xs text-white/70 relative">${number}</a>`,
),
);
}
setParsedMessage(message.content);
}, [message.content, message.sources, message.role]);
return (
<div>
{message.role === 'user' && (
<div className={cn('w-full', messageIndex === 0 ? 'pt-16' : 'pt-8')}>
<h2 className="text-white font-medium text-3xl lg:w-9/12">
{message.content}
</h2>
</div>
)}
{message.role === 'assistant' && (
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
<div
ref={dividerRef}
className="flex flex-col space-y-6 w-full lg:w-9/12"
>
{message.sources && message.sources.length > 0 && (
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center space-x-2">
<BookCopy className="text-white" size={20} />
<h3 className="text-white font-medium text-xl">Sources</h3>
</div>
<MessageSources sources={message.sources} />
</div>
)}
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center space-x-2">
<Disc3
className={cn(
'text-white',
isLast && loading ? 'animate-spin' : 'animate-none',
)}
size={20}
/>
<h3 className="text-white font-medium text-xl">Answer</h3>
</div>
<Markdown className="prose max-w-none break-words prose-invert prose-p:leading-relaxed prose-pre:p-0 text-white text-sm md:text-base font-medium">
{parsedMessage}
</Markdown>
{!loading && (
<div className="flex flex-row items-center justify-between w-full text-white py-4 -mx-2">
<div className="flex flex-row items-center space-x-1">
<button className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
<Share size={18} />
</button>
<Rewrite rewrite={rewrite} messageId={message.id} />
</div>
<div className="flex flex-row items-center space-x-1">
<Copy initialMessage={message.content} message={message} />
<button className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
<FilePen size={18} />
</button>
<button className="p-2 text-white/70 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
<ThumbsDown size={18} />
</button>
</div>
</div>
)}
</div>
</div>
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
<SearchImages query={history[messageIndex - 1].content} />
<div className="border border-dashed border-[#1C1C1C] px-4 py-2 flex flex-row items-center justify-between rounded-lg text-white text-sm w-full">
<div className="flex flex-row items-center space-x-2">
<VideoIcon size={17} />
<p>Search videos</p>
</div>
<PlusIcon className="text-[#24A0ED]" size={17} />
</div>
</div>
</div>
)}
</div>
);
};
export default MessageBox;

View File

@ -0,0 +1,11 @@
const MessageBoxLoading = () => {
return (
<div className="flex flex-col space-y-2 w-full lg:w-9/12 bg-[#111111] animate-pulse rounded-lg p-3">
<div className="h-2 rounded-full w-full bg-[#1c1c1c]" />
<div className="h-2 rounded-full w-9/12 bg-[#1c1c1c]" />
<div className="h-2 rounded-full w-10/12 bg-[#1c1c1c]" />
</div>
);
};
export default MessageBoxLoading;

View File

@ -0,0 +1,89 @@
import { cn } from '@/lib/utils';
import { ArrowUp } from 'lucide-react';
import { useEffect, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { Attach, CopilotToggle } from './MessageInputActions';
const MessageInput = ({
sendMessage,
}: {
sendMessage: (message: string) => void;
}) => {
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
const [textareaRows, setTextareaRows] = useState(1);
const [mode, setMode] = useState<'multi' | 'single'>('single');
useEffect(() => {
if (textareaRows >= 2 && message && mode === 'single') {
setMode('multi');
} else if (!message && mode === 'multi') {
setMode('single');
}
}, [textareaRows, mode, message]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className={cn(
'bg-[#111111] p-4 flex items-center overflow-hidden border border-[#1C1C1C]',
mode === 'multi' ? 'flex-col rounded-lg' : 'flex-row rounded-full',
)}
>
{mode === 'single' && <Attach />}
<TextareaAutosize
value={message}
onChange={(e) => setMessage(e.target.value)}
onHeightChange={(height, props) => {
setTextareaRows(Math.ceil(height / props.rowHeight));
}}
className="transition bg-transparent placeholder:text-white/50 placeholder:text-sm text-sm text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink"
placeholder="Ask a follow-up"
/>
{mode === 'single' && (
<div className="flex flex-row items-center space-x-4">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
<button
disabled={message.trim().length === 0}
className="bg-[#24A0ED] text-white disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button>
</div>
)}
{mode === 'multi' && (
<div className="flex flex-row items-center justify-between w-full pt-2">
<Attach />
<div className="flex flex-row items-center space-x-4">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
<button
disabled={message.trim().length === 0}
className="bg-[#24A0ED] text-white disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button>
</div>
</div>
)}
</form>
);
};
export default MessageInput;

View File

@ -0,0 +1,58 @@
import { CopyPlus, ScanEye } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Switch } from '@headlessui/react';
export const Attach = () => {
return (
<button className="p-2 text-white/50 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
<CopyPlus />
</button>
);
};
export const Focus = () => {
return (
<button className="p-2 text-white/50 rounded-xl hover:bg-[#1c1c1c] transition duration-200 hover:text-white">
<ScanEye />
</button>
);
};
export const CopilotToggle = ({
copilotEnabled,
setCopilotEnabled,
}: {
copilotEnabled: boolean;
setCopilotEnabled: (enabled: boolean) => void;
}) => {
return (
<div className="group flex flex-row items-center space-x-1 active:scale-95 duration-200 transition cursor-pointer">
<Switch
checked={copilotEnabled}
onChange={setCopilotEnabled}
className="bg-[#111111] border border-[#1C1C1C] relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full"
>
<span className="sr-only">Copilot</span>
<span
className={cn(
copilotEnabled
? 'translate-x-6 bg-[#24A0ED]'
: 'translate-x-1 bg-white/50',
'inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200',
)}
/>
</Switch>
<p
onClick={() => setCopilotEnabled(!copilotEnabled)}
className={cn(
'text-xs font-medium transition-colors duration-150 ease-in-out',
copilotEnabled
? 'text-[#24A0ED]'
: 'text-white/50 group-hover:text-white',
)}
>
Copilot
</p>
</div>
);
};

View File

@ -0,0 +1,136 @@
/* eslint-disable @next/next/no-img-element */
import { cn } from '@/lib/utils';
import { Dialog, Transition } from '@headlessui/react';
import { Document } from '@langchain/core/documents';
import Link from 'next/link';
import { Fragment, useState } from 'react';
const MessageSources = ({ sources }: { sources: Document[] }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
function closeModal() {
setIsDialogOpen(false);
document.body.classList.remove('overflow-hidden-scrollable');
}
function openModal() {
setIsDialogOpen(true);
document.body.classList.add('overflow-hidden-scrollable');
}
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{sources.slice(0, 3).map((source, i) => (
<a
className="bg-[#111111] hover:bg-[#1c1c1c] transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
key={i}
href={source.metadata.url}
target="_blank"
>
<p className="text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{source.metadata.title}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
/>
<p className="text-xs text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
</p>
</div>
<div className="flex flex-row items-center space-x-1 text-white/50 text-xs">
<div className="bg-white/50 h-[4px] w-[4px] rounded-full" />
<span>{i + 1}</span>
</div>
</div>
</a>
))}
{sources.length > 3 && (
<button
onClick={openModal}
className="bg-[#111111] hover:bg-[#1c1c1c] transition duration-200 rounded-lg px-4 py-2 flex flex-col justify-between space-y-2"
>
<div className="flex flex-row items-center space-x-1">
{sources.slice(3, 6).map((source, i) => (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
key={i}
/>
))}
</div>
<p className="text-xs text-white/50">
View {sources.length - 3} more
</p>
</button>
)}
<Transition appear show={isDialogOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-200"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform rounded-2xl bg-[#111111] border border-[#1c1c1c] p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title className="text-lg font-medium leading-6 text-white">
Sources
</Dialog.Title>
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
{sources.map((source, i) => (
<a
className="bg-[#111111] hover:bg-[#1c1c1c] border border-[#1c1c1c] transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
key={i}
href={source.metadata.url}
target="_blank"
>
<p className="text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{source.metadata.title}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
/>
<p className="text-xs text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
{source.metadata.url.replace(
/.+\/\/|www.|\..+/g,
'',
)}
</p>
</div>
<div className="flex flex-row items-center space-x-1 text-white/50 text-xs">
<div className="bg-white/50 h-[4px] w-[4px] rounded-full" />
<span>{i + 1}</span>
</div>
</div>
</a>
))}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};
export default MessageSources;

29
ui/components/Navbar.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Clock, Edit, Share, Trash } from 'lucide-react';
const Navbar = () => {
return (
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-32 lg:pr-4 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm text-white/70 border-b bg-[#0A0A0A] border-[#1C1C1C]">
<Edit
size={17}
className="active:scale-95 transition duration-100 cursor-pointer lg:hidden"
/>
<div className="hidden lg:flex flex-row items-center space-x-2">
<Clock size={17} />
<p className="text-xs">15 minutes ago</p>
</div>
<p className="hidden lg:flex">Blog on AI</p>
<div className="flex flex-row items-center space-x-4">
<Share
size={17}
className="active:scale-95 transition duration-100 cursor-pointer"
/>
<Trash
size={17}
className="text-red-400 active:scale-95 transition duration-100 cursor-pointer"
/>
</div>
</div>
);
};
export default Navbar;

View File

@ -0,0 +1,135 @@
/* eslint-disable @next/next/no-img-element */
import { ImagesIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react';
import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
type Image = {
url: string;
img_src: string;
title: string;
};
const SearchImages = ({ query }: { query: string }) => {
const [images, setImages] = useState<Image[] | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [slides, setSlides] = useState<any[]>([]);
return (
<>
{!loading && images === null && (
<button
onClick={async () => {
setLoading(true);
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/images`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
chat_history: [],
}),
},
);
const data = await res.json();
const images = data.images;
setImages(images);
setSlides(
images.map((image: Image) => {
return {
src: image.img_src,
};
}),
);
setLoading(false);
}}
className="border border-dashed border-[#1C1C1C] hover:bg-[#1c1c1c] active:scale-95 duration-200 transition px-4 py-2 flex flex-row items-center justify-between rounded-lg text-white text-sm w-full"
>
<div className="flex flex-row items-center space-x-2">
<ImagesIcon size={17} />
<p>Search images</p>
</div>
<PlusIcon className="text-[#24A0ED]" size={17} />
</button>
)}
{loading && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-[#1C1C1C] h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
)}
{images !== null && images.length > 0 && (
<>
<div className="grid grid-cols-2 gap-2">
{images.length > 4
? images.slice(0, 3).map((image, i) => (
<img
onClick={() => {
setOpen(true);
setSlides([
slides[i],
...slides.slice(0, i),
...slides.slice(i + 1),
]);
}}
key={i}
src={image.img_src}
alt={image.title}
className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 cursor-pointer"
/>
))
: images.map((image, i) => (
<img
onClick={() => {
setOpen(true);
setSlides([
slides[i],
...slides.slice(0, i),
...slides.slice(i + 1),
]);
}}
key={i}
src={image.img_src}
alt={image.title}
className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 cursor-pointer"
/>
))}
{images.length > 4 && (
<button
onClick={() => setOpen(true)}
className="bg-[#111111] hover:bg-[#1c1c1c] transition duration-200 active:scale-95 h-auto w-full rounded-lg flex flex-col justify-between text-white p-2"
>
<div className="flex flex-row items-center space-x-1">
{images.slice(3, 6).map((image, i) => (
<img
key={i}
src={image.img_src}
alt={image.title}
className="h-6 w-12 rounded-md lg:h-3 lg:w-6 lg:rounded-sm aspect-video object-cover"
/>
))}
</div>
<p className="text-white/70 text-xs">
View {images.slice(0, 2).length} more
</p>
</button>
)}
</div>
<Lightbox open={open} close={() => setOpen(false)} slides={slides} />
</>
)}
</>
);
};
export default SearchImages;

96
ui/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,96 @@
'use client';
import { cn } from '@/lib/utils';
import { BookOpenText, Home, Search, SquarePen } from 'lucide-react';
import { SiGithub } from '@icons-pack/react-simple-icons';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React from 'react';
import Layout from './Layout';
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const navLinks = [
{
icon: Home,
href: '/',
active: segments.length === 0,
label: 'Home',
},
{
icon: Search,
href: '/discover',
active: segments.includes('discover'),
label: 'Discover',
},
{
icon: BookOpenText,
href: '/library',
active: segments.includes('library'),
label: 'Library',
},
];
return (
<div>
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-20 lg:flex-col">
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-[#111111] px-2 py-8">
<a href="/">
<SquarePen className="text-white cursor-pointer" />
</a>
<div className="flex flex-col items-center gap-y-3 w-full">
{navLinks.map((link, i) => (
<Link
key={i}
href={link.href}
className={cn(
'relative flex flex-row items-center justify-center cursor-pointer hover:bg-white/10 hover:text-white duration-150 transition w-full py-2 rounded-lg',
link.active ? 'text-white' : 'text-white/70',
)}
>
<link.icon />
{link.active && (
<div className="absolute right-0 -mr-2 h-full w-1 rounded-l-lg bg-white" />
)}
</Link>
))}
</div>
<Link
href="https://github.com/ItzCrazyKns/Perplexica"
className="flex flex-col items-center text-center justify-center"
>
<SiGithub
className="text-white"
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
/>
</Link>
</div>
</div>
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-[#111111] px-4 py-4 shadow-sm lg:hidden">
{navLinks.map((link, i) => (
<Link
href={link.href}
key={i}
className={cn(
'relative flex flex-col items-center space-y-1 text-center w-full',
link.active ? 'text-white' : 'text-white/70',
)}
>
{link.active && (
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-white" />
)}
<link.icon />
<p className="text-xs">{link.label}</p>
</Link>
))}
</div>
<Layout>{children}</Layout>
</div>
);
};
export default Sidebar;

4
ui/lib/utils.ts Normal file
View File

@ -0,0 +1,4 @@
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes));

12
ui/next.config.mjs Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: 's2.googleusercontent.com',
},
],
},
};
export default nextConfig;

42
ui/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "perplexica-frontend",
"version": "1.0.0",
"license": "MIT",
"author": "ItzCrazyKns",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format:write": "prettier . --write"
},
"dependencies": {
"@headlessui/react": "^1.7.18",
"@icons-pack/react-simple-icons": "^9.4.0",
"@langchain/openai": "^0.0.25",
"@tailwindcss/typography": "^0.5.12",
"clsx": "^2.1.0",
"langchain": "^0.1.30",
"lucide-react": "^0.363.0",
"markdown-to-jsx": "^7.4.5",
"next": "14.1.4",
"react": "^18",
"react-dom": "^18",
"react-textarea-autosize": "^8.5.3",
"tailwind-merge": "^2.2.2",
"yet-another-react-lightbox": "^3.17.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.4",
"postcss": "^8",
"prettier": "^3.2.5",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

6
ui/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
ui/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
ui/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

14
ui/tailwind.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography')],
};
export default config;

26
ui/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}