Compare commits

..

31 Commits

Author SHA1 Message Date
59ab10110a Merge branch 'master' of https://github.com/HadiCherkaoui/Perplexica into pr/658 2025-03-02 15:35:14 +05:30
10f9cd2f79 feat(app): lint & beautify 2025-03-02 15:35:08 +05:30
82e1dd73b0 add config to ui 2025-03-02 11:00:56 +01:00
728b499281 Merge branch 'ItzCrazyKns:master' into master 2025-03-01 18:16:08 +01:00
5a4dafc753 Update yarn.lock 2025-03-01 18:12:40 +01:00
4ac99786f0 Update yarn.lock 2025-03-01 18:11:34 +01:00
1224281278 Update yarn.lock 2025-03-01 18:09:57 +01:00
3daae29a5d Update package.json 2025-03-01 18:05:28 +01:00
50bcaa13f2 Revert "Delete package.json"
This reverts commit 31e4abf068.
2025-03-01 17:58:54 +01:00
31e4abf068 Delete package.json 2025-03-01 17:57:40 +01:00
fd6e701cf0 Delete docs/installation/DEVELOPMENT.md 2025-03-01 17:49:59 +01:00
89880a2555 Delete .dev directory 2025-03-01 17:48:54 +01:00
07776d8699 feat(config): remove key cloak config functions 2025-03-01 22:13:38 +05:30
32fb6ac131 Update config.ts 2025-03-01 14:16:39 +01:00
99137d95e7 Update docker-compose.yaml 2025-03-01 14:15:44 +01:00
490a8db538 Update sample.config.toml 2025-03-01 14:15:21 +01:00
aba702c51b make it possible to configure multiple engines 2025-02-28 15:50:59 +01:00
89a6e7fbb1 Merge pull request #11 from HadiCherkaoui/add-bing
add bing
2025-02-28 15:17:40 +01:00
f19d2e3a97 add bing 2025-02-28 15:16:54 +01:00
4a7ca8fc68 Add keycloak config params to sample config 2025-02-28 15:07:50 +01:00
3d642f2539 fix sample messup 2025-02-28 14:58:16 +01:00
aa91d3bc60 Add config params for keycloak 2025-02-28 14:56:33 +01:00
93c5ed46f6 Merge pull request #9 from HadiCherkaoui/add-bing
Added brave, yacy and changed back the default search engine to searxng
2025-02-28 14:49:58 +01:00
af4b97b766 add yacy 2025-02-28 14:46:24 +01:00
ca86a7e358 add brave as a search engine 2025-02-28 13:11:00 +01:00
99351fc2a6 Added multiple search backends
Add support for multiple search engines/google as a search engine
2025-02-28 09:33:50 +01:00
7a816efc04 fix google pse
i tested everything, also tested if invalid api key and such and regression tested searxng which still works
2025-02-28 09:13:11 +01:00
4d41243108 added multi search engine support (didnt test) WIP 2025-02-27 21:32:26 +01:00
6c218b5fee move searxng.ts and update imports 2025-02-27 18:24:54 +01:00
1c1f31e23a Update .gitignore 2025-02-27 18:21:36 +01:00
5b15bcfe17 Add config for multiple search engines 2025-02-27 18:21:00 +01:00
36 changed files with 1794 additions and 9467 deletions

5
.gitignore vendored
View File

@ -2,6 +2,7 @@
node_modules/
npm-debug.log
yarn-error.log
package-lock.json
# Build output
/.next/
@ -11,7 +12,6 @@ yarn-error.log
# IDE/Editor specific
.vscode/
.idea/
.qodo/
*.iml
# Environment variables
@ -38,3 +38,6 @@ Thumbs.db
# Db
db.sqlite
/searxng
# Dev
docker-compose-dev.yaml

View File

