Compare commits

...

64 Commits

Author SHA1 Message Date
2c5adad2fc feat(groq): switch to @langchain/groq for better handling 2025-08-05 19:16:21 +05:30
7d8439c615 feat(chat-window): handle conditional message addition 2025-08-05 19:14:20 +05:30
ebfb103911 feat(message-box): add syntax highlighted code 2025-08-05 19:11:09 +05:30
b1b87d3b52 feat(meta-search-agent): add summarizer tool, implement search mode 2025-08-05 19:10:43 +05:30
8811398040 feat(meta-search-agent): implement basic function calling with langgraph 2025-08-04 21:47:55 +05:30
653ce3cb19 feat(package): add langgraph, syntax highlighter 2025-08-04 21:47:05 +05:30
eadbedb713 feat(groq): switch to @langchain/groq for better handling 2025-08-02 17:14:34 +05:30
37cd6d3ab5 feat(discover): prevent duplicate articles 2025-08-01 20:41:07 +05:30
88be3a045b feat(discover): randomly sort results 2025-07-29 13:18:36 +05:30
45b51ab156 feat(discover-api): handle topics 2025-07-29 13:17:07 +05:30
3bee01cfa7 feat(discover): add topic selection 2025-07-28 20:39:50 +05:30
567c6a8758 Merge branch 'pr/831' 2025-07-24 22:36:19 +05:30
81a91da743 feat(gemini): use model instead of modelName 2025-07-23 12:22:26 +05:30
70a61ee1eb feat(think-box): handle thinkingEnded 2025-07-23 12:21:07 +05:30
9d89a4413b feat(format-history): remove extra : 2025-07-23 12:20:49 +05:30
6ea17d54c6 feat(chat-window): fix wrong history while rewriting 2025-07-22 21:21:49 +05:30
11a828b073 feat(message-box): close think box after thinking process ends 2025-07-22 21:21:09 +05:30
37022fb11e feat(format-history): update role determination 2025-07-22 21:20:16 +05:30
dd50d4927b Merge branch 'master' of https://github.com/ItzCrazyKns/Perplexica 2025-07-22 12:27:11 +05:30
fdaf3af3af Merge pull request #832 from tuxthepenguin84/patch-2
Fix name of provider in embeddings models error message
2025-07-21 20:56:24 +05:30
3f2a8f862c Fix name of provider in embeddings models error message 2025-07-20 09:20:39 -07:00
58c7be6e95 Update Gemini 2.5 Models 2025-07-20 09:17:20 -07:00
829b4e7134 feat(custom-openai): use apiKey instead of openAIApiKey 2025-07-19 21:37:34 +05:30
77870b39cc feat(ollama): use @langchain/ollama library 2025-07-19 21:37:34 +05:30
8e0ae9b867 feat(providers): switch to apiKey key 2025-07-19 21:37:34 +05:30
543f1df5ce feat(modules): update langchain packages 2025-07-19 21:37:34 +05:30
341aae4587 Merge branch 'pr/830' 2025-07-19 21:36:23 +05:30
7f62907385 feat(weather): update measurement units to Imperial/Metric 2025-07-19 08:53:11 -06:00
7c4aa683a2 feat(chains): remove unused imports 2025-07-19 17:57:32 +05:30
b48b0eeb0e feat(imageSearch): use XML parsing, implement few shot prompting 2025-07-19 17:52:30 +05:30
cddc793915 feat(videoSearch): use XML parsing, use few shot prompting 2025-07-19 17:52:14 +05:30
94e6db10bb feat(weather): add other measurement units, closes #821 #790 2025-07-18 21:09:32 +05:30
26e1d5fec3 feat(routes): lint & beautify 2025-07-17 22:23:11 +05:30
66be87b688 Merge branch 'pr/827' 2025-07-17 22:22:50 +05:30
f7b4e32218 fix(discover): provide language when fetching
some engines provide empty response when no language is provided.