@ -2,7 +2,6 @@
[![Discord](https://dcbadge.vercel.app/api/server/26aArMy8tT?style=flat&compact=true)](https://discord.gg/26aArMy8tT)
![preview](.assets/perplexica-screenshot.png?)
## Table of Contents <!-- omit in toc -->

View File

@ -4,7 +4,7 @@ services:
volumes:
- ./searxng:/etc/searxng:rw
ports:
- 4000:8080
- '4000:8080'
networks:
- perplexica-network
restart: unless-stopped
@ -19,7 +19,7 @@ services:
depends_on:
- searxng
ports:
- 3001:3001
- '3001:3001'
volumes:
- backend-dbstore:/home/perplexica/data
- uploads:/home/perplexica/uploads
@ -41,7 +41,7 @@ services:
depends_on:
- perplexica-backend
ports:
- 3000:3000
- '3000:3000'
networks:
- perplexica-network
restart: unless-stopped

View File

@ -30,8 +30,8 @@
"@iarna/toml": "^2.2.5",
"@langchain/anthropic": "^0.2.3",
"@langchain/community": "^0.2.16",
"@langchain/openai": "^0.0.25",
"@langchain/google-genai": "^0.0.23",
"@langchain/openai": "^0.0.25",
"@xenova/transformers": "^2.17.1",
"axios": "^1.6.8",
"better-sqlite3": "^11.0.0",

View File

@ -3,6 +3,12 @@ PORT = 3001 # Port to run the server on
SIMILARITY_MEASURE = "cosine" # "cosine" or "dot"
KEEP_ALIVE = "5m" # How long to keep Ollama models loaded into memory. (Instead of using -1 use "-1m")
[SEARCH_ENGINE_BACKENDS] # "google" | "searxng" | "bing" | "brave" | "yacy"
SEARCH = "searxng"
IMAGE = "searxng"
VIDEO = "searxng"
NEWS = "searxng"
[MODELS.OPENAI]
API_KEY = ""
@ -15,20 +21,25 @@ API_KEY = ""
[MODELS.GEMINI]
API_KEY = ""
[MODELS.DEEPSEEK]
[MODELS.CUSTOM_OPENAI]
API_KEY = ""
STREAM_DELAY = 5 # Milliseconds between token emissions for reasoning models (higher = slower, 0 = no delay)
API_URL = ""
[MODELS.OLLAMA]
API_URL = "" # Ollama API URL - http://host.docker.internal:11434
[MODELS.LMSTUDIO]
API_URL = "" # LM STUDIO API URL - http://host.docker.internal:1234
[MODELS.CUSTOM_OPENAI]
[SEARCH_ENGINES.GOOGLE]
API_KEY = ""
API_URL = ""
MODEL_NAME = ""
CSE_ID = ""
[API_ENDPOINTS]
SEARXNG = "http://localhost:32768" # SearxNG API URL
[SEARCH_ENGINES.SEARXNG]
ENDPOINT = ""
[SEARCH_ENGINES.BING]
SUBSCRIPTION_KEY = ""
[SEARCH_ENGINES.BRAVE]
API_KEY = ""
[SEARCH_ENGINES.YACY]
ENDPOINT = ""

View File

@ -15,3 +15,5 @@ server:
engines:
- name: wolframalpha
disabled: false
- name: qwant
disabled: true

View File

@ -7,7 +7,12 @@ import { PromptTemplate } from '@langchain/core/prompts';
import formatChatHistoryAsString from '../utils/formatHistory';
import { BaseMessage } from '@langchain/core/messages';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { searchSearxng } from '../lib/searxng';
import { searchSearxng } from '../lib/searchEngines/searxng';
import { searchGooglePSE } from '../lib/searchEngines/google_pse';
import { searchBraveAPI } from '../lib/searchEngines/brave';
import { searchYaCy } from '../lib/searchEngines/yacy';
import { searchBingAPI } from '../lib/searchEngines/bing';
import { getImageSearchEngineBackend } from '../config';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
const imageSearchChainPrompt = `
@ -36,6 +41,103 @@ type ImageSearchChainInput = {
query: string;
};
async function performImageSearch(query: string) {
const searchEngine = getImageSearchEngineBackend();
let images = [];
switch (searchEngine) {
case 'google': {
const googleResult = await searchGooglePSE(query);
images = googleResult.results
.map((result) => {
if (result.img_src && result.url && result.title) {
return {
img_src: result.img_src,
url: result.url,
title: result.title,
source: result.displayLink,
};
}
})
.filter(Boolean);
break;
}
case 'searxng': {
const searxResult = await searchSearxng(query, {
engines: ['google images', 'bing images'],
pageno: 1,
});
searxResult.results.forEach((result) => {
if (result.img_src && result.url && result.title) {
images.push({
img_src: result.img_src,
url: result.url,
title: result.title,
});
}
});
break;
}
case 'brave': {
const braveResult = await searchBraveAPI(query);
images = braveResult.results
.map((result) => {
if (result.img_src && result.url && result.title) {
return {
img_src: result.img_src,
url: result.url,
title: result.title,
source: result.url,
};
}
})
.filter(Boolean);
break;
}
case 'yacy': {
const yacyResult = await searchYaCy(query);
images = yacyResult.results
.map((result) => {
if (result.img_src && result.url && result.title) {
return {
img_src: result.img_src,
url: result.url,
title: result.title,
source: result.url,
};
}
})
.filter(Boolean);
break;
}
case 'bing': {
const bingResult = await searchBingAPI(query);
images = bingResult.results
.map((result) => {
if (result.img_src && result.url && result.title) {
return {
img_src: result.img_src,
url: result.url,
title: result.title,
source: result.url,
};
}
})
.filter(Boolean);
break;
}
default:
throw new Error(`Unknown search engine ${searchEngine}`);
}
return images;
}
const strParser = new StringOutputParser();
const createImageSearchChain = (llm: BaseChatModel) => {
@ -52,22 +154,7 @@ const createImageSearchChain = (llm: BaseChatModel) => {
llm,
strParser,
RunnableLambda.from(async (input: string) => {
const res = await searchSearxng(input, {
engines: ['bing images', 'google images'],
});
const images = [];
res.results.forEach((result) => {
if (result.img_src && result.url && result.title) {
images.push({
img_src: result.img_src,
url: result.url,
title: result.title,
});
}
});
const images = await performImageSearch(input);
return images.slice(0, 10);
}),
]);

View File

@ -7,26 +7,30 @@ import { PromptTemplate } from '@langchain/core/prompts';
import formatChatHistoryAsString from '../utils/formatHistory';
import { BaseMessage } from '@langchain/core/messages';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { searchSearxng } from '../lib/searxng';
import { searchSearxng } from '../lib/searchEngines/searxng';
import { searchGooglePSE } from '../lib/searchEngines/google_pse';
import { searchBraveAPI } from '../lib/searchEngines/brave';
import { searchBingAPI } from '../lib/searchEngines/bing';
import { getVideoSearchEngineBackend } from '../config';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
const VideoSearchChainPrompt = `
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
Example:
1. Follow up question: How does a car work?
Rephrased: How does a car work?
2. Follow up question: What is the theory of relativity?
Rephrased: What is theory of relativity
3. Follow up question: How does an AC work?
Rephrased: How does an AC work
Conversation:
{chat_history}
Follow up question: {query}
Rephrased question:
`;
@ -38,6 +42,102 @@ type VideoSearchChainInput = {
const strParser = new StringOutputParser();
async function performVideoSearch(query: string) {
const searchEngine = getVideoSearchEngineBackend();
const youtubeQuery = `${query} site:youtube.com`;
let videos = [];
switch (searchEngine) {
case 'google': {
const googleResult = await searchGooglePSE(youtubeQuery);
googleResult.results.forEach((result) => {
// Use .results instead of .originalres
if (result.img_src && result.url && result.title) {
const videoId = new URL(result.url).searchParams.get('v');
videos.push({
img_src: result.img_src,
url: result.url,
title: result.title,
iframe_src: videoId
? `https://www.youtube.com/embed/${videoId}`
: null,
});
}
});
break;
}
case 'searxng': {
const searxResult = await searchSearxng(query, {
engines: ['youtube'],
});
searxResult.results.forEach((result) => {
if (
result.thumbnail &&
result.url &&
result.title &&
result.iframe_src
) {
videos.push({
img_src: result.thumbnail,
url: result.url,
title: result.title,
iframe_src: result.iframe_src,
});
}
});
break;
}
case 'brave': {
const braveResult = await searchBraveAPI(youtubeQuery);
braveResult.results.forEach((result) => {
if (result.img_src && result.url && result.title) {
const videoId = new URL(result.url).searchParams.get('v');
videos.push({
img_src: result.img_src,
url: result.url,
title: result.title,
iframe_src: videoId
? `https://www.youtube.com/embed/${videoId}`
: null,
});
}
});
break;
}
case 'yacy': {
console.log('Not available for yacy');
videos = [];
break;
}
case 'bing': {
const bingResult = await searchBingAPI(youtubeQuery);
bingResult.results.forEach((result) => {
if (result.img_src && result.url && result.title) {
const videoId = new URL(result.url).searchParams.get('v');
videos.push({
img_src: result.img_src,
url: result.url,
title: result.title,
iframe_src: videoId
? `https://www.youtube.com/embed/${videoId}`
: null,
});
}
});
break;
}
default:
throw new Error(`Unknown search engine ${searchEngine}`);
}
return videos;
}
const createVideoSearchChain = (llm: BaseChatModel) => {
return RunnableSequence.from([
RunnableMap.from({
@ -52,28 +152,7 @@ const createVideoSearchChain = (llm: BaseChatModel) => {
llm,
strParser,
RunnableLambda.from(async (input: string) => {
const res = await searchSearxng(input, {
engines: ['youtube'],
});
const videos = [];
res.results.forEach((result) => {
if (
result.thumbnail &&
result.url &&
result.title &&
result.iframe_src
) {
videos.push({
img_src: result.thumbnail,
url: result.url,
title: result.title,
iframe_src: result.iframe_src,
});
}
});
const videos = await performVideoSearch(input);
return videos.slice(0, 10);
}),
]);

View File

@ -10,6 +10,12 @@ interface Config {
SIMILARITY_MEASURE: string;
KEEP_ALIVE: string;
};
SEARCH_ENGINE_BACKENDS: {
SEARCH: string;
IMAGE: string;
VIDEO: string;
NEWS: string;
};
MODELS: {
OPENAI: {
API_KEY: string;
@ -23,24 +29,32 @@ interface Config {
GEMINI: {
API_KEY: string;
};
DEEPSEEK: {
API_KEY: string;
STREAM_DELAY: number;
};
OLLAMA: {
API_URL: string;
};
LMSTUDIO: {
API_URL: string;
};
CUSTOM_OPENAI: {
API_URL: string;
API_KEY: string;
MODEL_NAME: string;
};
};
API_ENDPOINTS: {
SEARXNG: string;
SEARCH_ENGINES: {
GOOGLE: {
API_KEY: string;
CSE_ID: string;
};
SEARXNG: {
ENDPOINT: string;
};
BING: {
SUBSCRIPTION_KEY: string;
};
BRAVE: {
API_KEY: string;
};
YACY: {
ENDPOINT: string;
};
};
}
@ -68,18 +82,35 @@ export const getAnthropicApiKey = () => loadConfig().MODELS.ANTHROPIC.API_KEY;
export const getGeminiApiKey = () => loadConfig().MODELS.GEMINI.API_KEY;
export const getDeepseekApiKey = () => loadConfig().MODELS.DEEPSEEK.API_KEY;
export const getSearchEngineBackend = () =>
loadConfig().SEARCH_ENGINE_BACKENDS.SEARCH;
export const getDeepseekStreamDelay = () =>
loadConfig().MODELS.DEEPSEEK.STREAM_DELAY || 5; // Default to 5ms if not specified
export const getImageSearchEngineBackend = () =>
loadConfig().SEARCH_ENGINE_BACKENDS.IMAGE || getSearchEngineBackend();
export const getVideoSearchEngineBackend = () =>
loadConfig().SEARCH_ENGINE_BACKENDS.VIDEO || getSearchEngineBackend();
export const getNewsSearchEngineBackend = () =>
loadConfig().SEARCH_ENGINE_BACKENDS.NEWS || getSearchEngineBackend();
export const getGoogleApiKey = () => loadConfig().SEARCH_ENGINES.GOOGLE.API_KEY;
export const getGoogleCseId = () => loadConfig().SEARCH_ENGINES.GOOGLE.CSE_ID;
export const getBraveApiKey = () => loadConfig().SEARCH_ENGINES.BRAVE.API_KEY;
export const getBingSubscriptionKey = () =>
loadConfig().SEARCH_ENGINES.BING.SUBSCRIPTION_KEY;
export const getYacyJsonEndpoint = () =>
loadConfig().SEARCH_ENGINES.YACY.ENDPOINT;
export const getSearxngApiEndpoint = () =>
process.env.SEARXNG_API_URL || loadConfig().API_ENDPOINTS.SEARXNG;
process.env.SEARXNG_API_URL || loadConfig().SEARCH_ENGINES.SEARXNG.ENDPOINT;
export const getOllamaApiEndpoint = () => loadConfig().MODELS.OLLAMA.API_URL;
export const getLMStudioApiEndpoint = () => loadConfig().MODELS.LMSTUDIO.API_URL;
export const getCustomOpenaiApiKey = () =>
loadConfig().MODELS.CUSTOM_OPENAI.API_KEY;

View File

@ -26,16 +26,3 @@ export const chats = sqliteTable('chats', {
.$type<File[]>()
.default(sql`'[]'`),
});
export const userPreferences = sqliteTable('user_preferences', {
id: integer('id').primaryKey(),
userId: text('user_id').notNull(),
categories: text('categories', { mode: 'json' })
.$type<string[]>()
.default(sql`'["AI", "Technology"]'`),
languages: text('languages', { mode: 'json' }) // Changed from 'language' to 'languages'
.$type<string[]>()
.default(sql`'[]'`), // Empty array means "All Languages"
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@ -1,89 +0,0 @@
import { ReasoningChatModel } from '../reasoningChatModel';
import { ChatOpenAI } from '@langchain/openai';
import logger from '../../utils/logger';
import { getDeepseekApiKey, getDeepseekStreamDelay } from '../../config';
import axios from 'axios';
interface DeepSeekModel {
id: string;
object: string;
owned_by: string;
}
interface ModelListResponse {
object: 'list';
data: DeepSeekModel[];
}
interface ChatModelConfig {
displayName: string;
model: ReasoningChatModel | ChatOpenAI;
}
const REASONING_MODELS = ['deepseek-reasoner'];
const MODEL_DISPLAY_NAMES: Record<string, string> = {
'deepseek-reasoner': 'DeepSeek R1',
'deepseek-chat': 'DeepSeek V3'
};
export const loadDeepSeekChatModels = async (): Promise<Record<string, ChatModelConfig>> => {
const deepSeekEndpoint = 'https://api.deepseek.com';
const apiKey = getDeepseekApiKey();
if (!apiKey) return {};
if (!deepSeekEndpoint || !apiKey) {
return {};
}
try {
const response = await axios.get<{ data: DeepSeekModel[] }>(`${deepSeekEndpoint}/models`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
});
const deepSeekModels = response.data.data;
const chatModels = deepSeekModels.reduce<Record<string, ChatModelConfig>>((acc, model) => {
if (model.id in MODEL_DISPLAY_NAMES) {
// Use ReasoningChatModel for models that need reasoning capabilities
if (REASONING_MODELS.includes(model.id)) {
const streamDelay = getDeepseekStreamDelay();
acc[model.id] = {
displayName: MODEL_DISPLAY_NAMES[model.id],
model: new ReasoningChatModel({
apiKey,
baseURL: deepSeekEndpoint,
modelName: model.id,
temperature: 0.7,
streamDelay // Use configured stream delay from config
}),
};
} else {
// Use standard ChatOpenAI for other models
acc[model.id] = {
displayName: MODEL_DISPLAY_NAMES[model.id],
model: new ChatOpenAI({
openAIApiKey: apiKey,
configuration: {
baseURL: deepSeekEndpoint,
},
modelName: model.id,
temperature: 0.7,
}),
};
}
}
return acc;
}, {});
return chatModels;
} catch (err) {
logger.error(`Error loading DeepSeek models: ${String(err)}`);
return {};
}
};

View File

@ -4,8 +4,6 @@ import { loadOpenAIChatModels, loadOpenAIEmbeddingsModels } from './openai';
import { loadAnthropicChatModels } from './anthropic';
import { loadTransformersEmbeddingsModels } from './transformers';
import { loadGeminiChatModels, loadGeminiEmbeddingsModels } from './gemini';
import { loadDeepSeekChatModels } from './deepseek';
import { loadLMStudioChatModels, loadLMStudioEmbeddingsModels } from './lmstudio';
import {
getCustomOpenaiApiKey,
getCustomOpenaiApiUrl,
@ -19,8 +17,6 @@ const chatModelProviders = {
ollama: loadOllamaChatModels,
anthropic: loadAnthropicChatModels,
gemini: loadGeminiChatModels,
deepseek: loadDeepSeekChatModels,
lm_studio: loadLMStudioChatModels,
};
const embeddingModelProviders = {
@ -28,7 +24,6 @@ const embeddingModelProviders = {
local: loadTransformersEmbeddingsModels,
ollama: loadOllamaEmbeddingsModels,
gemini: loadGeminiEmbeddingsModels,
lm_studio: loadLMStudioEmbeddingsModels,
};
export const getAvailableChatModelProviders = async () => {

View File

@ -1,96 +0,0 @@
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { getLMStudioApiEndpoint, getKeepAlive } from '../../config';
import logger from '../../utils/logger';
import axios from 'axios';
interface LMStudioModel {
id: string;
name?: string;
}
const ensureV1Endpoint = (endpoint: string): string =>
endpoint.endsWith('/v1') ? endpoint : `${endpoint}/v1`;
const checkServerAvailability = async (endpoint: string): Promise<boolean> => {
try {
const keepAlive = getKeepAlive();
await axios.get(`${ensureV1Endpoint(endpoint)}/models`, {
timeout: parseInt(keepAlive) * 1000 || 5000,
headers: { 'Content-Type': 'application/json' },
});
return true;
} catch {
return false;
}
};
export const loadLMStudioChatModels = async () => {
const endpoint = getLMStudioApiEndpoint();
const keepAlive = getKeepAlive();
if (!endpoint) return {};
if (!await checkServerAvailability(endpoint)) return {};
try {
const response = await axios.get(`${ensureV1Endpoint(endpoint)}/models`, {
timeout: parseInt(keepAlive) * 1000 || 5000,
headers: { 'Content-Type': 'application/json' },
});
const chatModels = response.data.data.reduce((acc: Record<string, any>, model: LMStudioModel) => {
acc[model.id] = {
displayName: model.name || model.id,
model: new ChatOpenAI({
openAIApiKey: 'lm-studio',
configuration: {
baseURL: ensureV1Endpoint(endpoint),
},
modelName: model.id,
temperature: 0.7,
streaming: true,
maxRetries: 3
}),
};
return acc;
}, {});
return chatModels;
} catch (err) {
logger.error(`Error loading LM Studio models: ${err}`);
return {};
}
};
export const loadLMStudioEmbeddingsModels = async () => {
const endpoint = getLMStudioApiEndpoint();
const keepAlive = getKeepAlive();
if (!endpoint) return {};
if (!await checkServerAvailability(endpoint)) return {};
try {
const response = await axios.get(`${ensureV1Endpoint(endpoint)}/models`, {
timeout: parseInt(keepAlive) * 1000 || 5000,
headers: { 'Content-Type': 'application/json' },
});
const embeddingsModels = response.data.data.reduce((acc: Record<string, any>, model: LMStudioModel) => {
acc[model.id] = {
displayName: model.name || model.id,
model: new OpenAIEmbeddings({
openAIApiKey: 'lm-studio',
configuration: {
baseURL: ensureV1Endpoint(endpoint),
},
modelName: model.id,
}),
};
return acc;
}, {});
return embeddingsModels;
} catch (err) {
logger.error(`Error loading LM Studio embeddings model: ${err}`);
return {};
}
};

View File

@ -1,278 +0,0 @@
import { BaseChatModel, BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import { AIMessage, AIMessageChunk, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
import { ChatResult, ChatGenerationChunk } from '@langchain/core/outputs';
import axios from 'axios';
import { BaseChatModelParams } from '@langchain/core/language_models/chat_models';
interface ReasoningChatModelParams extends BaseChatModelParams {
apiKey: string;
baseURL: string;
modelName: string;
temperature?: number;
max_tokens?: number;
top_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
streamDelay?: number; // Add this parameter for controlling stream delay
}
export class ReasoningChatModel extends BaseChatModel<BaseChatModelCallOptions & { stream?: boolean }> {
private apiKey: string;
private baseURL: string;
private modelName: string;
private temperature: number;
private maxTokens: number;
private topP: number;
private frequencyPenalty: number;
private presencePenalty: number;
private streamDelay: number;
constructor(params: ReasoningChatModelParams) {
super(params);
this.apiKey = params.apiKey;
this.baseURL = params.baseURL;
this.modelName = params.modelName;
this.temperature = params.temperature ?? 0.7;
this.maxTokens = params.max_tokens ?? 8192;
this.topP = params.top_p ?? 1;
this.frequencyPenalty = params.frequency_penalty ?? 0;
this.presencePenalty = params.presence_penalty ?? 0;
this.streamDelay = params.streamDelay ?? 0; // Default to no delay
}
async _generate(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun
): Promise<ChatResult> {
const formattedMessages = messages.map(msg => ({
role: this.getRole(msg),
content: msg.content.toString(),
}));
const response = await this.callAPI(formattedMessages, options.stream);
if (options.stream) {
return this.processStreamingResponse(response, messages, options, runManager);
} else {
const choice = response.data.choices[0];
let content = choice.message.content || '';
if (choice.message.reasoning_content) {
content = `<think>\n${choice.message.reasoning_content}\n</think>\n\n${content}`;
}
// Report usage stats if available
if (response.data.usage && runManager) {
runManager.handleLLMEnd({
generations: [],
llmOutput: {
tokenUsage: {
completionTokens: response.data.usage.completion_tokens,
promptTokens: response.data.usage.prompt_tokens,
totalTokens: response.data.usage.total_tokens
}
}
});
}
return {
generations: [
{
text: content,
message: new AIMessage(content),
},
],
};
}
}
private getRole(msg: BaseMessage): string {
if (msg instanceof SystemMessage) return 'system';
if (msg instanceof HumanMessage) return 'user';
if (msg instanceof AIMessage) return 'assistant';
return 'user'; // Default to user
}
private async callAPI(messages: Array<{ role: string; content: string }>, streaming?: boolean) {
return axios.post(
`${this.baseURL}/chat/completions`,
{
messages,
model: this.modelName,
stream: streaming,
temperature: this.temperature,
max_tokens: this.maxTokens,
top_p: this.topP,
frequency_penalty: this.frequencyPenalty,
presence_penalty: this.presencePenalty,
response_format: { type: 'text' },
...(streaming && {
stream_options: {
include_usage: true
}
})
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
responseType: streaming ? 'text' : 'json',
}
);
}
public async *_streamResponseChunks(messages: BaseMessage[], options: this['ParsedCallOptions'], runManager?: CallbackManagerForLLMRun) {
const response = await this.callAPI(messages.map(msg => ({
role: this.getRole(msg),
content: msg.content.toString(),
})), true);
let thinkState = -1; // -1: not started, 0: thinking, 1: answered
let currentContent = '';
// Split the response into lines
const lines = response.data.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const jsonStr = line.slice(6);
if (jsonStr === '[DONE]') break;
try {
const chunk = JSON.parse(jsonStr);
const delta = chunk.choices[0].delta;
// Handle usage stats in final chunk
if (chunk.usage && !chunk.choices?.length) {
runManager?.handleLLMEnd?.({
generations: [],
llmOutput: {
tokenUsage: {
completionTokens: chunk.usage.completion_tokens,
promptTokens: chunk.usage.prompt_tokens,
totalTokens: chunk.usage.total_tokens
}
}
});
continue;
}
// Handle reasoning content
if (delta.reasoning_content) {
if (thinkState === -1) {
thinkState = 0;
const startTag = '<think>\n';
currentContent += startTag;
runManager?.handleLLMNewToken(startTag);
const chunk = new ChatGenerationChunk({
text: startTag,
message: new AIMessageChunk(startTag),
generationInfo: {}
});
// Add configurable delay before yielding the chunk
if (this.streamDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.streamDelay));
}
yield chunk;
}
currentContent += delta.reasoning_content;
runManager?.handleLLMNewToken(delta.reasoning_content);
const chunk = new ChatGenerationChunk({
text: delta.reasoning_content,
message: new AIMessageChunk(delta.reasoning_content),
generationInfo: {}
});
// Add configurable delay before yielding the chunk
if (this.streamDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.streamDelay));
}
yield chunk;
}
// Handle regular content
if (delta.content) {
if (thinkState === 0) {
thinkState = 1;
const endTag = '\n</think>\n\n';
currentContent += endTag;
runManager?.handleLLMNewToken(endTag);
const chunk = new ChatGenerationChunk({
text: endTag,
message: new AIMessageChunk(endTag),
generationInfo: {}
});
// Add configurable delay before yielding the chunk
if (this.streamDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.streamDelay));
}
yield chunk;
}
currentContent += delta.content;
runManager?.handleLLMNewToken(delta.content);
const chunk = new ChatGenerationChunk({
text: delta.content,
message: new AIMessageChunk(delta.content),
generationInfo: {}
});
// Add configurable delay before yielding the chunk
if (this.streamDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.streamDelay));
}
yield chunk;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to parse chunk';
console.error(`Streaming error: ${errorMessage}`);
if (error instanceof Error && error.message.includes('DeepSeek API Error')) {
throw error;
}
}
}
// Handle any unclosed think block
if (thinkState === 0) {
const endTag = '\n</think>\n\n';
currentContent += endTag;
runManager?.handleLLMNewToken(endTag);
const chunk = new ChatGenerationChunk({
text: endTag,
message: new AIMessageChunk(endTag),
generationInfo: {}
});
// Add configurable delay before yielding the chunk
if (this.streamDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.streamDelay));
}
yield chunk;
}
}
private async processStreamingResponse(response: any, messages: BaseMessage[], options: this['ParsedCallOptions'], runManager?: CallbackManagerForLLMRun): Promise<ChatResult> {
let accumulatedContent = '';
for await (const chunk of this._streamResponseChunks(messages, options, runManager)) {
accumulatedContent += chunk.message.content;
}
return {
generations: [
{
text: accumulatedContent,
message: new AIMessage(accumulatedContent),
},
],
};
}
_llmType(): string {
return 'reasoning';
}
}

View File

@ -0,0 +1,105 @@
import axios from 'axios';
import { getBingSubscriptionKey } from '../../config';
interface BingAPISearchResult {
_type: string;
name: string;
url: string;
displayUrl: string;
snippet?: string;
dateLastCrawled?: string;
thumbnailUrl?: string;
contentUrl?: string;
hostPageUrl?: string;
width?: number;
height?: number;
accentColor?: string;
contentSize?: string;
datePublished?: string;
encodingFormat?: string;
hostPageDisplayUrl?: string;
id?: string;
isLicensed?: boolean;
isFamilyFriendly?: boolean;
language?: string;
mediaUrl?: string;
motionThumbnailUrl?: string;
publisher?: string;
viewCount?: number;
webSearchUrl?: string;
primaryImageOfPage?: {
thumbnailUrl?: string;
width?: number;
height?: number;
};
video?: {
allowHttpsEmbed?: boolean;
embedHtml?: string;
allowMobileEmbed?: boolean;
viewCount?: number;
duration?: string;
};
image?: {
thumbnail?: {
contentUrl?: string;
width?: number;
height?: number;
};
imageInsightsToken?: string;
imageId?: string;
};
}
export const searchBingAPI = async (query: string) => {
try {
const bingApiKey = await getBingSubscriptionKey();
const url = new URL(`https://api.cognitive.microsoft.com/bing/v7.0/search`);
url.searchParams.append('q', query);
url.searchParams.append('responseFilter', 'Webpages,Images,Videos');
const res = await axios.get(url.toString(), {
headers: {
'Ocp-Apim-Subscription-Key': bingApiKey,
Accept: 'application/json',
},
});
if (res.data.error) {
throw new Error(`Bing API Error: ${res.data.error.message}`);
}
const originalres = res.data;
// Extract web, image, and video results
const webResults = originalres.webPages?.value || [];
const imageResults = originalres.images?.value || [];
const videoResults = originalres.videos?.value || [];
const results = webResults.map((item: BingAPISearchResult) => ({
title: item.name,
url: item.url,
content: item.snippet,
img_src:
item.primaryImageOfPage?.thumbnailUrl ||
imageResults.find((img: any) => img.hostPageUrl === item.url)
?.thumbnailUrl ||
videoResults.find((vid: any) => vid.hostPageUrl === item.url)
?.thumbnailUrl,
...(item.video && {
videoData: {
duration: item.video.duration,
embedUrl: item.video.embedHtml?.match(/src="(.*?)"/)?.[1],
},
publisher: item.publisher,
datePublished: item.datePublished,
}),
}));
return { results, originalres };
} catch (error) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message || 'Unknown error';
throw new Error(`Bing API Error: ${errorMessage}`);
}
};

View File

@ -0,0 +1,102 @@
import axios from 'axios';
import { getBraveApiKey } from '../../config';
interface BraveSearchResult {
title: string;
url: string;
content?: string;
img_src?: string;
age?: string;
family_friendly?: boolean;
language?: string;
video?: {
embedUrl?: string;
duration?: string;
};
rating?: {
value: number;
scale: number;
};
products?: Array<{
name: string;
price?: string;
}>;
recipe?: {
ingredients?: string[];
cookTime?: string;
};
meta?: {
fetched?: string;
lastCrawled?: string;
};
}
export const searchBraveAPI = async (
query: string,
numResults: number = 20,
): Promise<{ results: BraveSearchResult[]; originalres: any }> => {
try {
const braveApiKey = await getBraveApiKey();
const url = new URL(`https://api.search.brave.com/res/v1/web/search`);
url.searchParams.append('q', query);
url.searchParams.append('count', numResults.toString());
const res = await axios.get(url.toString(), {
headers: {
'X-Subscription-Token': braveApiKey,
Accept: 'application/json',
},
});
if (res.data.error) {
throw new Error(`Brave API Error: ${res.data.error.message}`);
}
const originalres = res.data;
const webResults = originalres.web?.results || [];
const results: BraveSearchResult[] = webResults.map((item: any) => ({
title: item.title,
url: item.url,
content: item.description,
img_src: item.thumbnail?.src || item.deep_results?.images?.[0]?.src,
age: item.age,
family_friendly: item.family_friendly,
language: item.language,
video: item.video
? {
embedUrl: item.video.embed_url,
duration: item.video.duration,
}
: undefined,
rating: item.rating
? {
value: item.rating.value,
scale: item.rating.scale_max,
}
: undefined,
products: item.deep_results?.product_cluster?.map((p: any) => ({
name: p.name,
price: p.price,
})),
recipe: item.recipe
? {
ingredients: item.recipe.ingredients,
cookTime: item.recipe.cook_time,
}
: undefined,
meta: {
fetched: item.meta?.fetched,
lastCrawled: item.meta?.last_crawled,
},
}));
return { results, originalres };
} catch (error) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message || 'Unknown error';
throw new Error(`Brave API Error: ${errorMessage}`);
}
};