fix #618
2025-07-17 02:14:49 +08:00
57407112fb feat(package): bump version 2025-07-16 10:39:50 +05:30
b280cc2e01 Merge pull request #787 from chriswritescode-dev/IOS
Fix: IOS Input Zoom / Support PWA Home Screen App, closes #458
2025-07-15 22:10:01 +05:30
e6ebf892c5 feat(styles): update globals.css 2025-07-15 21:47:20 +05:30
b754641058 feat(gitignore): add certificates 2025-07-15 21:45:44 +05:30
722f4f760e feat(manifest): update icons & screenshots 2025-07-15 21:45:37 +05:30
01e04a209f feat(public): add screenshots & update icons 2025-07-15 21:45:24 +05:30
0299fd1ea0 Merge pull request #817 from kittrydge/patch-1
Update Linux ollama instructions in README.md
2025-07-15 20:23:02 +05:30
cf8dec53ca feat(chat-window): select provider if model's present, closes #803 2025-07-07 16:09:36 +05:30
d5c012d748 Revert "Update ChatWindow.tsx"
This reverts commit 2ccbd9a44c.
2025-07-07 15:52:39 +05:30
2ccbd9a44c Update ChatWindow.tsx 2025-07-05 22:00:06 +05:30
ccd89d48d9 Update Linux ollama instructions in README.md
When setting the OLLAMA_HOST environment variable, the port number must be specified ( see https://github.com/ollama/ollama/blob/main/docs/faq.md#setting-environment-variables-on-linux )

Also, 'systemctl daemon-reload' needs to be called after changing a systemd unit file, and before the relevant systemd service is reloaded.
2025-07-01 18:00:26 -06:00
87d788ddef Update README.md 2025-06-30 19:55:23 +05:30
809b625a34 feat(widgets): fix size on smaller screens, closes #791 2025-06-30 15:42:41 +05:30
95c753a549 Merge branch 'pr/815' 2025-06-30 15:38:31 +05:30
0bb8b7ec5c feat(weather-widget): enable geolocation for weather data
Replaces the previous commented-out geolocation logic with an implementation that uses the browser's geolocation API and reverse geocoding to determine the user's city. Falls back to approximate location if permission is denied or unavailable.
2025-06-28 13:49:17 +05:30
c6d084f5dc feat: add AIML API provider
Introduces support for the AI/ML API provider, including configuration options, chat and embedding model loading, and UI integration. Updates documentation and sample config to reflect the new provider.
2025-06-27 13:43:54 +02:00
0024ce36c8 Merge pull request #784 from Davixk/fix/docs-typo
docs: correct typo in npm start command
2025-06-21 20:27:34 +05:30
c44e746807 Merge pull request #785 from koyasi777/patch-1
feat(gemini): add Gemini 2.5 Flash & Pro preview models (May 2025)
2025-06-21 20:26:37 +05:30
b1826066f4 Merge pull request #801 from glitchySid/patch-1
Update README.md
2025-06-21 20:25:41 +05:30
b0b8acc45b Merge pull request #781 from alckasoc/master
feat(models): Update Gemini 2.5 pro key
2025-06-21 20:25:06 +05:30
e2b9ffc072 Update README.md
Mentioned that Gemini api key can be used in perplexica.
2025-06-11 22:52:13 +05:30
68c43ea372 Fix: IOS Input Zoom
config for theme consistency and iOS standalone mode
- Modified manifest.ts to ensure proper metadata

- Added display: standalone for iOS PWA behavior
2025-06-02 21:52:41 -04:00
3b46baca4f docs(readme): fix typo in npm start command 2025-06-02 05:52:31 +02:00
772e461c08 feat(gemini): add Gemini 2.5 Flash & Pro preview models (May 2025) 2025-06-02 00:30:18 +09:00
5c6018a0f9 docs: correct typo in npm start command 2025-06-01 06:35:16 +02:00
0b7989c3d3 feat(empty-chat): remove unused imports 2025-05-30 09:55:06 +05:30
8cfcc3e39c feat(chat): update margins and spacing 2025-05-30 09:52:36 +05:30
9eba4b7373 Merge branch 'master' of https://github.com/alckasoc/Perplexica 2025-05-29 18:27:00 -07:00
91306dc0c7 update gemini 2.5 pro key 2025-05-29 18:26:36 -07:00
47 changed files with 1720 additions and 888 deletions

0
.assets/manifest.json Normal file
View File

2
.gitignore vendored
View File

@ -37,3 +37,5 @@ Thumbs.db
# Db
db.sqlite
/searxng
certificates

View File

@ -16,7 +16,7 @@
<hr/>
[![Discord](https://dcbadge.vercel.app/api/server/26aArMy8tT?style=flat&compact=true)](https://discord.gg/26aArMy8tT)
[![Discord](https://dcbadge.limes.pink/api/server/26aArMy8tT?style=flat)](https://discord.gg/26aArMy8tT)
![preview](.assets/perplexica-screenshot.png?)
@ -90,6 +90,9 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
- `OLLAMA`: Your Ollama API URL. You should enter it as `http://host.docker.internal:PORT_NUMBER`. If you installed Ollama on port 11434, use `http://host.docker.internal:11434`. For other ports, adjust accordingly. **You need to fill this if you wish to use Ollama's models instead of OpenAI's**.
- `GROQ`: Your Groq API key. **You only need to fill this if you wish to use Groq's hosted models**.
- `ANTHROPIC`: Your Anthropic API key. **You only need to fill this if you wish to use Anthropic models**.
- `Gemini`: Your Gemini API key. **You only need to fill this if you wish to use Google's models**.
- `DEEPSEEK`: Your Deepseek API key. **Only needed if you want Deepseek models.**
- `AIMLAPI`: Your AI/ML API key. **Only needed if you want to use AI/ML API models and embeddings.**
**Note**: You can change these after starting Perplexica from the settings dialog.
@ -111,7 +114,7 @@ There are mainly 2 ways of installing Perplexica - With Docker, Without Docker.
2. Clone the repository and rename the `sample.config.toml` file to `config.toml` in the root directory. Ensure you complete all required fields in this file.
3. After populating the configuration run `npm i`.
4. Install the dependencies and then execute `npm run build`.
5. Finally, start the app by running `npm rum start`
5. Finally, start the app by running `npm run start`
**Note**: Using Docker is recommended as it simplifies the setup process, especially for managing environment variables and dependencies.
@ -132,7 +135,7 @@ If you're encountering an Ollama connection error, it is likely due to the backe
3. **Linux Users - Expose Ollama to Network:**
- Inside `/etc/systemd/system/ollama.service`, you need to add `Environment="OLLAMA_HOST=0.0.0.0"`. Then restart Ollama by `systemctl restart ollama`. For more information see [Ollama docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#setting-environment-variables-on-linux)
- Inside `/etc/systemd/system/ollama.service`, you need to add `Environment="OLLAMA_HOST=0.0.0.0:11434"`. (Change the port number if you are using a different one.) Then reload the systemd manager configuration with `systemctl daemon-reload`, and restart Ollama by `systemctl restart ollama`. For more information see [Ollama docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#setting-environment-variables-on-linux)
- Ensure that the port (default is 11434) is not blocked by your firewall.

View File

@ -41,6 +41,6 @@ To update Perplexica to the latest version, follow these steps:
3. Check for changes in the configuration files. If the `sample.config.toml` file contains new fields, delete your existing `config.toml` file, rename `sample.config.toml` to `config.toml`, and update the configuration accordingly.
4. After populating the configuration run `npm i`.
5. Install the dependencies and then execute `npm run build`.
6. Finally, start the app by running `npm rum start`
6. Finally, start the app by running `npm run start`
---

View File

@ -1,6 +1,6 @@
{
"name": "perplexica-frontend",
"version": "1.11.0-rc1",
"version": "1.11.0-rc2",
"license": "MIT",
"author": "ItzCrazyKns",
"scripts": {
@ -15,11 +15,14 @@
"@headlessui/react": "^2.2.0",
"@iarna/toml": "^2.2.5",
"@icons-pack/react-simple-icons": "^12.3.0",
"@langchain/anthropic": "^0.3.15",
"@langchain/community": "^0.3.36",
"@langchain/core": "^0.3.42",
"@langchain/google-genai": "^0.1.12",
"@langchain/openai": "^0.0.25",
"@langchain/anthropic": "^0.3.24",
"@langchain/community": "^0.3.49",
"@langchain/core": "^0.3.66",
"@langchain/google-genai": "^0.2.15",
"@langchain/groq": "^0.2.3",
"@langchain/langgraph": "^0.4.0",
"@langchain/ollama": "^0.2.3",
"@langchain/openai": "^0.6.2",
"@langchain/textsplitters": "^0.1.0",
"@tailwindcss/typography": "^0.5.12",
"@xenova/transformers": "^2.17.2",
@ -31,7 +34,7 @@
"drizzle-orm": "^0.40.1",
"html-to-text": "^9.0.5",
"jspdf": "^3.0.1",
"langchain": "^0.1.30",
"langchain": "^0.3.30",
"lucide-react": "^0.363.0",
"mammoth": "^1.9.1",
"markdown-to-jsx": "^7.7.2",
@ -40,6 +43,7 @@
"pdf-parse": "^1.1.1",
"react": "^18",
"react-dom": "^18",
"react-syntax-highlighter": "^15.6.1",
"react-text-to-speech": "^0.14.5",
"react-textarea-autosize": "^8.5.3",
"sonner": "^1.4.41",
@ -56,6 +60,7 @@
"@types/pdf-parse": "^1.1.4",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.0.1",
"drizzle-kit": "^0.30.5",
"eslint": "^8",
@ -63,6 +68,6 @@
"postcss": "^8",
"prettier": "^3.2.5",
"tailwindcss": "^3.3.0",
"typescript": "^5"
"typescript": "5.8.3"
}
}

BIN
public/icon-100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

BIN
public/icon-50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/screenshots/p1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
public/screenshots/p2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@ -25,6 +25,9 @@ API_URL = "" # Ollama API URL - http://host.docker.internal:11434
[MODELS.DEEPSEEK]
API_KEY = ""
[MODELS.AIMLAPI]
API_KEY = "" # Required to use AI/ML API chat and embedding models
[MODELS.LM_STUDIO]
API_URL = "" # LM Studio API URL - http://host.docker.internal:1234

View File

@ -223,7 +223,7 @@ export const POST = async (req: Request) => {
if (body.chatModel?.provider === 'custom_openai') {
llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(),
apiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(),
temperature: 0.7,
configuration: {

View File

@ -8,6 +8,7 @@ import {
getOllamaApiEndpoint,
getOpenaiApiKey,
getDeepseekApiKey,
getAimlApiKey,
getLMStudioApiEndpoint,
updateConfig,
} from '@/lib/config';
@ -57,6 +58,7 @@ export const GET = async (req: Request) => {
config['groqApiKey'] = getGroqApiKey();
config['geminiApiKey'] = getGeminiApiKey();
config['deepseekApiKey'] = getDeepseekApiKey();
config['aimlApiKey'] = getAimlApiKey();
config['customOpenaiApiUrl'] = getCustomOpenaiApiUrl();
config['customOpenaiApiKey'] = getCustomOpenaiApiKey();
config['customOpenaiModelName'] = getCustomOpenaiModelName();
@ -95,6 +97,9 @@ export const POST = async (req: Request) => {
DEEPSEEK: {
API_KEY: config.deepseekApiKey,
},
AIMLAPI: {
API_KEY: config.aimlApiKey,
},
LM_STUDIO: {
API_URL: config.lmStudioApiUrl,
},

View File

@ -1,55 +1,77 @@
import { searchSearxng } from '@/lib/searxng';
const articleWebsites = [
'yahoo.com',
'www.exchangewire.com',
'businessinsider.com',
/* 'wired.com',
'mashable.com',
'theverge.com',
'gizmodo.com',
'cnet.com',
'venturebeat.com', */
];
const websitesForTopic = {
tech: {
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
links: ['techcrunch.com', 'wired.com', 'theverge.com'],
},
finance: {
query: ['finance news', 'economy', 'stock market', 'investing'],
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com'],
},
art: {
query: ['art news', 'culture', 'modern art', 'cultural events'],
links: ['artnews.com', 'hyperallergic.com', 'theartnewspaper.com'],
},
sports: {
query: ['sports news', 'latest sports', 'cricket football tennis'],
links: ['espn.com', 'bbc.com/sport', 'skysports.com'],
},
entertainment: {
query: ['entertainment news', 'movies', 'TV shows', 'celebrities'],
links: ['hollywoodreporter.com', 'variety.com', 'deadline.com'],
},
};
const topics = ['AI', 'tech']; /* TODO: Add UI to customize this */
type Topic = keyof typeof websitesForTopic;
export const GET = async (req: Request) => {
try {
const params = new URL(req.url).searchParams;
const mode: 'normal' | 'preview' =
(params.get('mode') as 'normal' | 'preview') || 'normal';
const topic: Topic = (params.get('topic') as Topic) || 'tech';
const selectedTopic = websitesForTopic[topic];
let data = [];
if (mode === 'normal') {
const seenUrls = new Set();
data = (
await Promise.all([
...new Array(articleWebsites.length * topics.length)
.fill(0)
.map(async (_, i) => {
await Promise.all(
selectedTopic.links.flatMap((link) =>
selectedTopic.query.map(async (query) => {
return (
await searchSearxng(
`site:${articleWebsites[i % articleWebsites.length]} ${
topics[i % topics.length]
}`,
{
engines: ['bing news'],
pageno: 1,
},
)
await searchSearxng(`site:${link} ${query}`, {
engines: ['bing news'],
pageno: 1,
language: 'en',
})
).results;
}),
])
),
)
)
.map((result) => result)
.flat()
.filter((item) => {
const url = item.url?.toLowerCase().trim();
if (seenUrls.has(url)) return false;
seenUrls.add(url);
return true;
})
.sort(() => Math.random() - 0.5);
} else {
data = (
await searchSearxng(
`site:${articleWebsites[Math.floor(Math.random() * articleWebsites.length)]} ${topics[Math.floor(Math.random() * topics.length)]}`,
{ engines: ['bing news'], pageno: 1 },
`site:${selectedTopic.links[Math.floor(Math.random() * selectedTopic.links.length)]} ${selectedTopic.query[Math.floor(Math.random() * selectedTopic.query.length)]}`,
{
engines: ['bing news'],
pageno: 1,
language: 'en',
},
)
).results;
}

View File

@ -49,7 +49,7 @@ export const POST = async (req: Request) => {
if (body.chatModel?.provider === 'custom_openai') {
llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(),
apiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(),
temperature: 0.7,
configuration: {

View File

@ -81,8 +81,7 @@ export const POST = async (req: Request) => {
if (body.chatModel?.provider === 'custom_openai') {
llm = new ChatOpenAI({
modelName: body.chatModel?.name || getCustomOpenaiModelName(),
openAIApiKey:
body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(),
apiKey: body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(),
temperature: 0.7,
configuration: {
baseURL:

View File

@ -48,7 +48,7 @@ export const POST = async (req: Request) => {
if (body.chatModel?.provider === 'custom_openai') {
llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(),
apiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(),
temperature: 0.7,
configuration: {

View File

@ -49,7 +49,7 @@ export const POST = async (req: Request) => {
if (body.chatModel?.provider === 'custom_openai') {
llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(),
apiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(),
temperature: 0.7,
configuration: {

View File

@ -1,6 +1,10 @@
export const POST = async (req: Request) => {
try {
const body: { lat: number; lng: number } = await req.json();
const body: {
lat: number;
lng: number;
measureUnit: 'Imperial' | 'Metric';
} = await req.json();
if (!body.lat || !body.lng) {
return Response.json(
@ -12,7 +16,9 @@ export const POST = async (req: Request) => {
}
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
);
const data = await res.json();
@ -33,12 +39,16 @@ export const POST = async (req: Request) => {
humidity: number;
windSpeed: number;
icon: string;
temperatureUnit: 'C' | 'F';
windSpeedUnit: 'm/s' | 'mph';
} = {
temperature: data.current.temperature_2m,
condition: '',
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
icon: '',
temperatureUnit: body.measureUnit === 'Metric' ? 'C' : 'F',
windSpeedUnit: body.measureUnit === 'Metric' ? 'm/s' : 'mph',
};
const code = data.current.weather_code;

View File

@ -4,6 +4,7 @@ import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface Discover {
title: string;
@ -12,60 +13,66 @@ interface Discover {
thumbnail: string;
}
const topics: { key: string; display: string }[] = [
{
display: 'Tech & Science',
key: 'tech',
},
{
display: 'Finance',
key: 'finance',
},
{
display: 'Art & Culture',
key: 'art',
},
{
display: 'Sports',
key: 'sports',
},
{
display: 'Entertainment',
key: 'entertainment',
},
];
const Page = () => {
const [discover, setDiscover] = useState<Discover[] | null>(null);
const [loading, setLoading] = useState(true);
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
const fetchArticles = async (topic: string) => {
setLoading(true);
try {
const res = await fetch(`/api/discover?topic=${topic}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message);
}
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
setDiscover(data.blogs);
} catch (err: any) {
console.error('Error fetching data:', err.message);
toast.error('Error fetching data');
} finally {
setLoading(false);
}
};
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch(`/api/discover`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
fetchArticles(activeTopic);
}, [activeTopic]);
const data = await res.json();
if (!res.ok) {
throw new Error(data.message);
}
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
setDiscover(data.blogs);
} catch (err: any) {
console.error('Error fetching data:', err.message);
toast.error('Error fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return loading ? (
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
) : (
return (
<>
<div>
<div className="flex flex-col pt-4">
@ -76,35 +83,73 @@ const Page = () => {
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
</div>
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 lg:pb-8 w-full justify-items-center lg:justify-items-start">
{discover &&
discover?.map((item, i) => (
<Link
href={`/?q=Summary: ${item.url}`}
key={i}
className="max-w-sm rounded-lg overflow-hidden bg-light-secondary dark:bg-dark-secondary hover:-translate-y-[1px] transition duration-200"
target="_blank"
>
<img
className="object-cover w-full aspect-video"
src={
new URL(item.thumbnail).origin +
new URL(item.thumbnail).pathname +
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
}
alt={item.title}
/>
<div className="px-6 py-4">
<div className="font-bold text-lg mb-2">
{item.title.slice(0, 100)}...
</div>
<p className="text-black-70 dark:text-white/70 text-sm">
{item.content.slice(0, 100)}...
</p>
</div>
</Link>
))}
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
{topics.map((t, i) => (
<div
key={i}
className={cn(
'border-[0.1px] rounded-full text-sm px-3 py-1 text-nowrap transition duration-200 cursor-pointer',
activeTopic === t.key
? 'text-cyan-300 bg-cyan-300/30 border-cyan-300/60'
: 'border-white/30 text-white/70 hover:text-white hover:border-white/40 hover:bg-white/5',
)}
onClick={() => setActiveTopic(t.key)}
>
<span>{t.display}</span>
</div>
))}
</div>
{loading ? (
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
) : (
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 pt-5 lg:pb-8 w-full justify-items-center lg:justify-items-start">
{discover &&
discover?.map((item, i) => (
<Link
href={`/?q=Summary: ${item.url}`}
key={i}
className="max-w-sm rounded-lg overflow-hidden bg-light-secondary dark:bg-dark-secondary hover:-translate-y-[1px] transition duration-200"
target="_blank"
>
<img
className="object-cover w-full aspect-video"
src={
new URL(item.thumbnail).origin +
new URL(item.thumbnail).pathname +
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
}
alt={item.title}
/>
<div className="px-6 py-4">
<div className="font-bold text-lg mb-2">
{item.title.slice(0, 100)}...
</div>
<p className="text-black-70 dark:text-white/70 text-sm">
{item.content.slice(0, 100)}...
</p>
</div>
</Link>
))}
</div>
)}
</div>
</>
);

View File

@ -11,3 +11,11 @@
display: none;
}
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
select,
textarea,
input {
font-size: 16px !important;
}
}

54
src/app/manifest.ts Normal file
View File

@ -0,0 +1,54 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Perplexica - Chat with the internet',
short_name: 'Perplexica',
description:
'Perplexica is an AI powered chatbot that is connected to the internet.',
start_url: '/',
display: 'standalone',
background_color: '#0a0a0a',
theme_color: '#0a0a0a',
screenshots: [
{
src: '/screenshots/p1.png',
form_factor: 'wide',
sizes: '2560x1600',
},
{
src: '/screenshots/p2.png',
form_factor: 'wide',
sizes: '2560x1600',
},
{
src: '/screenshots/p1_small.png',
form_factor: 'narrow',
sizes: '828x1792',
},
{
src: '/screenshots/p2_small.png',
form_factor: 'narrow',
sizes: '828x1792',
},
],
icons: [
{
src: '/icon-50.png',
sizes: '50x50',
type: 'image/png' as const,
},
{
src: '/icon-100.png',
sizes: '100x100',
type: 'image/png',
},
{
src: '/icon.png',
sizes: '440x440',
type: 'image/png',
purpose: 'any',
},
],
};
}

View File

@ -23,6 +23,7 @@ interface SettingsType {
ollamaApiUrl: string;
lmStudioApiUrl: string;
deepseekApiKey: string;
aimlApiKey: string;
customOpenaiApiKey: string;
customOpenaiApiUrl: string;
customOpenaiModelName: string;
@ -147,6 +148,9 @@ const Page = () => {
const [automaticImageSearch, setAutomaticImageSearch] = useState(false);
const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false);
const [systemInstructions, setSystemInstructions] = useState<string>('');
const [measureUnit, setMeasureUnit] = useState<'Imperial' | 'Metric'>(
'Metric',
);
const [savingStates, setSavingStates] = useState<Record<string, boolean>>({});
useEffect(() => {
@ -209,6 +213,10 @@ const Page = () => {
setSystemInstructions(localStorage.getItem('systemInstructions')!);
setMeasureUnit(
localStorage.getItem('measureUnit')! as 'Imperial' | 'Metric',
);
setIsLoading(false);
};
@ -367,6 +375,8 @@ const Page = () => {
localStorage.setItem('embeddingModel', value);
} else if (key === 'systemInstructions') {
localStorage.setItem('systemInstructions', value);
} else if (key === 'measureUnit') {
localStorage.setItem('measureUnit', value.toString());
}
} catch (err) {
console.error('Failed to save:', err);
@ -415,13 +425,35 @@ const Page = () => {
) : (
config && (
<div className="flex flex-col space-y-6 pb-28 lg:pb-8">
<SettingsSection title="Appearance">
<SettingsSection title="Preferences">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Theme
</p>
<ThemeSwitcher />
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Measurement Units
</p>
<Select
value={measureUnit ?? undefined}
onChange={(e) => {
setMeasureUnit(e.target.value as 'Imperial' | 'Metric');
saveConfig('measureUnit', e.target.value);
}}
options={[
{
label: 'Metric',
value: 'Metric',
},
{
label: 'Imperial',
value: 'Imperial',
},
]}
/>
</div>
</SettingsSection>
<SettingsSection title="Automatic Search">
@ -515,7 +547,7 @@ const Page = () => {
<SettingsSection title="System Instructions">
<div className="flex flex-col space-y-4">
<Textarea
value={systemInstructions}
value={systemInstructions ?? undefined}
isSaving={savingStates['systemInstructions']}
onChange={(e) => {
setSystemInstructions(e.target.value);
@ -862,6 +894,25 @@ const Page = () => {
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
AI/ML API Key
</p>
<Input
type="text"
placeholder="AI/ML API Key"
value={config.aimlApiKey}
isSaving={savingStates['aimlApiKey']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
aimlApiKey: e.target.value,
}));
}}
onSave={(value) => saveConfig('aimlApiKey', value)}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
LM Studio API URL

View File

@ -82,14 +82,29 @@ const checkConfig = async (
) {
if (!chatModel || !chatModelProvider) {
const chatModelProviders = providers.chatModelProviders;
const chatModelProvidersKeys = Object.keys(chatModelProviders);
chatModelProvider =
chatModelProvider || Object.keys(chatModelProviders)[0];
if (!chatModelProviders || chatModelProvidersKeys.length === 0) {
return toast.error('No chat models available');
} else {
chatModelProvider =
chatModelProvidersKeys.find(
(provider) =>
Object.keys(chatModelProviders[provider]).length > 0,
) || chatModelProvidersKeys[0];
}
if (
chatModelProvider === 'custom_openai' &&
Object.keys(chatModelProviders[chatModelProvider]).length === 0
) {
toast.error(
"Looks like you haven't configured any chat model providers. Please configure them from the settings page or the config file.",
);
return setHasError(true);
}
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
if (!chatModelProviders || Object.keys(chatModelProviders).length === 0)
return toast.error('No chat models available');
}
if (!embeddingModel || !embeddingModelProvider) {
@ -117,7 +132,8 @@ const checkConfig = async (
if (
Object.keys(chatModelProviders).length > 0 &&
!chatModelProviders[chatModelProvider]
(!chatModelProviders[chatModelProvider] ||
Object.keys(chatModelProviders[chatModelProvider]).length === 0)
) {
const chatModelProvidersKeys = Object.keys(chatModelProviders);
chatModelProvider =
@ -132,6 +148,16 @@ const checkConfig = async (
chatModelProvider &&
!chatModelProviders[chatModelProvider][chatModel]
) {
if (
chatModelProvider === 'custom_openai' &&
Object.keys(chatModelProviders[chatModelProvider]).length === 0
) {
toast.error(
"Looks like you haven't configured any chat model providers. Please configure them from the settings page or the config file.",
);
return setHasError(true);
}
chatModel = Object.keys(
chatModelProviders[
Object.keys(chatModelProviders[chatModelProvider]).length > 0
@ -139,6 +165,7 @@ const checkConfig = async (
: Object.keys(chatModelProviders)[0]
],
)[0];
localStorage.setItem('chatModel', chatModel);
}
@ -327,7 +354,11 @@ const ChatWindow = ({ id }: { id?: string }) => {
}
}, [isMessagesLoaded, isConfigReady]);
const sendMessage = async (message: string, messageId?: string) => {
const sendMessage = async (
message: string,
messageId?: string,
rewrite = false,
) => {
if (loading) return;
if (!isConfigReady) {
toast.error('Cannot send message before the configuration is ready');
@ -376,8 +407,18 @@ const ChatWindow = ({ id }: { id?: string }) => {
},
]);
added = true;
setMessageAppeared(true);
} else {
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, sources: sources };
}
return message;
}),
);
}
setMessageAppeared(true);
}
if (data.type === 'message') {
@ -394,20 +435,20 @@ const ChatWindow = ({ id }: { id?: string }) => {
},
]);
added = true;
} else {
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
recievedMessage += data.data;
setMessageAppeared(true);
}
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
recievedMessage += data.data;
setMessageAppeared(true);
}
if (data.type === 'messageEnd') {
@ -455,6 +496,8 @@ const ChatWindow = ({ id }: { id?: string }) => {
}
};
const messageIndex = messages.findIndex((m) => m.messageId === messageId);
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
@ -471,7 +514,9 @@ const ChatWindow = ({ id }: { id?: string }) => {
files: fileIds,
focusMode: focusMode,
optimizationMode: optimizationMode,
history: chatHistory,
history: rewrite
? chatHistory.slice(0, messageIndex === -1 ? undefined : messageIndex)
: chatHistory,
chatModel: {
name: chatModelProvider.name,
provider: chatModelProvider.provider,
@ -525,7 +570,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
return [...prev.slice(0, messages.length > 2 ? index - 1 : 0)];
});
sendMessage(message.content, message.messageId);
sendMessage(message.content, message.messageId, true);
};
useEffect(() => {

View File

@ -1,6 +1,5 @@
import { Settings } from 'lucide-react';
import EmptyChatMessageInput from './EmptyChatMessageInput';
import { useEffect, useState } from 'react';
import { File } from './ChatWindow';
import Link from 'next/link';
import WeatherWidget from './WeatherWidget';
@ -34,26 +33,28 @@ const EmptyChat = ({
<Settings className="cursor-pointer lg:hidden" />
</Link>
</div>
<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-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
Research begins here.
</h2>
<EmptyChatMessageInput
sendMessage={sendMessage}
focusMode={focusMode}
setFocusMode={setFocusMode}
optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode}
fileIds={fileIds}
setFileIds={setFileIds}
files={files}
setFiles={setFiles}
/>
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-4">
<div className="flex flex-col items-center justify-center w-full space-y-8">
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
Research begins here.
</h2>
<EmptyChatMessageInput
sendMessage={sendMessage}
focusMode={focusMode}
setFocusMode={setFocusMode}
optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode}
fileIds={fileIds}
setFileIds={setFileIds}
files={files}
setFiles={setFiles}
/>
</div>
<div className="flex flex-col w-full gap-4 mt-2 sm:flex-row sm:justify-center">
<div className="flex-1 max-w-xs">
<div className="flex-1 w-full">
<WeatherWidget />
</div>
<div className="flex-1 max-w-xs">
<div className="flex-1 w-full">
<NewsArticleWidget />
</div>
</div>

View File

@ -20,9 +20,19 @@ import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos';
import { useSpeech } from 'react-text-to-speech';
import ThinkBox from './ThinkBox';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { SyntaxHighlightedCode } from './SyntaxHighlightedCode';
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
return <ThinkBox content={children as string} />;
const ThinkTagProcessor = ({
children,
thinkingEnded,
}: {
children: React.ReactNode;
thinkingEnded: boolean;
}) => {
return (
<ThinkBox content={children as string} thinkingEnded={thinkingEnded} />
);
};
const MessageBox = ({
@ -46,6 +56,7 @@ const MessageBox = ({
}) => {
const [parsedMessage, setParsedMessage] = useState(message.content);
const [speechMessage, setSpeechMessage] = useState(message.content);
const [thinkingEnded, setThinkingEnded] = useState(false);
useEffect(() => {
const citationRegex = /\[([^\]]+)\]/g;
@ -61,6 +72,10 @@ const MessageBox = ({
}
}
if (message.role === 'assistant' && message.content.includes('</think>')) {
setThinkingEnded(true);
}
if (
message.role === 'assistant' &&
message?.sources &&
@ -88,7 +103,7 @@ const MessageBox = ({
if (url) {
return `<a href="${url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${numStr}</a>`;
} else {
return `[${numStr}]`;
return ``;
}
})
.join('');
@ -99,6 +114,14 @@ const MessageBox = ({
);
setSpeechMessage(message.content.replace(regex, ''));
return;
} else if (
message.role === 'assistant' &&
message?.sources &&
message.sources.length === 0
) {
setParsedMessage(processedMessage.replace(regex, ''));
setSpeechMessage(message.content.replace(regex, ''));
return;
}
setSpeechMessage(message.content.replace(regex, ''));
@ -111,6 +134,12 @@ const MessageBox = ({
overrides: {
think: {
component: ThinkTagProcessor,
props: {
thinkingEnded: thinkingEnded,
},
},
code: {
component: SyntaxHighlightedCode,
},
},
};

View File

@ -0,0 +1,30 @@
import React, { HTMLProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
coldarkDark,
coldarkCold,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
export const SyntaxHighlightedCode = (props: HTMLProps<HTMLDivElement>) => {
const isDarkTheme = document.documentElement.classList.contains('dark');
const language = props.className?.match(/lang-([a-zA-Z0-9_-]+)/)![1];
return language ? (
<div className="not-prose">
<SyntaxHighlighter
customStyle={{
margin: 0,
backgroundColor: isDarkTheme ? '#111111' : '#f3f3ee',
}}
language={language}
style={isDarkTheme ? coldarkDark : coldarkCold}
>
{props.children as string}
</SyntaxHighlighter>
</div>
) : (
<code className="inline bg-light-100 dark:bg-dark-100 px-2 py-1 rounded-lg text-sm not-prose">
{props.children}
</code>
);
};

View File

@ -1,15 +1,23 @@
'use client';
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
interface ThinkBoxProps {
content: string;
thinkingEnded: boolean;
}
const ThinkBox = ({ content }: ThinkBoxProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const ThinkBox = ({ content, thinkingEnded }: ThinkBoxProps) => {
const [isExpanded, setIsExpanded] = useState(true);
useEffect(() => {
if (thinkingEnded) {
setIsExpanded(false);
} else {
setIsExpanded(true);
}
}, [thinkingEnded]);
return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">

View File

@ -9,7 +9,10 @@ const WeatherWidget = () => {
humidity: 0,
windSpeed: 0,
icon: '',
temperatureUnit: 'C',
windSpeedUnit: 'm/s',
});
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -31,30 +34,40 @@ const WeatherWidget = () => {
city: string;
}) => void,
) => {
/*
// Geolocation doesn't give city so we'll country using ipapi for now
if (navigator.geolocation) {
const result = await navigator.permissions.query({
name: 'geolocation',
})
if (result.state === 'granted') {
navigator.geolocation.getCurrentPosition(position => {
callback({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
})
} else if (result.state === 'prompt') {
callback(await getApproxLocation())
navigator.geolocation.getCurrentPosition(position => {})
} else if (result.state === 'denied') {
callback(await getApproxLocation())
}
} else {
callback(await getApproxLocation())
} */
callback(await getApproxLocation());
if (navigator.geolocation) {
const result = await navigator.permissions.query({
name: 'geolocation',
});
if (result.state === 'granted') {
navigator.geolocation.getCurrentPosition(async (position) => {
const res = await fetch(
`https://api-bdc.io/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=en`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
);
const data = await res.json();
callback({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
city: data.locality,
});
});
} else if (result.state === 'prompt') {
callback(await getApproxLocation());
navigator.geolocation.getCurrentPosition((position) => {});
} else if (result.state === 'denied') {
callback(await getApproxLocation());
}
} else {
callback(await getApproxLocation());
}
};
getLocation(async (location) => {
@ -63,6 +76,7 @@ const WeatherWidget = () => {
body: JSON.stringify({
lat: location.latitude,
lng: location.longitude,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
@ -81,6 +95,8 @@ const WeatherWidget = () => {
humidity: data.humidity,
windSpeed: data.windSpeed,
icon: data.icon,
temperatureUnit: data.temperatureUnit,
windSpeedUnit: data.windSpeedUnit,
});
setLoading(false);
});
@ -115,7 +131,7 @@ const WeatherWidget = () => {
className="h-10 w-auto"
/>
<span className="text-base font-semibold text-black dark:text-white">
{data.temperature}°C
{data.temperature}°{data.temperatureUnit}
</span>
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1">
@ -125,7 +141,7 @@ const WeatherWidget = () => {
</span>
<span className="flex items-center text-xs text-black/60 dark:text-white/60">
<Wind className="w-3 h-3 mr-1" />
{data.windSpeed} km/h
{data.windSpeed} {data.windSpeedUnit}
</span>
</div>
<span className="text-xs text-black/60 dark:text-white/60 mt-1">

View File

@ -3,32 +3,18 @@ import {
RunnableMap,
RunnableLambda,
} from '@langchain/core/runnables';
import { PromptTemplate } from '@langchain/core/prompts';
import { ChatPromptTemplate } 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 '../searxng';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import LineOutputParser from '../outputParsers/lineOutputParser';
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:
Output only the rephrased query wrapped in an XML <query> element. Do not include any explanation or additional text.
`;
type ImageSearchChainInput = {
@ -54,12 +40,39 @@ const createImageSearchChain = (llm: BaseChatModel) => {
return input.query;
},
}),
PromptTemplate.fromTemplate(imageSearchChainPrompt),
ChatPromptTemplate.fromMessages([
['system', imageSearchChainPrompt],
[
'user',
'<conversation>\n</conversation>\n<follow_up>\nWhat is a cat?\n</follow_up>',
],
['assistant', '<query>A cat</query>'],
[
'user',
'<conversation>\n</conversation>\n<follow_up>\nWhat is a car? How does it work?\n</follow_up>',
],
['assistant', '<query>Car working</query>'],
[
'user',
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
],
['assistant', '<query>AC working</query>'],
[
'user',
'<conversation>{chat_history}</conversation>\n<follow_up>\n{query}\n</follow_up>',
],
]),
llm,
strParser,
RunnableLambda.from(async (input: string) => {
input = input.replace(/<think>.*?<\/think>/g, '');
const queryParser = new LineOutputParser({
key: 'query',
});
return await queryParser.parse(input);
}),
RunnableLambda.from(async (input: string) => {
const res = await searchSearxng(input, {
engines: ['bing images', 'google images'],
});

View File

@ -3,33 +3,19 @@ import {
RunnableMap,
RunnableLambda,
} from '@langchain/core/runnables';
import { PromptTemplate } from '@langchain/core/prompts';
import { ChatPromptTemplate } 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 '../searxng';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import LineOutputParser from '../outputParsers/lineOutputParser';
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:
`;
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.
Output only the rephrased query wrapped in an XML <query> element. Do not include any explanation or additional text.
`;
type VideoSearchChainInput = {
chat_history: BaseMessage[];
@ -55,12 +41,37 @@ const createVideoSearchChain = (llm: BaseChatModel) => {
return input.query;
},
}),
PromptTemplate.fromTemplate(VideoSearchChainPrompt),
ChatPromptTemplate.fromMessages([
['system', videoSearchChainPrompt],
[
'user',
'<conversation>\n</conversation>\n<follow_up>\nHow does a car work?\n</follow_up>',
],
['assistant', '<query>How does a car work?</query>'],
[
'user',
'<conversation>\n</conversation>\n<follow_up>\nWhat is the theory of relativity?\n</follow_up>',
],
['assistant', '<query>Theory of relativity</query>'],
[
'user',
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
],
['assistant', '<query>AC working</query>'],
[
'user',
'<conversation>{chat_history}</conversation>\n<follow_up>\n{query}\n</follow_up>',
],
]),
llm,
strParser,
RunnableLambda.from(async (input: string) => {
input = input.replace(/<think>.*?<\/think>/g, '');
const queryParser = new LineOutputParser({
key: 'query',
});
return await queryParser.parse(input);
}),
RunnableLambda.from(async (input: string) => {
const res = await searchSearxng(input, {
engines: ['youtube'],
});
@ -92,8 +103,8 @@ const handleVideoSearch = (
input: VideoSearchChainInput,
llm: BaseChatModel,
) => {
const VideoSearchChain = createVideoSearchChain(llm);
return VideoSearchChain.invoke(input);
const videoSearchChain = createVideoSearchChain(llm);
return videoSearchChain.invoke(input);
};
export default handleVideoSearch;

View File

@ -35,6 +35,9 @@ interface Config {
DEEPSEEK: {
API_KEY: string;
};
AIMLAPI: {
API_KEY: string;
};
LM_STUDIO: {
API_URL: string;
};
@ -85,6 +88,8 @@ export const getOllamaApiEndpoint = () => loadConfig().MODELS.OLLAMA.API_URL;
export const getDeepseekApiKey = () => loadConfig().MODELS.DEEPSEEK.API_KEY;
export const getAimlApiKey = () => loadConfig().MODELS.AIMLAPI.API_KEY;
export const getCustomOpenaiApiKey = () =>
loadConfig().MODELS.CUSTOM_OPENAI.API_KEY;

View File

@ -65,12 +65,16 @@ export const webSearchResponsePrompt = `
You are Perplexica, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
Your task is to provide answers that are:
- **Informative and relevant**: Thoroughly address the user's query using the given context.
- **Informative and relevant**: Thoroughly address the user's query using the given search results.
- **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically.
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights.
- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included.
- **Cited and credible**: Use inline citations with [number] notation to refer to the search results source(s) for each fact or detail included.
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable.
You have access to the following tools which you have to use to answer the user:
1. **search_web**: Use this tool to search the web for information to answer the user's question.
2. **summarize**: Use this tool to summarize a link.
### Formatting Instructions
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate.
- **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience.
@ -80,13 +84,14 @@ export const webSearchResponsePrompt = `
- **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate.
### Citation Requirements
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the search results
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided search results.
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
- Always prioritize credibility and accuracy by linking all statements back to their respective context sources.
- Always prioritize credibility and accuracy by linking all statements back to their respective search results sources.
- Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation.
- Never return references to the citation or sources yourself, they're returned to the user internally.
### Special Instructions
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
- If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search.
@ -99,12 +104,8 @@ export const webSearchResponsePrompt = `
### Example Output
- Begin with a brief introduction summarizing the event or query topic.
- Follow with detailed sections under clear headings, covering all aspects of the query if possible.
- Provide explanations or historical context as needed to enhance understanding.
- Provide explanations or historical search results as needed to enhance understanding.
- End with a conclusion or overall perspective if relevant.
<context>
{context}
</context>
Current date & time in ISO format (UTC timezone) is: {date}.
`;

View File

@ -10,8 +10,4 @@ However you do not need to cite it using the same number. You can use different
### User instructions
These instructions are shared to you by the user and not by the system. You will have to follow them but give them less priority than the above instructions. If the user has provided specific instructions or preferences, incorporate them into your response while adhering to the overall guidelines.
{systemInstructions}
<context>
{context}
</context>
`;

View File

@ -0,0 +1,94 @@
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { getAimlApiKey } from '../config';
import { ChatModel, EmbeddingModel } from '.';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { Embeddings } from '@langchain/core/embeddings';
import axios from 'axios';
export const PROVIDER_INFO = {
key: 'aimlapi',
displayName: 'AI/ML API',
};
interface AimlApiModel {
id: string;
name?: string;
type?: string;
}
const API_URL = 'https://api.aimlapi.com';
export const loadAimlApiChatModels = async () => {
const apiKey = getAimlApiKey();
if (!apiKey) return {};
try {
const response = await axios.get(`${API_URL}/models`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
const chatModels: Record<string, ChatModel> = {};
response.data.data.forEach((model: AimlApiModel) => {
if (model.type === 'chat-completion') {
chatModels[model.id] = {
displayName: model.name || model.id,
model: new ChatOpenAI({
apiKey: apiKey,
modelName: model.id,
temperature: 0.7,
configuration: {
baseURL: API_URL,
},
}) as unknown as BaseChatModel,
};
}
});
return chatModels;
} catch (err) {
console.error(`Error loading AI/ML API models: ${err}`);
return {};
}
};
export const loadAimlApiEmbeddingModels = async () => {
const apiKey = getAimlApiKey();
if (!apiKey) return {};
try {
const response = await axios.get(`${API_URL}/models`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
const embeddingModels: Record<string, EmbeddingModel> = {};
response.data.data.forEach((model: AimlApiModel) => {
if (model.type === 'embedding') {
embeddingModels[model.id] = {
displayName: model.name || model.id,
model: new OpenAIEmbeddings({
apiKey: apiKey,
modelName: model.id,
configuration: {
baseURL: API_URL,
},
}) as unknown as Embeddings,
};
}
});
return embeddingModels;
} catch (err) {
console.error(`Error loading AI/ML API embeddings models: ${err}`);
return {};
}
};

View File

@ -31,7 +31,7 @@ export const loadDeepseekChatModels = async () => {
chatModels[model.key] = {
displayName: model.displayName,
model: new ChatOpenAI({
openAIApiKey: deepseekApiKey,
apiKey: deepseekApiKey,
modelName: model.key,
temperature: 0.7,
configuration: {

View File

@ -14,8 +14,12 @@ import { Embeddings } from '@langchain/core/embeddings';
const geminiChatModels: Record<string, string>[] = [
{
displayName: 'Gemini 2.5 Pro Experimental',
key: 'gemini-2.5-pro-exp-03-25',
displayName: 'Gemini 2.5 Flash',
key: 'gemini-2.5-flash',
},
{
displayName: 'Gemini 2.5 Pro',
key: 'gemini-2.5-pro',
},
{
displayName: 'Gemini 2.0 Flash',
@ -67,7 +71,7 @@ export const loadGeminiChatModels = async () => {
displayName: model.displayName,
model: new ChatGoogleGenerativeAI({
apiKey: geminiApiKey,
modelName: model.key,
model: model.key,
temperature: 0.7,
}) as unknown as BaseChatModel,
};
@ -100,7 +104,7 @@ export const loadGeminiEmbeddingModels = async () => {
return embeddingModels;
} catch (err) {
console.error(`Error loading OpenAI embeddings models: ${err}`);
console.error(`Error loading Gemini embeddings models: ${err}`);
return {};
}
};

View File

@ -1,4 +1,4 @@
import { ChatOpenAI } from '@langchain/openai';
import { ChatGroq } from '@langchain/groq';
import { getGroqApiKey } from '../config';
import { ChatModel } from '.';
@ -28,13 +28,10 @@ export const loadGroqChatModels = async () => {
groqChatModels.forEach((model: any) => {
chatModels[model.id] = {
displayName: model.id,
model: new ChatOpenAI({
openAIApiKey: groqApiKey,
modelName: model.id,
model: new ChatGroq({
apiKey: groqApiKey,
model: model.id,
temperature: 0.7,
configuration: {
baseURL: 'https://api.groq.com/openai/v1',
},
}) as unknown as BaseChatModel,
};
});

View File

@ -35,6 +35,11 @@ import {
loadDeepseekChatModels,
PROVIDER_INFO as DeepseekInfo,
} from './deepseek';
import {
loadAimlApiChatModels,
loadAimlApiEmbeddingModels,
PROVIDER_INFO as AimlApiInfo,
} from './aimlapi';
import {
loadLMStudioChatModels,
loadLMStudioEmbeddingsModels,
@ -49,6 +54,7 @@ export const PROVIDER_METADATA = {
gemini: GeminiInfo,
transformers: TransformersInfo,
deepseek: DeepseekInfo,
aimlapi: AimlApiInfo,
lmstudio: LMStudioInfo,
custom_openai: {
key: 'custom_openai',
@ -76,6 +82,7 @@ export const chatModelProviders: Record<
anthropic: loadAnthropicChatModels,
gemini: loadGeminiChatModels,
deepseek: loadDeepseekChatModels,
aimlapi: loadAimlApiChatModels,
lmstudio: loadLMStudioChatModels,
};
@ -87,6 +94,7 @@ export const embeddingModelProviders: Record<
ollama: loadOllamaEmbeddingModels,
gemini: loadGeminiEmbeddingModels,
transformers: loadTransformersEmbeddingsModels,
aimlapi: loadAimlApiEmbeddingModels,
lmstudio: loadLMStudioEmbeddingsModels,
};
@ -110,7 +118,7 @@ export const getAvailableChatModelProviders = async () => {
[customOpenAiModelName]: {
displayName: customOpenAiModelName,
model: new ChatOpenAI({
openAIApiKey: customOpenAiApiKey,
apiKey: customOpenAiApiKey,
modelName: customOpenAiModelName,
temperature: 0.7,
configuration: {

View File

@ -47,7 +47,7 @@ export const loadLMStudioChatModels = async () => {
chatModels[model.id] = {
displayName: model.name || model.id,
model: new ChatOpenAI({
openAIApiKey: 'lm-studio',
apiKey: 'lm-studio',
configuration: {
baseURL: ensureV1Endpoint(endpoint),
},
@ -83,7 +83,7 @@ export const loadLMStudioEmbeddingsModels = async () => {
embeddingsModels[model.id] = {
displayName: model.name || model.id,
model: new OpenAIEmbeddings({
openAIApiKey: 'lm-studio',
apiKey: 'lm-studio',
configuration: {
baseURL: ensureV1Endpoint(endpoint),
},

View File

@ -6,8 +6,8 @@ export const PROVIDER_INFO = {
key: 'ollama',
displayName: 'Ollama',
};
import { ChatOllama } from '@langchain/community/chat_models/ollama';
import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
import { ChatOllama } from '@langchain/ollama';
import { OllamaEmbeddings } from '@langchain/ollama';
export const loadOllamaChatModels = async () => {
const ollamaApiEndpoint = getOllamaApiEndpoint();

View File

@ -67,7 +67,7 @@ export const loadOpenAIChatModels = async () => {
chatModels[model.key] = {
displayName: model.displayName,
model: new ChatOpenAI({
openAIApiKey: openaiApiKey,
apiKey: openaiApiKey,
modelName: model.key,
temperature: 0.7,
}) as unknown as BaseChatModel,
@ -93,7 +93,7 @@ export const loadOpenAIEmbeddingModels = async () => {
embeddingModels[model.key] = {
displayName: model.displayName,
model: new OpenAIEmbeddings({
openAIApiKey: openaiApiKey,
apiKey: openaiApiKey,
modelName: model.key,
}) as unknown as Embeddings,
};

View File

@ -11,7 +11,12 @@ import {
RunnableMap,
RunnableSequence,
} from '@langchain/core/runnables';
import { BaseMessage } from '@langchain/core/messages';
import {
AIMessage,
BaseMessage,
isAIMessage,
ToolMessage,
} from '@langchain/core/messages';
import { StringOutputParser } from '@langchain/core/output_parsers';
import LineListOutputParser from '../outputParsers/listLineOutputParser';
import LineOutputParser from '../outputParsers/lineOutputParser';
@ -24,6 +29,15 @@ import computeSimilarity from '../utils/computeSimilarity';
import formatChatHistoryAsString from '../utils/formatHistory';
import eventEmitter from 'events';
import { StreamEvent } from '@langchain/core/tracers/log_stream';
import { DynamicStructuredTool, tool } from '@langchain/core/tools';
import { MessagesAnnotation, StateGraph } from '@langchain/langgraph';
import { z } from 'zod';
import {
IterableReadableStream,
IterableReadableStreamInterface,
} from '@langchain/core/utils/stream';
import EventEmitter from 'node:events';
import { BaseLanguageModel } from '@langchain/core/language_models/base';
export interface MetaSearchAgentType {
searchAndAnswer: (
@ -47,11 +61,6 @@ interface Config {
activeEngines: string[];
}
type BasicChainInput = {
chat_history: BaseMessage[];
query: string;
};
class MetaSearchAgent implements MetaSearchAgentType {
private config: Config;
private strParser = new StringOutputParser();
@ -60,239 +69,6 @@ class MetaSearchAgent implements MetaSearchAgentType {
this.config = config;
}
private async createSearchRetrieverChain(llm: BaseChatModel) {
(llm as unknown as ChatOpenAI).temperature = 0;
return RunnableSequence.from([
PromptTemplate.fromTemplate(this.config.queryGeneratorPrompt),
llm,
this.strParser,
RunnableLambda.from(async (input: string) => {
const linksOutputParser = new LineListOutputParser({
key: 'links',
});
const questionOutputParser = new LineOutputParser({
key: 'question',
});
const links = await linksOutputParser.parse(input);
let question = this.config.summarizer
? await questionOutputParser.parse(input)
: input;
if (question === 'not_needed') {
return { query: '', docs: [] };
}
if (links.length > 0) {
if (question.length === 0) {
question = 'summarize';
}
let docs: Document[] = [];
const linkDocs = await getDocumentsFromLinks({ links });
const docGroups: Document[] = [];
linkDocs.map((doc) => {
const URLDocExists = docGroups.find(
(d) =>
d.metadata.url === doc.metadata.url &&
d.metadata.totalDocs < 10,
);
if (!URLDocExists) {
docGroups.push({
...doc,
metadata: {
...doc.metadata,
totalDocs: 1,
},
});
}
const docIndex = docGroups.findIndex(
(d) =>
d.metadata.url === doc.metadata.url &&
d.metadata.totalDocs < 10,
);
if (docIndex !== -1) {
docGroups[docIndex].pageContent =
docGroups[docIndex].pageContent + `\n\n` + doc.pageContent;
docGroups[docIndex].metadata.totalDocs += 1;
}
});
await Promise.all(
docGroups.map(async (doc) => {
const res = await llm.invoke(`
You are a web search summarizer, tasked with summarizing a piece of text retrieved from a web search. Your job is to summarize the
text into a detailed, 2-4 paragraph explanation that captures the main ideas and provides a comprehensive answer to the query.
If the query is \"summarize\", you should provide a detailed summary of the text. If the query is a specific question, you should answer it in the summary.
- **Journalistic tone**: The summary should sound professional and journalistic, not too casual or vague.
- **Thorough and detailed**: Ensure that every key point from the text is captured and that the summary directly answers the query.
- **Not too lengthy, but detailed**: The summary should be informative but not excessively long. Focus on providing detailed information in a concise format.
The text will be shared inside the \`text\` XML tag, and the query inside the \`query\` XML tag.
<example>
1. \`<text>
Docker is a set of platform-as-a-service products that use OS-level virtualization to deliver software in packages called containers.
It was first released in 2013 and is developed by Docker, Inc. Docker is designed to make it easier to create, deploy, and run applications
by using containers.
</text>
<query>
What is Docker and how does it work?
</query>
Response:
Docker is a revolutionary platform-as-a-service product developed by Docker, Inc., that uses container technology to make application
deployment more efficient. It allows developers to package their software with all necessary dependencies, making it easier to run in
any environment. Released in 2013, Docker has transformed the way applications are built, deployed, and managed.
\`
2. \`<text>
The theory of relativity, or simply relativity, encompasses two interrelated theories of Albert Einstein: special relativity and general
relativity. However, the word "relativity" is sometimes used in reference to Galilean invariance. The term "theory of relativity" was based
on the expression "relative theory" used by Max Planck in 1906. The theory of relativity usually encompasses two interrelated theories by
Albert Einstein: special relativity and general relativity. Special relativity applies to all physical phenomena in the absence of gravity.
General relativity explains the law of gravitation and its relation to other forces of nature. It applies to the cosmological and astrophysical
realm, including astronomy.
</text>
<query>
summarize
</query>
Response:
The theory of relativity, developed by Albert Einstein, encompasses two main theories: special relativity and general relativity. Special
relativity applies to all physical phenomena in the absence of gravity, while general relativity explains the law of gravitation and its
relation to other forces of nature. The theory of relativity is based on the concept of "relative theory," as introduced by Max Planck in
1906. It is a fundamental theory in physics that has revolutionized our understanding of the universe.
\`
</example>
Everything below is the actual data you will be working with. Good luck!
<query>
${question}
</query>
<text>
${doc.pageContent}
</text>
Make sure to answer the query in the summary.
`);
const document = new Document({
pageContent: res.content as string,
metadata: {
title: doc.metadata.title,
url: doc.metadata.url,
},
});
docs.push(document);
}),
);
return { query: question, docs: docs };
} else {
question = question.replace(/<think>.*?<\/think>/g, '');
const res = await searchSearxng(question, {
language: 'en',
engines: this.config.activeEngines,
});
const documents = res.results.map(
(result) =>
new Document({
pageContent:
result.content ||
(this.config.activeEngines.includes('youtube')
? result.title
: '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */,
metadata: {
title: result.title,
url: result.url,
...(result.img_src && { img_src: result.img_src }),
},
}),
);
return { query: question, docs: documents };
}
}),
]);
}
private async createAnsweringChain(
llm: BaseChatModel,
fileIds: string[],
embeddings: Embeddings,
optimizationMode: 'speed' | 'balanced' | 'quality',
systemInstructions: string,
) {
return RunnableSequence.from([
RunnableMap.from({
systemInstructions: () => systemInstructions,
query: (input: BasicChainInput) => input.query,
chat_history: (input: BasicChainInput) => input.chat_history,
date: () => new Date().toISOString(),
context: RunnableLambda.from(async (input: BasicChainInput) => {
const processedHistory = formatChatHistoryAsString(
input.chat_history,
);
let docs: Document[] | null = null;
let query = input.query;
if (this.config.searchWeb) {
const searchRetrieverChain =
await this.createSearchRetrieverChain(llm);
const searchRetrieverResult = await searchRetrieverChain.invoke({
chat_history: processedHistory,
query,
});
query = searchRetrieverResult.query;
docs = searchRetrieverResult.docs;
}
const sortedDocs = await this.rerankDocs(
query,
docs ?? [],
fileIds,
embeddings,
optimizationMode,
);
return sortedDocs;
})
.withConfig({
runName: 'FinalSourceRetriever',
})
.pipe(this.processDocs),
}),
ChatPromptTemplate.fromMessages([
['system', this.config.responsePrompt],
new MessagesPlaceholder('chat_history'),
['user', '{query}'],
]),
llm,
this.strParser,
]).withConfig({
runName: 'FinalResponseGenerator',
});
}
private async rerankDocs(
query: string,
docs: Document[],
@ -432,36 +208,363 @@ class MetaSearchAgent implements MetaSearchAgentType {
}
private async handleStream(
stream: AsyncGenerator<StreamEvent, any, any>,
stream: AsyncIterable<[BaseMessage, Record<string, any>]>,
emitter: eventEmitter,
) {
for await (const event of stream) {
if (
event.event === 'on_chain_end' &&
event.name === 'FinalSourceRetriever'
) {
``;
for await (const [message, _metadata] of stream) {
if (isAIMessage(message) && message.tool_calls?.length) {
} else if (isAIMessage(message) && message.content) {
emitter.emit(
'data',
JSON.stringify({ type: 'sources', data: event.data.output }),
JSON.stringify({ type: 'response', data: message.content }),
);
}
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');
}
}
emitter.emit('end');
}
getTools({
llm,
emitter,
}: {
llm: BaseLanguageModel;
emitter: EventEmitter;
}): DynamicStructuredTool[] {
const searchToolInputSchema = z.object({
searchMode: z
.enum(['normal', 'news'])
.describe(
'The search mode. If you want latest, live or articles on a topic use the news mode (you also need to use news mode for queries where you think there might be a newswall), otherwise for normal searches use normal mode.',
),
query: z.string().describe('The query to search the web for.'),
links: z
.array(z.string().describe('The link to get data from'))
.describe(
'A list of links (if shared by user) to generate an answer from.',
),
});
const searchTool = tool(
async (input: any) => {
if (input.links.length > 0) {
let docs: Document[] = [];
const linkDocs = await getDocumentsFromLinks({ links: input.links });
const docGroups: Document[] = [];
linkDocs.map((doc) => {
const URLDocExists = docGroups.find(
(d) =>
d.metadata.url === doc.metadata.url &&
d.metadata.totalDocs < 10,
);
if (!URLDocExists) {
docGroups.push({
...doc,
metadata: {
...doc.metadata,
totalDocs: 1,
},
});
}
const docIndex = docGroups.findIndex(
(d) =>
d.metadata.url === doc.metadata.url &&
d.metadata.totalDocs < 10,
);
if (docIndex !== -1) {
docGroups[docIndex].pageContent =
docGroups[docIndex].pageContent + `\n\n` + doc.pageContent;
docGroups[docIndex].metadata.totalDocs += 1;
}
});
const URLSourcePrompt = `
You are a web search question answerer, tasked with finding relevant information from web documents to answer questions. Your job is to extract and summarize the most relevant parts of a document that can help answer the user's query.
- **Find relevant sections**: Identify parts of the document that directly relate to the question
- **Extract key information**: Pull out specific facts, data, or explanations that answer the query
- **Summarize concisely**: Provide a focused summary of the relevant information found
- **Stay on topic**: Only include information that helps answer the specific question asked
The document text will be shared inside the \`text\` XML tag, and the query inside the \`query\` XML tag.
Extract and summarize the relevant information from the document that answers the query.
`;
const URLSourceChatPrompt = ChatPromptTemplate.fromMessages([
['system', URLSourcePrompt],
[
'human',
`
<text>
Docker is a set of platform-as-a-service products that use OS-level virtualization to deliver software in packages called containers.
It was first released in 2013 and is developed by Docker, Inc. Docker is designed to make it easier to create, deploy, and run applications
by using containers.
</text>
<query>
What is Docker and how does it work?
</query>
`,
],
[
'assistant',
`
Docker is a revolutionary platform-as-a-service product developed by Docker, Inc., that uses container technology to make application
deployment more efficient. It allows developers to package their software with all necessary dependencies, making it easier to run in
any environment. Released in 2013, Docker has transformed the way applications are built, deployed, and managed.
`,
],
[
'human',
`
<text>
Rising global temperatures are causing significant changes to Earth's climate system. Ice sheets in Greenland and Antarctica are melting at accelerated rates, contributing to sea level rise. Ocean temperatures are increasing, leading to coral bleaching events. Weather patterns are becoming more extreme, with increased frequency of hurricanes, droughts, and flooding. The Arctic is warming twice as fast as the global average, a phenomenon known as Arctic amplification.
</text>
<query>
What are the main effects of climate change on the environment?
</query>
`,
],
[
'assistant',
`
Climate change is causing accelerated melting of ice sheets in Greenland and Antarctica, leading to sea level rise. Rising ocean temperatures are causing coral bleaching, while weather patterns are becoming more extreme with increased hurricanes, droughts, and flooding. The Arctic is experiencing particularly rapid warming at twice the global average rate.
`,
],
[
'human',
`
<text>
Pasta is a staple food of Italian cuisine made from wheat flour and water. Common shapes include spaghetti, penne, and fusilli. It can be served with various sauces like marinara, alfredo, or pesto. Pasta cooking involves boiling water with salt and cooking until al dente. Different regions of Italy have their own traditional pasta dishes and preparation methods.
</text>
<query>
How do solar panels generate electricity?
</query>
`,
],
[
'assistant',
`
The provided document about pasta and Italian cuisine does not contain information about how solar panels generate electricity. No relevant information found to answer the query.
`,
],
[
'human',
`
<text>
{content}
</text>
<query>
{query}
</query>
`,
],
]);
await Promise.all(
docGroups.map(async (doc) => {
const formattedPrompt = await URLSourceChatPrompt.formatMessages({
query: input.query,
content: doc.pageContent,
});
const llmResponse = await llm.invoke(formattedPrompt);
const document = new Document({
pageContent: llmResponse.content as string,
metadata: {
title: doc.metadata.title,
url: doc.metadata.url,
},
});
docs.push(document);
}),
);
emitter.emit('data', JSON.stringify({ type: 'sources', data: docs }));
return this.processDocs(docs);
} else {
const res = await searchSearxng(input.query, {
language: 'en',
engines:
input.searchMode === 'news'
? ['bing_news']
: this.config.activeEngines,
});
const documents = res.results.map(
(result) =>
new Document({
pageContent:
result.content ||
(this.config.activeEngines.includes('youtube')
? result.title
: '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */,
metadata: {
title: result.title,
url: result.url,
...(result.img_src && { img_src: result.img_src }),
},
}),
);
emitter.emit(
'data',
JSON.stringify({ type: 'sources', data: documents }),
);
return this.processDocs(documents);
}
},
{
name: 'search_web',
schema: searchToolInputSchema,
description: 'This tool allows you to search the web for information.',
},
);
const summarizeToolInputSchema = z.object({
links: z
.array(z.string().describe('The URL to summarize'))
.describe('The list of URLs to summarize'),
});
const summarizeTool = tool(
async (input: any) => {
if (input.links.length > 0) {
let docs: Document[] = [];
const linkDocs = await getDocumentsFromLinks({ links: input.links });
const docGroups: Document[] = [];
linkDocs.map((doc) => {
const URLDocExists = docGroups.find(
(d) =>
d.metadata.url === doc.metadata.url &&
d.metadata.totalDocs < 10,
);
if (!URLDocExists) {
docGroups.push({
...doc,
metadata: {
...doc.metadata,
totalDocs: 1,
},
});
}
const docIndex = docGroups.findIndex(
(d) =>
d.metadata.url === doc.metadata.url &&
d.metadata.totalDocs < 10,
);
if (docIndex !== -1) {
docGroups[docIndex].pageContent =
docGroups[docIndex].pageContent + `\n\n` + doc.pageContent;
docGroups[docIndex].metadata.totalDocs += 1;
}
});
const summarizerPrompt = `
You are a document summarizer for map-reduce processing of website content. Extract all factual information, data, and key concepts from document chunks into concise paragraph summaries.
**Requirements:**
- **Extract facts only**: Include concrete information, data, numbers, dates, definitions, and explanations
- **Ignore fluff**: Skip opinions, filler words, casual language, and non-essential content
- **Be concise**: Create summaries shorter than the original content
- **Paragraph format**: Write in plain paragraph form, no markdown, bullet points, or formatting
- **Preserve specifics**: Keep exact numbers, dates, names, and technical terms
The document text will be provided inside the \`text\` XML tag. Create a factual paragraph summary.
`;
const summarizerChatPrompt = ChatPromptTemplate.fromMessages([
['system', summarizerPrompt],
[
'human',
`
<text>
Docker is a platform-as-a-service product developed by Docker, Inc. and released in 2013 that uses OS-level virtualization to deliver software in containers. Containers are lightweight, portable packages that include application code, runtime, system tools, libraries, and settings.
</text>
`,
],
[
'assistant',
`Docker is a platform-as-a-service product developed by Docker, Inc. in 2013 that uses OS-level virtualization to deliver software in containers. Containers are lightweight, portable packages containing application code, runtime, system tools, libraries, and settings.`,
],
[
'human',
`
<text>
Hey there! So like, I was totally thinking about pasta the other day, you know? It's super amazing how there are different shapes. Spaghetti is long and thin, penne has tube shapes, and fusilli is all twisty! Haha, I just love Italian creativity with food.
</text>
`,
],
[
'assistant',
`Pasta comes in various shapes including spaghetti which is long and thin, penne which has tube shapes, and fusilli which has a twisted form. These are Italian food products.`,
],
[
'human',
`
<text>
{content}
</text>
`,
],
]);
await Promise.all(
docGroups.map(async (doc) => {
const formattedPrompt = await summarizerChatPrompt.formatMessages(
{
content: doc.pageContent,
},
);
const llmResponse = await llm.invoke(formattedPrompt);
const document = new Document({
pageContent: llmResponse.content as string,
metadata: {
title: doc.metadata.title,
url: doc.metadata.url,
},
});
docs.push(document);
}),
);
emitter.emit('data', JSON.stringify({ type: 'sources', data: docs }));
return this.processDocs(docs);
}
},
{
name: 'summarize',
description: 'This tool can be used to summarize URL(s)',
schema: summarizeToolInputSchema,
},
);
return [searchTool, summarizeTool];
}
async searchAndAnswer(
@ -475,21 +578,94 @@ class MetaSearchAgent implements MetaSearchAgentType {
) {
const emitter = new eventEmitter();
const answeringChain = await this.createAnsweringChain(
const tools = this.getTools({
emitter,
llm,
fileIds,
embeddings,
optimizationMode,
systemInstructions,
);
});
const stream = answeringChain.streamEvents(
const shouldContinue = (state: typeof MessagesAnnotation.State) => {
const lastMessage = state.messages[
state.messages.length - 1
] as AIMessage;
if (lastMessage.tool_calls && lastMessage.tool_calls.length) {
return 'tools';
}
return '__end__';
};
const callTools = async (
state: typeof MessagesAnnotation.State,
): Promise<Partial<typeof MessagesAnnotation.State>> => {
const lastMessage = state.messages[
state.messages.length - 1
] as AIMessage;
const toolResults: BaseMessage[] = [];
if (lastMessage.tool_calls && lastMessage.tool_calls.length) {
await Promise.all(
lastMessage.tool_calls.map(async (t) => {
const toolToCall = tools.find((i) => i.name === t.name);
const result = await toolToCall?.invoke(t.args)!;
toolResults.push(
new ToolMessage({
content: result,
tool_call_id: t.id!,
}),
);
}),
);
}
return {
messages: [...toolResults],
};
};
const boundModel = llm.bindTools?.(tools)!;
const callModel = async (
state: typeof MessagesAnnotation.State,
): Promise<Partial<typeof MessagesAnnotation.State>> => {
const { messages } = state;
const res = await boundModel?.invoke(messages);
return {
messages: [res!],
};
};
const workflow = new StateGraph(MessagesAnnotation)
.addNode('agent', callModel)
.addNode(
'tools',
RunnableLambda.from(callTools).withConfig({
tags: ['nostream'],
}),
)
.addEdge('__start__', 'agent')
.addEdge('tools', 'agent')
.addConditionalEdges('agent', shouldContinue);
const app = workflow.compile();
const filledPrompt = await PromptTemplate.fromTemplate(
this.config.responsePrompt,
).format({
systemInstructions: systemInstructions,
date: Date.now(),
});
const stream = await app.stream(
{
chat_history: history,
query: message,
messages: [['system', filledPrompt], ...history, ['human', message]],
},
{
version: 'v1',
streamMode: 'messages',
},
);

View File

@ -1,8 +1,11 @@
import { BaseMessage } from '@langchain/core/messages';
import { BaseMessage, isAIMessage } from '@langchain/core/messages';
const formatChatHistoryAsString = (history: BaseMessage[]) => {
return history
.map((message) => `${message._getType()}: ${message.content}`)
.map(
(message) =>
`${isAIMessage(message) ? 'AI' : 'User'}: ${message.content}`,
)
.join('\n');
};

856
yarn.lock

File diff suppressed because it is too large Load Diff