View File

@ -0,0 +1,85 @@
import axios from 'axios';
import { getGoogleApiKey, getGoogleCseId } from '../../config';
interface GooglePSESearchResult {
kind: string;
title: string;
htmlTitle: string;
link: string;
displayLink: string;
snippet?: string;
htmlSnippet?: string;
cacheId?: string;
formattedUrl: string;
htmlFormattedUrl: string;
pagemap?: {
videoobject: any;
cse_thumbnail?: Array<{
src: string;
width: string;
height: string;
}>;
metatags?: Array<{
[key: string]: string;
author?: string;
}>;
cse_image?: Array<{
src: string;
}>;
};
fileFormat?: string;
image?: {
contextLink: string;
thumbnailLink: string;
};
mime?: string;
labels?: Array<{
name: string;
displayName: string;
}>;
}
export const searchGooglePSE = async (query: string) => {
try {
const [googleApiKey, googleCseID] = await Promise.all([
getGoogleApiKey(),
getGoogleCseId(),
]);
const url = new URL(`https://www.googleapis.com/customsearch/v1`);
url.searchParams.append('q', query);
url.searchParams.append('cx', googleCseID);
url.searchParams.append('key', googleApiKey);
const res = await axios.get(url.toString());
if (res.data.error) {
throw new Error(`Google PSE Error: ${res.data.error.message}`);
}
const originalres = res.data.items;
const results = originalres.map((item: GooglePSESearchResult) => ({
title: item.title,
url: item.link,
content: item.snippet,
img_src:
item.pagemap?.cse_image?.[0]?.src ||
item.pagemap?.cse_thumbnail?.[0]?.src ||
item.image?.thumbnailLink,
...(item.pagemap?.videoobject?.[0] && {
videoData: {
duration: item.pagemap.videoobject[0].duration,
embedUrl: item.pagemap.videoobject[0].embedurl,
},
}),
}));
return { results, originalres };
} catch (error) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message || 'Unknown error';
throw new Error(`Google PSE Error: ${errorMessage}`);
}
};

View File

@ -1,14 +1,14 @@
import axios from 'axios';
import { getSearxngApiEndpoint } from '../config';
import { getSearxngApiEndpoint } from '../../config';
export interface SearxngSearchOptions {
interface SearxngSearchOptions {
categories?: string[];
engines?: string[];
language?: string;
pageno?: number;
}
export interface SearxngSearchResult {
interface SearxngSearchResult {
title: string;
url: string;
img_src?: string;

View File

@ -0,0 +1,79 @@
import axios from 'axios';
import { getYacyJsonEndpoint } from '../../config';
interface YaCySearchResult {
channels: {
title: string;
description: string;
link: string;
image: {
url: string;
title: string;
link: string;
};
startIndex: string;
itemsPerPage: string;
searchTerms: string;
items: {
title: string;
link: string;
code: string;
description: string;
pubDate: string;
image?: string;
size: string;
sizename: string;
guid: string;
faviconUrl: string;
host: string;
path: string;
file: string;
urlhash: string;
ranking: string;
}[];
navigation: {
facetname: string;
displayname: string;
type: string;
min: string;
max: string;
mean: string;
elements: {
name: string;
count: string;
modifier: string;
url: string;
}[];
}[];
}[];
}
export const searchYaCy = async (query: string, numResults: number = 20) => {
try {
const yacyBaseUrl = getYacyJsonEndpoint();
const url = new URL(`${yacyBaseUrl}/yacysearch.json`);
url.searchParams.append('query', query);
url.searchParams.append('count', numResults.toString());
const res = await axios.get(url.toString());
const originalres = res.data as YaCySearchResult;
const results = originalres.channels[0].items.map((item) => ({
title: item.title,
url: item.link,
content: item.description,
img_src: item.image || null,
pubDate: item.pubDate,
host: item.host,
}));
return { results, originalres };
} catch (error) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message || 'Unknown error';
throw new Error(`YaCy Error: ${errorMessage}`);
}
};

View File

@ -9,11 +9,20 @@ import {
getAnthropicApiKey,
getGeminiApiKey,
getOpenaiApiKey,
getDeepseekApiKey,
updateConfig,
getCustomOpenaiApiUrl,
getCustomOpenaiApiKey,
getCustomOpenaiModelName,
getSearchEngineBackend,
getImageSearchEngineBackend,
getVideoSearchEngineBackend,
getNewsSearchEngineBackend,
getSearxngApiEndpoint,
getGoogleApiKey,
getGoogleCseId,
getBingSubscriptionKey,
getBraveApiKey,
getYacyJsonEndpoint,
} from '../config';
import logger from '../utils/logger';
@ -58,10 +67,24 @@ router.get('/', async (_, res) => {
config['anthropicApiKey'] = getAnthropicApiKey();
config['groqApiKey'] = getGroqApiKey();
config['geminiApiKey'] = getGeminiApiKey();
config['deepseekApiKey'] = getDeepseekApiKey();
config['customOpenaiApiUrl'] = getCustomOpenaiApiUrl();
config['customOpenaiApiKey'] = getCustomOpenaiApiKey();
config['customOpenaiModelName'] = getCustomOpenaiModelName();
// Add search engine configuration
config['searchEngineBackends'] = {
search: getSearchEngineBackend(),
image: getImageSearchEngineBackend(),
video: getVideoSearchEngineBackend(),
news: getNewsSearchEngineBackend(),
};
config['searxngEndpoint'] = getSearxngApiEndpoint();
config['googleApiKey'] = getGoogleApiKey();
config['googleCseId'] = getGoogleCseId();
config['bingSubscriptionKey'] = getBingSubscriptionKey();
config['braveApiKey'] = getBraveApiKey();
config['yacyEndpoint'] = getYacyJsonEndpoint();
res.status(200).json(config);
} catch (err: any) {
@ -87,9 +110,6 @@ router.post('/', async (req, res) => {
GEMINI: {
API_KEY: config.geminiApiKey,
},
DEEPSEEK: {
API_KEY: config.deepseekApiKey,
},
OLLAMA: {
API_URL: config.ollamaApiUrl,
},
@ -99,6 +119,30 @@ router.post('/', async (req, res) => {
MODEL_NAME: config.customOpenaiModelName,
},
},
SEARCH_ENGINE_BACKENDS: config.searchEngineBackends ? {
SEARCH: config.searchEngineBackends.search,
IMAGE: config.searchEngineBackends.image,
VIDEO: config.searchEngineBackends.video,
NEWS: config.searchEngineBackends.news,
} : undefined,
SEARCH_ENGINES: {
GOOGLE: {
API_KEY: config.googleApiKey,
CSE_ID: config.googleCseId,
},
SEARXNG: {
ENDPOINT: config.searxngEndpoint,
},
BING: {
SUBSCRIPTION_KEY: config.bingSubscriptionKey,
},
BRAVE: {
API_KEY: config.braveApiKey,
},
YACY: {
ENDPOINT: config.yacyEndpoint,
},
},
};
updateConfig(updatedConfig);

View File

@ -1,207 +1,125 @@
import express from 'express';
import { searchSearxng, SearxngSearchOptions } from '../lib/searxng';
import { searchSearxng } from '../lib/searchEngines/searxng';
import { searchGooglePSE } from '../lib/searchEngines/google_pse';
import { searchBraveAPI } from '../lib/searchEngines/brave';
import { searchYaCy } from '../lib/searchEngines/yacy';
import { searchBingAPI } from '../lib/searchEngines/bing';
import { getNewsSearchEngineBackend } from '../config';
import logger from '../utils/logger';
import db from '../db';
import { userPreferences } from '../db/schema';
import { eq } from 'drizzle-orm';
const router = express.Router();
// Helper function to get search queries for a category
const getSearchQueriesForCategory = (category: string): { site: string, keyword: string }[] => {
const categories: Record<string, { site: string, keyword: string }[]> = {
'Technology': [
{ site: 'techcrunch.com', keyword: 'tech' },
{ site: 'wired.com', keyword: 'technology' },
{ site: 'theverge.com', keyword: 'tech' },
{ site: 'arstechnica.com', keyword: 'technology' },
{ site: 'thenextweb.com', keyword: 'tech' }
],
'AI': [
{ site: 'ai.googleblog.com', keyword: 'AI' },
{ site: 'openai.com/blog', keyword: 'AI' },
{ site: 'venturebeat.com', keyword: 'artificial intelligence' },
{ site: 'techcrunch.com', keyword: 'artificial intelligence' },
{ site: 'technologyreview.mit.edu', keyword: 'AI' }
],
'Sports': [
{ site: 'espn.com', keyword: 'sports' },
{ site: 'sports.yahoo.com', keyword: 'sports' },
{ site: 'cbssports.com', keyword: 'sports' },
{ site: 'si.com', keyword: 'sports' },
{ site: 'bleacherreport.com', keyword: 'sports' }
],
'Money': [
{ site: 'bloomberg.com', keyword: 'finance' },
{ site: 'cnbc.com', keyword: 'money' },
{ site: 'wsj.com', keyword: 'finance' },
{ site: 'ft.com', keyword: 'finance' },
{ site: 'economist.com', keyword: 'economy' }
],
'Gaming': [
{ site: 'ign.com', keyword: 'games' },
{ site: 'gamespot.com', keyword: 'gaming' },
{ site: 'polygon.com', keyword: 'games' },
{ site: 'kotaku.com', keyword: 'gaming' },
{ site: 'eurogamer.net', keyword: 'games' }
],
'Entertainment': [
{ site: 'variety.com', keyword: 'entertainment' },
{ site: 'hollywoodreporter.com', keyword: 'entertainment' },
{ site: 'ew.com', keyword: 'entertainment' },
{ site: 'deadline.com', keyword: 'entertainment' },
{ site: 'rollingstone.com', keyword: 'entertainment' }
],
'Art and Culture': [
{ site: 'artnews.com', keyword: 'art' },
{ site: 'artsy.net', keyword: 'art' },
{ site: 'theartnewspaper.com', keyword: 'art' },
{ site: 'nytimes.com/section/arts', keyword: 'culture' },
{ site: 'culturalweekly.com', keyword: 'culture' }
],
'Science': [
{ site: 'scientificamerican.com', keyword: 'science' },
{ site: 'nature.com', keyword: 'science' },
{ site: 'science.org', keyword: 'science' },
{ site: 'newscientist.com', keyword: 'science' },
{ site: 'popsci.com', keyword: 'science' }
],
'Health': [
{ site: 'webmd.com', keyword: 'health' },
{ site: 'health.harvard.edu', keyword: 'health' },
{ site: 'mayoclinic.org', keyword: 'health' },
{ site: 'nih.gov', keyword: 'health' },
{ site: 'medicalnewstoday.com', keyword: 'health' }
],
'Travel': [
{ site: 'travelandleisure.com', keyword: 'travel' },
{ site: 'lonelyplanet.com', keyword: 'travel' },
{ site: 'tripadvisor.com', keyword: 'travel' },
{ site: 'nationalgeographic.com', keyword: 'travel' },
{ site: 'cntraveler.com', keyword: 'travel' }
],
'Current News': [
{ site: 'reuters.com', keyword: 'news' },
{ site: 'apnews.com', keyword: 'news' },
{ site: 'bbc.com', keyword: 'news' },
{ site: 'npr.org', keyword: 'news' },
{ site: 'aljazeera.com', keyword: 'news' }
]
};
return categories[category] || categories['Technology'];
};
async function performSearch(query: string, site: string) {
const searchEngine = getNewsSearchEngineBackend();
switch (searchEngine) {
case 'google': {
const googleResult = await searchGooglePSE(query);
// Helper function to perform searches for a category
const searchCategory = async (category: string, languages?: string[]) => {
const queries = getSearchQueriesForCategory(category);
// If no languages specified or empty array, search all languages
if (!languages || languages.length === 0) {
const searchOptions: SearxngSearchOptions = {
engines: ['bing news'],
pageno: 1,
};
const searchPromises = queries.map(query =>
searchSearxng(`site:${query.site} ${query.keyword}`, searchOptions)
);
const results = await Promise.all(searchPromises);
return results.map(result => result.results).flat();
}
// If languages specified, search each language and combine results
const allResults = [];
for (const language of languages) {
const searchOptions: SearxngSearchOptions = {
engines: ['bing news'],
pageno: 1,
language,
};
const searchPromises = queries.map(query =>
searchSearxng(`site:${query.site} ${query.keyword}`, searchOptions)
);
const results = await Promise.all(searchPromises);
allResults.push(...results.map(result => result.results).flat());
}
return allResults;
};
return googleResult.originalres.map((item) => {
const imageSources = [
item.pagemap?.cse_image?.[0]?.src,
item.pagemap?.cse_thumbnail?.[0]?.src,
item.pagemap?.metatags?.[0]?.['og:image'],
item.pagemap?.metatags?.[0]?.['twitter:image'],
item.pagemap?.metatags?.[0]?.['image'],
].filter(Boolean); // Remove undefined values
return {
title: item.title,
url: item.link,
content: item.snippet,
thumbnail: imageSources[0], // First available image
img_src: imageSources[0], // Same as thumbnail for consistency
iframe_src: null,
author: item.pagemap?.metatags?.[0]?.['og:site_name'] || site,
publishedDate:
item.pagemap?.metatags?.[0]?.['article:published_time'],
};
});
}
case 'searxng': {
const searxResult = await searchSearxng(query, {
engines: ['bing news'],
pageno: 1,
});
return searxResult.results;
}
case 'brave': {
const braveResult = await searchBraveAPI(query);
return braveResult.results.map((item) => ({
title: item.title,
url: item.url,
content: item.content,
thumbnail: item.img_src,
img_src: item.img_src,
iframe_src: null,
author: item.meta?.fetched || site,
publishedDate: item.meta?.lastCrawled,
}));
}
case 'yacy': {
const yacyResult = await searchYaCy(query);
return yacyResult.results.map((item) => ({
title: item.title,
url: item.url,
content: item.content,
thumbnail: item.img_src,
img_src: item.img_src,
iframe_src: null,
author: item?.host || site,
publishedDate: item?.pubDate,
}));
}
case 'bing': {
const bingResult = await searchBingAPI(query);
return bingResult.results.map((item) => ({
title: item.title,
url: item.url,
content: item.content,
thumbnail: item.img_src,
img_src: item.img_src,
iframe_src: null,
author: item?.publisher || site,
publishedDate: item?.datePublished,
}));
}
default:
throw new Error(`Unknown search engine ${searchEngine}`);
}
}
// Main discover route - supports category, preferences, and languages parameters
router.get('/', async (req, res) => {
try {
const category = req.query.category as string;
const preferencesParam = req.query.preferences as string;
const languagesParam = req.query.languages as string;
let languages: string[] = [];
if (languagesParam) {
languages = JSON.parse(languagesParam);
}
let data: any[] = [];
if (category && category !== 'For You') {
// Get news for a specific category
data = await searchCategory(category, languages);
} else if (preferencesParam) {
// Get news based on user preferences
const preferences = JSON.parse(preferencesParam);
const categoryPromises = preferences.map((pref: string) => searchCategory(pref, languages));
const results = await Promise.all(categoryPromises);
data = results.flat();
} else {
// Default behavior with optional language filter
if (languages.length === 0) {
// No language filter
const searchOptions: SearxngSearchOptions = {
engines: ['bing news'],
pageno: 1,
};
// Use improved sources for default searches
data = (
await Promise.all([
searchSearxng('site:techcrunch.com tech', searchOptions),
searchSearxng('site:wired.com technology', searchOptions),
searchSearxng('site:theverge.com tech', searchOptions),
searchSearxng('site:venturebeat.com artificial intelligence', searchOptions),
searchSearxng('site:technologyreview.mit.edu AI', searchOptions),
searchSearxng('site:ai.googleblog.com AI', searchOptions),
])
const queries = [
{ site: 'businessinsider.com', topic: 'AI' },
{ site: 'www.exchangewire.com', topic: 'AI' },
{ site: 'yahoo.com', topic: 'AI' },
{ site: 'businessinsider.com', topic: 'tech' },
{ site: 'www.exchangewire.com', topic: 'tech' },
{ site: 'yahoo.com', topic: 'tech' },
];
const data = (
await Promise.all(
queries.map(async ({ site, topic }) => {
try {
const query = `site:${site} ${topic}`;
return await performSearch(query, site);
} catch (error) {
logger.error(`Error searching ${site}: ${error.message}`);
return [];
}
}),
)
.map((result) => result.results)
.flat();
} else {
// Search each language and combine results
for (const language of languages) {
const searchOptions: SearxngSearchOptions = {
engines: ['bing news'],
pageno: 1,
language,
};
const results = await Promise.all([
searchSearxng('site:techcrunch.com tech', searchOptions),
searchSearxng('site:wired.com technology', searchOptions),
searchSearxng('site:theverge.com tech', searchOptions),
searchSearxng('site:venturebeat.com artificial intelligence', searchOptions),
searchSearxng('site:technologyreview.mit.edu AI', searchOptions),
searchSearxng('site:ai.googleblog.com AI', searchOptions),
]);
data.push(...results.map(result => result.results).flat());
}
}
}
// Shuffle the results
data = data.sort(() => Math.random() - 0.5);
)
.flat()
.sort(() => Math.random() - 0.5)
.filter((item) => item.title && item.url && item.content);
return res.json({ blogs: data });
} catch (err: any) {
@ -210,97 +128,4 @@ router.get('/', async (req, res) => {
}
});
// Get user preferences
router.get('/preferences', async (req, res) => {
try {
// In a real app, you would get the user ID from the session/auth
const userId = req.query.userId as string || 'default-user';
const userPrefs = await db.select().from(userPreferences).where(eq(userPreferences.userId, userId));
if (userPrefs.length === 0) {
// Return default preferences if none exist
return res.json({
categories: ['AI', 'Technology'],
languages: ['en'] // Default to English
});
}
// Handle both old 'language' field and new 'languages' field for backward compatibility
let languages = [];
if ('languages' in userPrefs[0] && userPrefs[0].languages) {
languages = userPrefs[0].languages;
} else if ('language' in userPrefs[0] && userPrefs[0].language) {
// Convert old single language to array
languages = [userPrefs[0].language];
} else {
languages = ['en']; // Default to English
}
return res.json({
categories: userPrefs[0].categories,
languages: languages
});
} catch (err: any) {
logger.error(`Error getting user preferences: ${err.message}`);
return res.status(500).json({ message: 'An error has occurred' });
}
});
// Update user preferences
router.post('/preferences', async (req, res) => {
try {
// In a real app, you would get the user ID from the session/auth
const userId = req.query.userId as string || 'default-user';
const { categories, languages } = req.body;
if (!categories || !Array.isArray(categories)) {
return res.status(400).json({ message: 'Invalid categories format' });
}
if (languages && !Array.isArray(languages)) {
return res.status(400).json({ message: 'Invalid languages format' });
}
const userPrefs = await db.select().from(userPreferences).where(eq(userPreferences.userId, userId));
// Let's use a simpler approach - just use the drizzle ORM as intended
// but handle errors gracefully
try {
if (userPrefs.length === 0) {
// Create new preferences
await db.insert(userPreferences).values({
userId,
categories,
languages: languages || ['en'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
} else {
// Update existing preferences
await db.update(userPreferences)
.set({
categories,
languages: languages || ['en'],
updatedAt: new Date().toISOString()
})
.where(eq(userPreferences.userId, userId));
}
} catch (error) {
// If there's an error (likely due to schema mismatch), log it but don't fail
logger.warn(`Error updating preferences with new schema: ${error.message}`);
logger.warn('Continuing with request despite error');
// We'll just return success anyway since we can't fix the schema issue here
// In a production app, you would want to handle this more gracefully
}
return res.json({ message: 'Preferences updated successfully' });
} catch (err: any) {
logger.error(`Error updating user preferences: ${err.message}`);
return res.status(500).json({ message: 'An error has occurred' });
}
});
export default router;

View File

@ -11,19 +11,23 @@ import {
RunnableMap,
RunnableSequence,
} from '@langchain/core/runnables';
import { BaseMessage, SystemMessage, HumanMessage } from '@langchain/core/messages';
import { BaseMessage } from '@langchain/core/messages';
import { StringOutputParser } from '@langchain/core/output_parsers';
import LineListOutputParser from '../lib/outputParsers/listLineOutputParser';
import LineOutputParser from '../lib/outputParsers/lineOutputParser';
import { getDocumentsFromLinks } from '../utils/documents';
import { Document } from 'langchain/document';
import { searchSearxng } from '../lib/searxng';
import { searchSearxng } from '../lib/searchEngines/searxng';
import { searchGooglePSE } from '../lib/searchEngines/google_pse';
import { searchBingAPI } from '../lib/searchEngines/bing';
import { searchBraveAPI } from '../lib/searchEngines/brave';
import { searchYaCy } from '../lib/searchEngines/yacy';
import { getSearchEngineBackend } from '../config';
import path from 'path';
import fs from 'fs';
import computeSimilarity from '../utils/computeSimilarity';
import formatChatHistoryAsString from '../utils/formatHistory';
import eventEmitter from 'events';
import { getMessageValidator } from '../utils/alternatingMessageValidator';
import { StreamEvent } from '@langchain/core/tracers/log_stream';
import { IterableReadableStream } from '@langchain/core/utils/stream';
@ -133,7 +137,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
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.
@ -204,10 +208,37 @@ class MetaSearchAgent implements MetaSearchAgentType {
return { query: question, docs: docs };
} else {
const res = await searchSearxng(question, {
language: 'en',
engines: this.config.activeEngines,
});
const searchEngine = getSearchEngineBackend();
let res;
switch (searchEngine) {
case 'searxng':
res = await searchSearxng(question, {
language: 'en',
engines: this.config.activeEngines,
});
break;
case 'google':
res = await searchGooglePSE(question);
break;
case 'bing':
res = await searchBingAPI(question);
break;
case 'brave':
res = await searchBraveAPI(question);
break;
case 'yacy':
res = await searchYaCy(question);
break;
default:
throw new Error(`Unknown search engine ${searchEngine}`);
}
if (!res?.results) {
throw new Error(
`No results found for search engine: ${searchEngine}`,
);
}
const documents = res.results.map(
(result) =>
@ -476,41 +507,10 @@ class MetaSearchAgent implements MetaSearchAgentType {
optimizationMode,
);
// Create all messages including system prompt and new query
const allMessages = [
new SystemMessage(this.config.responsePrompt),
...history,
new HumanMessage(message)
];
// Get message validator if model needs it
const messageValidator = getMessageValidator((llm as any).modelName);
const processedMessages = messageValidator
? messageValidator.processMessages(allMessages)
: allMessages;
// Extract system message and chat history
const systemMessage = processedMessages[0];
const chatHistory = processedMessages.slice(1, -1);
const userQuery = processedMessages[processedMessages.length - 1];
// Extract string content from message
const getStringContent = (content: any): string => {
if (typeof content === 'string') return content;
if (Array.isArray(content)) return content.map(getStringContent).join('\n');
if (typeof content === 'object' && content !== null) {
if ('text' in content) return content.text;
if ('value' in content) return content.value;
}
return String(content || '');
};
const queryContent = getStringContent(userQuery.content);
const stream = answeringChain.streamEvents(
{
chat_history: chatHistory,
query: queryContent,
chat_history: history,
query: message,
},
{
version: 'v1',

View File

@ -1,88 +0,0 @@
import { BaseMessage, HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
import logger from "./logger";
export interface MessageValidationRules {
requireAlternating?: boolean;
firstMessageType?: typeof HumanMessage | typeof AIMessage;
allowSystem?: boolean;
}
export class AlternatingMessageValidator {
private rules: MessageValidationRules;
private modelName: string;
constructor(modelName: string, rules: MessageValidationRules) {
this.rules = rules;
this.modelName = modelName;
}
processMessages(messages: BaseMessage[]): BaseMessage[] {
if (!this.rules.requireAlternating) {
return messages;
}
const processedMessages: BaseMessage[] = [];
for (let i = 0; i < messages.length; i++) {
const currentMsg = messages[i];
if (currentMsg instanceof SystemMessage) {
if (this.rules.allowSystem) {
processedMessages.push(currentMsg);
} else {
logger.warn(`${this.modelName}: Skipping system message - not allowed`);
}
continue;
}
if (processedMessages.length === 0 ||
processedMessages[processedMessages.length - 1] instanceof SystemMessage) {
if (this.rules.firstMessageType &&
!(currentMsg instanceof this.rules.firstMessageType)) {
logger.warn(`${this.modelName}: Converting first message to required type`);
processedMessages.push(new this.rules.firstMessageType({
content: currentMsg.content,
additional_kwargs: currentMsg.additional_kwargs
}));
continue;
}
}
const lastMsg = processedMessages[processedMessages.length - 1];
if (lastMsg instanceof HumanMessage && currentMsg instanceof HumanMessage) {
logger.warn(`${this.modelName}: Skipping consecutive human message`);
continue;
}
if (lastMsg instanceof AIMessage && currentMsg instanceof AIMessage) {
logger.warn(`${this.modelName}: Skipping consecutive AI message`);
continue;
}
if (this.modelName === 'deepseek-reasoner' && currentMsg instanceof AIMessage) {
const { reasoning_content, ...cleanedKwargs } = currentMsg.additional_kwargs;
processedMessages.push(new AIMessage({
content: currentMsg.content,
additional_kwargs: cleanedKwargs
}));
} else {
processedMessages.push(currentMsg);
}
}
return processedMessages;
}
}
export const getMessageValidator = (modelName: string): AlternatingMessageValidator | null => {
const validators: Record<string, MessageValidationRules> = {
'deepseek-reasoner': {
requireAlternating: true,
firstMessageType: HumanMessage,
allowSystem: true
},
// Add more model configurations as needed
};
const rules = validators[modelName];
return rules ? new AlternatingMessageValidator(modelName, rules) : null;
};

View File

@ -1,7 +1,7 @@
'use client';
import { Search, Sliders, ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useState, useRef, memo } from 'react';
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { toast } from 'sonner';
@ -12,135 +12,14 @@ interface Discover {
thumbnail: string;
}
const categories = [
'For You', 'AI', 'Technology', 'Current News', 'Sports',
'Money', 'Gaming', 'Entertainment', 'Art and Culture',
'Science', 'Health', 'Travel'
];
const DiscoverHeader = memo(({
activeCategory,
setActiveCategory,
setShowPreferences
}: {
activeCategory: string;
setActiveCategory: (category: string) => void;
setShowPreferences: (show: boolean) => void;
}) => {
const categoryContainerRef = useRef<HTMLDivElement>(null);
const scrollCategories = (direction: 'left' | 'right') => {
const container = categoryContainerRef.current;
if (!container) return;
const scrollAmount = container.clientWidth * 0.8;
const currentScroll = container.scrollLeft;
container.scrollTo({
left: direction === 'left'
? Math.max(0, currentScroll - scrollAmount)
: currentScroll + scrollAmount,
behavior: 'smooth'
});
};
return (
<div className="flex flex-col pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Search />
<h1 className="text-3xl font-medium p-2">Discover</h1>
</div>
<button
className="p-2 rounded-full bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary hover:dark:bg-dark-primary transition-colors"
onClick={() => setShowPreferences(true)}
aria-label="Personalize"
>
<Sliders size={20} />
</button>
</div>
<div className="relative flex items-center py-4">
<button
className="absolute left-0 z-10 p-1 rounded-full bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
onClick={() => scrollCategories('left')}
aria-label="Scroll left"
>
<ChevronLeft size={20} />
</button>
<div
className="flex overflow-x-auto mx-8 no-scrollbar scroll-smooth"
ref={categoryContainerRef}
style={{ scrollbarWidth: 'none' }} // Additional style to ensure no scrollbar in Firefox
>
<div className="flex space-x-2">
{categories.map((category) => (
<button
key={category}
className={`px-4 py-2 rounded-full whitespace-nowrap transition-colors ${
activeCategory === category
? 'bg-light-primary dark:bg-dark-primary text-white'
: 'bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80'
}`}
onClick={() => setActiveCategory(category)}
>
{category}
</button>
))}
</div>
</div>
<button
className="absolute right-0 z-10 p-1 rounded-full bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
onClick={() => scrollCategories('right')}
aria-label="Scroll right"
>
<ChevronRight size={20} />
</button>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
</div>
);
});
DiscoverHeader.displayName = 'DiscoverHeader';
const DiscoverContent = memo(({
activeCategory,
userPreferences,
preferredLanguages
}: {
activeCategory: string;
userPreferences: string[];
preferredLanguages: string[];
}) => {
const Page = () => {
const [discover, setDiscover] = useState<Discover[] | null>(null);
const [contentLoading, setContentLoading] = useState(true);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setContentLoading(true);
try {
let endpoint = `${process.env.NEXT_PUBLIC_API_URL}/discover`;
let params = [];
if (activeCategory !== 'For You') {
params.push(`category=${encodeURIComponent(activeCategory)}`);
} else if (userPreferences.length > 0) {
params.push(`preferences=${encodeURIComponent(JSON.stringify(userPreferences))}`);
}
if (preferredLanguages.length > 0) {
params.push(`languages=${encodeURIComponent(JSON.stringify(preferredLanguages))}`);
}
if (params.length > 0) {
endpoint += `?${params.join('&')}`;
}
const res = await fetch(endpoint, {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@ -160,309 +39,74 @@ const DiscoverContent = memo(({
console.error('Error fetching data:', err.message);
toast.error('Error fetching data');
} finally {
setContentLoading(false);
setLoading(false);
}
};
fetchData();
}, [activeCategory, userPreferences, preferredLanguages]);
if (contentLoading) {
return (
<div className="flex flex-row items-center justify-center py-20">
<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 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"
>
{/* Using img tag instead of Next.js Image for external URLs */}
<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>
);
});
DiscoverContent.displayName = 'DiscoverContent';
const PreferencesModal = memo(({
showPreferences,
setShowPreferences,
userPreferences,
setUserPreferences,
preferredLanguages,
setPreferredLanguages,
setActiveCategory
}: {
showPreferences: boolean;
setShowPreferences: (show: boolean) => void;
userPreferences: string[];
setUserPreferences: (prefs: string[]) => void;
preferredLanguages: string[];
setPreferredLanguages: (langs: string[]) => void;
setActiveCategory: (category: string) => void;
}) => {
const [tempPreferences, setTempPreferences] = useState<string[]>([]);
const [tempLanguages, setTempLanguages] = useState<string[]>([]);
useEffect(() => {
if (showPreferences) {
setTempPreferences([...userPreferences]);
setTempLanguages([...preferredLanguages]);
}
}, [showPreferences, userPreferences, preferredLanguages]);
const saveUserPreferences = async (preferences: string[], languages: string[]) => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover/preferences`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
categories: preferences,
languages
}),
});
if (res.ok) {
toast.success('Preferences saved successfully');
} else {
const data = await res.json();
throw new Error(data.message);
}
} catch (err: any) {
console.error('Error saving preferences:', err.message);
toast.error('Error saving preferences');
}
};
if (!showPreferences) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-[#1E1E1E] p-6 rounded-lg w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Personalize Your Feed</h2>
<h3 className="font-medium mb-2">Select categories you&apos;re interested in:</h3>
<div className="grid grid-cols-2 gap-2 mb-6">
{categories.filter(c => c !== 'For You').map((category) => (
<label key={category} className="flex items-center space-x-2">
<input
type="checkbox"
checked={tempPreferences.includes(category)}
onChange={(e) => {
if (e.target.checked) {
setTempPreferences([...tempPreferences, category]);
} else {
setTempPreferences(tempPreferences.filter(p => p !== category));
}
}}
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
/>
<span>{category}</span>
</label>
))}
</div>
<div className="mb-6">
<h3 className="font-medium mb-2">Preferred Languages</h3>
<div className="grid grid-cols-2 gap-2">
{[
{ code: 'en', name: 'English' },
{ code: 'ar', name: 'Arabic' },
{ code: 'zh', name: 'Chinese' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'hi', name: 'Hindi' },
{ code: 'it', name: 'Italian' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'ru', name: 'Russian' },
{ code: 'es', name: 'Spanish' },
].map((language) => (
<label key={language.code} className="flex items-center space-x-2">
<input
type="checkbox"
checked={tempLanguages.includes(language.code)}
onChange={(e) => {
if (e.target.checked) {
setTempLanguages([...tempLanguages, language.code]);
} else {
setTempLanguages(tempLanguages.filter(l => l !== language.code));
}
}}
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
/>
<span>{language.name}</span>
</label>
))}
</div>
<p className="text-sm text-gray-500 mt-2">
{tempLanguages.length === 0
? "No languages selected will show results in all languages"
: `Selected: ${tempLanguages.length} language(s)`}
</p>
</div>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 rounded bg-gray-300 dark:bg-gray-700 hover:bg-gray-400 dark:hover:bg-gray-600 transition-colors"
onClick={() => {
setShowPreferences(false);
// Reset temp preferences
setTempPreferences([]);
setTempLanguages([]);
}}
>
Cancel
</button>
<button
className="px-4 py-2 rounded bg-light-primary dark:bg-dark-primary text-white hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
onClick={async () => {
await saveUserPreferences(tempPreferences, tempLanguages);
// Update the actual preferences after saving
setUserPreferences(tempPreferences);
setPreferredLanguages(tempLanguages);
setShowPreferences(false);
setActiveCategory('For You'); // Switch to For You view to show personalized content
// Reset temp preferences
setTempPreferences([]);
setTempLanguages([]);
}}
>
Save
</button>
</div>
</div>
</div>
);
});
PreferencesModal.displayName = 'PreferencesModal';
const Page = () => {
const [activeCategory, setActiveCategory] = useState('For You');
const [showPreferences, setShowPreferences] = useState(false);
const [userPreferences, setUserPreferences] = useState<string[]>(['AI', 'Technology']);
const [preferredLanguages, setPreferredLanguages] = useState<string[]>(['en']); // Default to English
const [initialLoading, setInitialLoading] = useState(true);
useEffect(() => {
const loadUserPreferences = async () => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover/preferences`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (res.ok) {
const data = await res.json();
setUserPreferences(data.categories || ['AI', 'Technology']);
setPreferredLanguages(data.languages || ['en']); // Default to English if no languages are set
}
} catch (err: any) {
console.error('Error loading preferences:', err.message);
} finally {
setInitialLoading(false);
}
};
loadUserPreferences();
}, []);
if (initialLoading) {
return (
<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>
<DiscoverHeader
activeCategory={activeCategory}
setActiveCategory={setActiveCategory}
setShowPreferences={setShowPreferences}
/>
<DiscoverContent
activeCategory={activeCategory}
userPreferences={userPreferences}
preferredLanguages={preferredLanguages}
/>
<PreferencesModal
showPreferences={showPreferences}
setShowPreferences={setShowPreferences}
userPreferences={userPreferences}
setUserPreferences={setUserPreferences}
preferredLanguages={preferredLanguages}
setPreferredLanguages={setPreferredLanguages}
setActiveCategory={setActiveCategory}
/>
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>
) : (
<>
<div>
<div className="flex flex-col pt-4">
<div className="flex items-center">
<Search />
<h1 className="text-3xl font-medium p-2">Discover</h1>
</div>
<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>
</div>
</>
);
};

View File

@ -11,14 +11,3 @@
display: none;
}
}
@layer utilities {
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
}

View File

@ -1,12 +1,10 @@
'use client';
import DeleteChat from '@/components/DeleteChat';
import BatchDeleteChats from '@/components/BatchDeleteChats';
import { cn, formatTimeDifference } from '@/lib/utils';
import { BookOpenText, Check, ClockIcon, Search, Trash, X } from 'lucide-react';
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
export interface Chat {
id: string;
@ -17,13 +15,7 @@ export interface Chat {
const Page = () => {
const [chats, setChats] = useState<Chat[]>([]);
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectionMode, setSelectionMode] = useState(false);
const [selectedChats, setSelectedChats] = useState<string[]>([]);
const [hoveredChatId, setHoveredChatId] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
useEffect(() => {
const fetchChats = async () => {
@ -39,71 +31,12 @@ const Page = () => {
const data = await res.json();
setChats(data.chats);
setFilteredChats(data.chats);
setLoading(false);
};
fetchChats();
}, []);
useEffect(() => {
if (searchQuery.trim() === '') {
setFilteredChats(chats);
} else {
const filtered = chats.filter((chat) =>
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
);
setFilteredChats(filtered);
}
}, [searchQuery, chats]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const clearSearch = () => {
setSearchQuery('');
};
const toggleSelectionMode = () => {
setSelectionMode(!selectionMode);
setSelectedChats([]);
};
const toggleChatSelection = (chatId: string) => {
if (selectedChats.includes(chatId)) {
setSelectedChats(selectedChats.filter(id => id !== chatId));
} else {
setSelectedChats([...selectedChats, chatId]);
}
};
const selectAllChats = () => {
if (selectedChats.length === filteredChats.length) {
setSelectedChats([]);
} else {
setSelectedChats(filteredChats.map(chat => chat.id));
}
};
const deleteSelectedChats = () => {
if (selectedChats.length === 0) return;
setIsDeleteDialogOpen(true);
};
const handleBatchDeleteComplete = () => {
setSelectedChats([]);
setSelectionMode(false);
};
const updateChatsAfterDelete = (newChats: Chat[]) => {
setChats(newChats);
setFilteredChats(newChats.filter(chat =>
searchQuery.trim() === '' ||
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
));
};
return loading ? (
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
@ -131,145 +64,32 @@ const Page = () => {
<h1 className="text-3xl font-medium p-2">Library</h1>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
{/* Search Box */}
<div className="relative mt-6 mb-6">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search className="w-5 h-5 text-black/50 dark:text-white/50" />
</div>
<input
type="text"
className="block w-full p-2 pl-10 pr-10 bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 rounded-md text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Search your threads..."
value={searchQuery}
onChange={handleSearchChange}
/>
{searchQuery && (
<button
onClick={clearSearch}
className="absolute inset-y-0 right-0 flex items-center pr-3"
>
<X className="w-5 h-5 text-black/50 dark:text-white/50 hover:text-black dark:hover:text-white" />
</button>
)}
</div>
{/* Thread Count and Selection Controls */}
<div className="mb-4">
{!selectionMode ? (
<div className="flex items-center justify-between">
<div className="text-black/70 dark:text-white/70">
You have {chats.length} threads in Perplexica
</div>
<button
onClick={toggleSelectionMode}
className="text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white text-sm transition duration-200"
>
Select
</button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="text-black/70 dark:text-white/70">
{selectedChats.length} selected thread{selectedChats.length !== 1 ? 's' : ''}
</div>
<div className="flex space-x-4">
<button
onClick={selectAllChats}
className="text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white text-sm transition duration-200"
>
{selectedChats.length === filteredChats.length ? 'Deselect all' : 'Select all'}
</button>
<button
onClick={toggleSelectionMode}
className="text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white text-sm transition duration-200"
>
Cancel
</button>
<button
onClick={deleteSelectedChats}
disabled={selectedChats.length === 0}
className={cn(
"text-sm transition duration-200",
selectedChats.length === 0
? "text-red-400/50 hover:text-red-500/50 cursor-not-allowed"
: "text-red-400 hover:text-red-500 cursor-pointer"
)}
>
Delete Selected
</button>
</div>
</div>
)}
</div>
</div>
{filteredChats.length === 0 && (
<div className="flex flex-row items-center justify-center min-h-[50vh]">
{chats.length === 0 && (
<div className="flex flex-row items-center justify-center min-h-screen">
<p className="text-black/70 dark:text-white/70 text-sm">
{searchQuery ? 'No threads found matching your search.' : 'No threads found.'}
No chats found.
</p>
</div>
)}
{filteredChats.length > 0 && (
{chats.length > 0 && (
<div className="flex flex-col pb-20 lg:pb-2">
{filteredChats.map((chat, i) => (
{chats.map((chat, i) => (
<div
className={cn(
'flex flex-col space-y-4 py-6',
i !== filteredChats.length - 1
i !== chats.length - 1
? 'border-b border-white-200 dark:border-dark-200'
: '',
)}
key={i}
onMouseEnter={() => setHoveredChatId(chat.id)}
onMouseLeave={() => setHoveredChatId(null)}
>
<div className="flex items-center">
{/* Checkbox - visible when in selection mode or when hovering */}
{(selectionMode || hoveredChatId === chat.id) && (
<div
className="mr-3 cursor-pointer"
onClick={(e) => {
e.preventDefault();
if (!selectionMode) setSelectionMode(true);
toggleChatSelection(chat.id);
}}
>
<div className={cn(
"w-5 h-5 border rounded flex items-center justify-center transition-colors",
selectedChats.includes(chat.id)
? "bg-blue-500 border-blue-500"
: "border-gray-400 dark:border-gray-600"
)}>
{selectedChats.includes(chat.id) && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Chat Title */}
<Link
href={`/c/${chat.id}`}
className={cn(
"text-black dark:text-white lg:text-xl font-medium truncate transition duration-200 hover:text-[#24A0ED] dark:hover:text-[#24A0ED] cursor-pointer",
selectionMode && "pointer-events-none text-black dark:text-white hover:text-black dark:hover:text-white"
)}
onClick={(e) => {
if (selectionMode) {
e.preventDefault();
toggleChatSelection(chat.id);
}
}}
>
{chat.title}
</Link>
</div>
<Link
href={`/c/${chat.id}`}
className="text-black dark:text-white lg:text-xl font-medium truncate transition duration-200 hover:text-[#24A0ED] dark:hover:text-[#24A0ED] cursor-pointer"
>
{chat.title}
</Link>
<div className="flex flex-row items-center justify-between w-full">
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
<ClockIcon size={15} />
@ -277,30 +97,16 @@ const Page = () => {
{formatTimeDifference(new Date(), chat.createdAt)} Ago
</p>
</div>
{/* Delete button - only visible when not in selection mode */}
{!selectionMode && (
<DeleteChat
chatId={chat.id}
chats={chats}
setChats={updateChatsAfterDelete}
/>
)}
<DeleteChat
chatId={chat.id}
chats={chats}
setChats={setChats}
/>
</div>
</div>
))}
</div>
)}
{/* Batch Delete Confirmation Dialog */}
<BatchDeleteChats
chatIds={selectedChats}
chats={chats}
setChats={updateChatsAfterDelete}
onComplete={handleBatchDeleteComplete}
isOpen={isDeleteDialogOpen}
setIsOpen={setIsDeleteDialogOpen}
/>
</div>
);
};

View File

@ -2,7 +2,7 @@
import { Settings as SettingsIcon, ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn, formatProviderName } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { Switch } from '@headlessui/react';
import ThemeSwitcher from '@/components/theme/Switcher';
import { ImagesIcon, VideoIcon } from 'lucide-react';
@ -19,11 +19,22 @@ interface SettingsType {
groqApiKey: string;
anthropicApiKey: string;
geminiApiKey: string;
deepseekApiKey: string;
ollamaApiUrl: string;
customOpenaiApiKey: string;
customOpenaiApiUrl: string;
customOpenaiModelName: string;
searchEngineBackends: {
search: string;
image: string;
video: string;
news: string;
};
searxngEndpoint: string;
googleApiKey: string;
googleCseId: string;
bingSubscriptionKey: string;
braveApiKey: string;
yacyEndpoint: string;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@ -113,6 +124,12 @@ const Page = () => {
const [automaticImageSearch, setAutomaticImageSearch] = useState(false);
const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false);
const [savingStates, setSavingStates] = useState<Record<string, boolean>>({});
const [searchEngineBackends, setSearchEngineBackends] = useState({
search: '',
image: '',
video: '',
news: '',
});
useEffect(() => {
const fetchConfig = async () => {
@ -126,6 +143,16 @@ const Page = () => {
const data = (await res.json()) as SettingsType;
setConfig(data);
// Set search engine backends if they exist in the response
if (data.searchEngineBackends) {
setSearchEngineBackends({
search: data.searchEngineBackends.search || '',
image: data.searchEngineBackends.image || '',
video: data.searchEngineBackends.video || '',
news: data.searchEngineBackends.news || '',
});
}
const chatModelProvidersKeys = Object.keys(data.chatModelProviders || {});
const embeddingModelProvidersKeys = Object.keys(
data.embeddingModelProviders || {},
@ -332,6 +359,8 @@ const Page = () => {
localStorage.setItem('embeddingModelProvider', value);
} else if (key === 'embeddingModel') {
localStorage.setItem('embeddingModel', value);
} else if (key === 'searchEngineBackends') {
localStorage.setItem('searchEngineBackends', value);
}
} catch (err) {
console.error('Failed to save:', err);
@ -500,7 +529,9 @@ const Page = () => {
options={Object.keys(config.chatModelProviders).map(
(provider) => ({
value: provider,
label: formatProviderName(provider),
label:
provider.charAt(0).toUpperCase() +
provider.slice(1),
}),
)}
/>
@ -640,7 +671,9 @@ const Page = () => {
options={Object.keys(config.embeddingModelProviders).map(
(provider) => ({
value: provider,
label: formatProviderName(provider),
label:
provider.charAt(0).toUpperCase() +
provider.slice(1),
}),
)}
/>
@ -788,23 +821,232 @@ const Page = () => {
onSave={(value) => saveConfig('geminiApiKey', value)}
/>
</div>
</div>
</SettingsSection>
<SettingsSection title="Search Engine Settings">
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Default Search Engine
</p>
<Select
value={searchEngineBackends.search}
onChange={(e) => {
const value = e.target.value;
setSearchEngineBackends((prev) => ({
...prev,
search: value,
}));
saveConfig('searchEngineBackends', {
...searchEngineBackends,
search: value,
});
}}
options={[
{ value: 'searxng', label: 'SearXNG' },
{ value: 'google', label: 'Google' },
{ value: 'bing', label: 'Bing' },
{ value: 'brave', label: 'Brave' },
{ value: 'yacy', label: 'YaCy' },
]}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
DeepSeek API Key
Image Search Engine
</p>
<Select
value={searchEngineBackends.image}
onChange={(e) => {
const value = e.target.value;
setSearchEngineBackends((prev) => ({
...prev,
image: value,
}));
saveConfig('searchEngineBackends', {
...searchEngineBackends,
image: value,
});
}}
options={[
{ value: '', label: 'Use Default Search Engine' },
{ value: 'searxng', label: 'SearXNG' },
{ value: 'google', label: 'Google' },
{ value: 'bing', label: 'Bing' },
{ value: 'brave', label: 'Brave' },
]}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Video Search Engine
</p>
<Select
value={searchEngineBackends.video}
onChange={(e) => {
const value = e.target.value;
setSearchEngineBackends((prev) => ({
...prev,
video: value,
}));
saveConfig('searchEngineBackends', {
...searchEngineBackends,
video: value,
});
}}
options={[
{ value: '', label: 'Use Default Search Engine' },
{ value: 'searxng', label: 'SearXNG' },
{ value: 'google', label: 'Google' },
{ value: 'bing', label: 'Bing' },
{ value: 'brave', label: 'Brave' },
]}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
News Search Engine
</p>
<Select
value={searchEngineBackends.news}
onChange={(e) => {
const value = e.target.value;
setSearchEngineBackends((prev) => ({
...prev,
news: value,
}));
saveConfig('searchEngineBackends', {
...searchEngineBackends,
news: value,
});
}}
options={[
{ value: '', label: 'Use Default Search Engine' },
{ value: 'searxng', label: 'SearXNG' },
{ value: 'google', label: 'Google' },
{ value: 'bing', label: 'Bing' },
{ value: 'brave', label: 'Brave' },
]}
/>
</div>
<div className="pt-4 border-t border-light-200 dark:border-dark-200">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
SearXNG Endpoint
</p>
<Input
type="text"
placeholder="SearXNG API Endpoint"
value={config.searxngEndpoint || ''}
isSaving={savingStates['searxngEndpoint']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
searxngEndpoint: e.target.value,
}));
}}
onSave={(value) => saveConfig('searxngEndpoint', value)}
/>
</div>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Google API Key
</p>
<Input
type="text"
placeholder="DeepSeek API key"
value={config.deepseekApiKey}
isSaving={savingStates['deepseekApiKey']}
placeholder="Google API Key"
value={config.googleApiKey || ''}
isSaving={savingStates['googleApiKey']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
deepseekApiKey: e.target.value,
googleApiKey: e.target.value,
}));
}}
onSave={(value) => saveConfig('deepseekApiKey', value)}
onSave={(value) => saveConfig('googleApiKey', value)}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Google CSE ID
</p>
<Input
type="text"
placeholder="Google Custom Search Engine ID"
value={config.googleCseId || ''}
isSaving={savingStates['googleCseId']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
googleCseId: e.target.value,
}));
}}
onSave={(value) => saveConfig('googleCseId', value)}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Bing Subscription Key
</p>
<Input
type="text"
placeholder="Bing Subscription Key"
value={config.bingSubscriptionKey || ''}
isSaving={savingStates['bingSubscriptionKey']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
bingSubscriptionKey: e.target.value,
}));
}}
onSave={(value) => saveConfig('bingSubscriptionKey', value)}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Brave API Key
</p>
<Input
type="text"
placeholder="Brave API Key"
value={config.braveApiKey || ''}
isSaving={savingStates['braveApiKey']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
braveApiKey: e.target.value,
}));
}}
onSave={(value) => saveConfig('braveApiKey', value)}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
YaCy Endpoint
</p>
<Input
type="text"
placeholder="YaCy API Endpoint"
value={config.yacyEndpoint || ''}
isSaving={savingStates['yacyEndpoint']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
yacyEndpoint: e.target.value,
}));
}}
onSave={(value) => saveConfig('yacyEndpoint', value)}
/>
</div>
</div>

View File

@ -1,118 +0,0 @@
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { Fragment, useState } from 'react';
import { toast } from 'sonner';
import { Chat } from '@/app/library/page';
interface BatchDeleteChatsProps {
chatIds: string[];
chats: Chat[];
setChats: (chats: Chat[]) => void;
onComplete: () => void;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
const BatchDeleteChats = ({
chatIds,
chats,
setChats,
onComplete,
isOpen,
setIsOpen,
}: BatchDeleteChatsProps) => {
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
if (chatIds.length === 0) return;
setLoading(true);
try {
for (const chatId of chatIds) {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chats/${chatId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
}
const newChats = chats.filter(chat => !chatIds.includes(chat.id));
setChats(newChats);
toast.success(`${chatIds.length} thread${chatIds.length > 1 ? 's' : ''} deleted`);
onComplete();
} catch (err: any) {
toast.error('Failed to delete threads');
} finally {
setIsOpen(false);
setLoading(false);
}
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={() => {
if (!loading) {
setIsOpen(false);
}
}}
>
<DialogBackdrop className="fixed inset-0 bg-black/30" />
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
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"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
Delete Confirmation
</DialogTitle>
<Description className="text-sm dark:text-white/70 text-black/70">
Are you sure you want to delete {chatIds.length} selected thread{chatIds.length !== 1 ? 's' : ''}?
</Description>
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
<button
onClick={() => {
if (!loading) {
setIsOpen(false);
}
}}
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
>
Cancel
</button>
<button
onClick={handleDelete}
className="text-red-400 text-sm hover:text-red-500 transition duration-200"
disabled={loading}
>
Delete
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
};
export default BatchDeleteChats;

View File

@ -39,11 +39,11 @@ const useSocket = (
const retryCountRef = useRef(0);
const isCleaningUpRef = useRef(false);
const MAX_RETRIES = 3;
const INITIAL_BACKOFF = 1000;
const INITIAL_BACKOFF = 1000; // 1 second
const isConnectionErrorRef = useRef(false);
const getBackoffDelay = (retryCount: number) => {
return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000);
return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000); // Cap at 10 seconds
};
useEffect(() => {

View File

@ -1,108 +0,0 @@
'use client';
import * as React from 'react';
import { Brain, ChevronDown, Maximize2, Minimize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import Markdown from 'markdown-to-jsx';
import logger from '@/lib/logger';
interface ReasoningPanelProps {
thinking: string;
className?: string;
isExpanded?: boolean;
}
const ReasoningPanel = ({ thinking, className, isExpanded: propExpanded }: ReasoningPanelProps): React.ReactElement => {
const [isExpanded, setIsExpanded] = React.useState(true);
const [detailsRefs, setDetailsRefs] = React.useState<HTMLDetailsElement[]>([]);
React.useEffect(() => {
if (propExpanded !== undefined) {
setIsExpanded(propExpanded);
}
}, [propExpanded]);
const addDetailsRef = React.useCallback((element: HTMLDetailsElement | null) => {
if (element) {
setDetailsRefs(refs => {
if (!refs.includes(element)) {
return [...refs, element];
}
return refs;
});
}
}, []);
const expandAll = () => {
detailsRefs.forEach(ref => ref.open = true);
};
const collapseAll = () => {
detailsRefs.forEach(ref => ref.open = false);
};
return (
<div className={cn("flex flex-col space-y-2 mb-4", className)}>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex flex-row items-center space-x-2 group text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white transition duration-200"
type="button"
>
<Brain size={20} />
<h3 className="font-medium text-xl">Reasoning</h3>
<ChevronDown
size={16}
className={cn(
"transition-transform duration-200",
isExpanded ? "rotate-180" : ""
)}
/>
</button>
{isExpanded && (
<div className="rounded-lg bg-light-secondary/50 dark:bg-dark-secondary/50 p-4">
{thinking.split('\n\n').map((paragraph, index) => {
if (!paragraph.trim()) return null;
const content = paragraph.replace(/^[•\-\d.]\s*/, '');
return (
<div key={index} className="mb-2 last:mb-0">
<details
ref={addDetailsRef}
className="group [&_summary::-webkit-details-marker]:hidden"
>
<summary className="flex items-center cursor-pointer list-none text-sm text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white">
<span className="arrow mr-2 inline-block transition-transform duration-200 group-open:rotate-90 group-open:self-start group-open:mt-1"></span>
<p className="relative whitespace-normal line-clamp-1 group-open:line-clamp-none after:content-['...'] after:inline group-open:after:hidden transition-all duration-200 text-ellipsis overflow-hidden group-open:overflow-visible">
{content}
</p>
</summary>
{/* Content is shown in the summary when expanded - no need to render it again */}
</details>
</div>
);
})}
<div className="flex justify-end space-x-2 mt-4 text-sm text-black/70 dark:text-white/70">
<button
onClick={expandAll}
className="flex items-center space-x-1 hover:text-[#24A0ED] transition-colors"
>
<Maximize2 size={10} />
<span className="text-xs">Expand all</span>
</button>
<span></span>
<button
onClick={collapseAll}
className="flex items-center space-x-1 hover:text-[#24A0ED] transition-colors"
>
<Minimize2 size={10} />
<span className="text-xs">Collapse all</span>
</button>
</div>
</div>
)}
</div>
);
};
export default ReasoningPanel;

View File

@ -4,7 +4,6 @@
import React, { MutableRefObject, useEffect, useState } from 'react';
import { Message } from './ChatWindow';
import { cn } from '@/lib/utils';
import logger from '@/lib/logger';
import {
BookCopy,
Disc3,
@ -13,7 +12,6 @@ import {
Layers3,
Plus,
} from 'lucide-react';
import ReasoningPanel from './MessageActions/ReasoningPanel';
import Markdown from 'markdown-to-jsx';
import Copy from './MessageActions/Copy';
import Rewrite from './MessageActions/Rewrite';
@ -43,52 +41,26 @@ const MessageBox = ({
}) => {
const [parsedMessage, setParsedMessage] = useState(message.content);
const [speechMessage, setSpeechMessage] = useState(message.content);
const [thinking, setThinking] = useState<string>('');
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
useEffect(() => {
const regex = /\[(\d+)\]/g;
const thinkRegex = /<think>(.*?)(?:<\/think>|$)(.*)/s;
const match = message.content.match(thinkRegex);
if (match) {
const [_, thinkingContent, answerContent] = match;
if (thinkingContent) {
setThinking(thinkingContent.trim());
setIsThinkingExpanded(true);
}
if (answerContent) {
setIsThinkingExpanded(false);
if (message.role === 'assistant' && message?.sources && message.sources.length > 0) {
setParsedMessage(
answerContent.trim().replace(
regex,
(_, number) =>
`<a href="${message.sources?.[number - 1]?.metadata?.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">${number}</a>`,
),
);
} else {
setParsedMessage(answerContent.trim());
}
setSpeechMessage(answerContent.trim().replace(regex, ''));
}
} else {
if (message.role === 'assistant' && message?.sources && message.sources.length > 0) {
setParsedMessage(
message.content.replace(
regex,
(_, number) =>
`<a href="${message.sources?.[number - 1]?.metadata?.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">${number}</a>`,
),
);
} else {
setParsedMessage(message.content);
}
setSpeechMessage(message.content.replace(regex, ''));
if (
message.role === 'assistant' &&
message?.sources &&
message.sources.length > 0
) {
return setParsedMessage(
message.content.replace(
regex,
(_, number) =>
`<a href="${message.sources?.[number - 1]?.metadata?.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">${number}</a>`,
),
);
}
setSpeechMessage(message.content.replace(regex, ''));
setParsedMessage(message.content);
}, [message.content, message.sources, message.role]);
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
@ -96,7 +68,13 @@ const MessageBox = ({
return (
<div>
{message.role === 'user' && (
<div className={cn('w-full', messageIndex === 0 ? 'pt-16' : 'pt-8', 'break-words')}>
<div
className={cn(
'w-full',
messageIndex === 0 ? 'pt-16' : 'pt-8',
'break-words',
)}
>
<h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12">
{message.content}
</h2>
@ -109,7 +87,6 @@ const MessageBox = ({
ref={dividerRef}
className="flex flex-col space-y-6 w-full lg:w-9/12"
>
{thinking && <ReasoningPanel thinking={thinking} isExpanded={isThinkingExpanded} />}
{message.sources && message.sources.length > 0 && (
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center space-x-2">

View File

@ -1,13 +0,0 @@
const logger = {
info: (...args: any[]) => {
console.log('[INFO]', ...args);
},
warn: (...args: any[]) => {
console.warn('[WARN]', ...args);
},
error: (...args: any[]) => {
console.error('[ERROR]', ...args);
}
};
export default logger;

View File

@ -3,71 +3,6 @@ import { twMerge } from 'tailwind-merge';
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes));
export const formatProviderName = (provider: string): string => {
// Mapping of provider keys to their properly formatted display names
const providerNameMap: Record<string, string> = {
// Providers
'openai': 'OpenAI',
'groq': 'Groq',
'anthropic': 'Anthropic',
'gemini': 'Gemini',
'ollama': 'Ollama',
'deepseek': 'DeepSeek',
'lm_studio': 'LM Studio',
'custom_openai': 'Custom OpenAI',
'transformers': 'Transformers',
'nvidia': 'NVIDIA',
'openrouter': 'OpenRouter',
'together': 'Together AI',
'together_ai': 'Together AI',
'mistral': 'Mistral AI',
'mistral_ai': 'Mistral AI',
'le_chat_mistral': 'Le Chat Mistral',
'xai': 'xAI',
'grok': 'Grok',
'cohere': 'Cohere',
'ai21': 'AI21 Labs',
'ai21_labs': 'AI21 Labs',
'huggingface': 'Hugging Face',
'hugging_face': 'Hugging Face',
'replicate': 'Replicate',
'stability': 'Stability AI',
'stability_ai': 'Stability AI',
'perplexity': 'Perplexity AI',
'perplexity_ai': 'Perplexity AI',
'claude': 'Claude',
'azure_openai': 'Azure OpenAI',
'amazon': 'Amazon Bedrock',
'bedrock': 'Amazon Bedrock',
'amazon_bedrock': 'Amazon Bedrock',
'vertex': 'Vertex AI',
'vertex_ai': 'Vertex AI',
'google': 'Google AI',
'google_ai': 'Google AI',
'meta': 'Meta AI',
'meta_ai': 'Meta AI',
'llama': 'Llama',
'falcon': 'Falcon',
'aleph_alpha': 'Aleph Alpha',
'forefront': 'Forefront AI',
'forefront_ai': 'Forefront AI'
};
// Return the mapped name if it exists
if (provider in providerNameMap) {
return providerNameMap[provider];
}
// Default formatting for unknown providers:
// 1. Replace underscores with spaces
// 2. Capitalize each word
return provider
.replace(/_/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
export const formatTimeDifference = (
date1: Date | string,
date2: Date | string,

6961
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
{
"compilerOptions": {
"target": "es2018",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

File diff suppressed because it is too large Load Diff