mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-06-20 16:58:30 +00:00
Compare commits
13 Commits
feat/syste
...
3f9c1db4a6
Author | SHA1 | Date | |
---|---|---|---|
3f9c1db4a6 | |||
8948b317f1 | |||
4f81079f64 | |||
c2df5e47c9 | |||
18f627b1af | |||
2133bebc90 | |||
5a603a7fd4 | |||
136063792c | |||
649bb4ea7e | |||
92f6a9f7e1 | |||
7b15f43bb3 | |||
f473a581ce | |||
a6e4402616 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,6 +11,7 @@ yarn-error.log
|
||||
# IDE/Editor specific
|
||||
.vscode/
|
||||
.idea/
|
||||
.qodo/
|
||||
*.iml
|
||||
|
||||
# Environment variables
|
||||
|
@ -15,12 +15,20 @@ API_KEY = ""
|
||||
[MODELS.GEMINI]
|
||||
API_KEY = ""
|
||||
|
||||
[MODELS.CUSTOM_OPENAI]
|
||||
[MODELS.DEEPSEEK]
|
||||
API_KEY = ""
|
||||
API_URL = ""
|
||||
STREAM_DELAY = 5 # Milliseconds between token emissions for reasoning models (higher = slower, 0 = no delay)
|
||||
|
||||
[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]
|
||||
API_KEY = ""
|
||||
API_URL = ""
|
||||
MODEL_NAME = ""
|
||||
|
||||
[API_ENDPOINTS]
|
||||
SEARXNG = "http://localhost:32768" # SearxNG API URL
|
||||
SEARXNG = "http://localhost:32768" # SearxNG API URL
|
||||
|
@ -23,9 +23,16 @@ 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;
|
||||
@ -61,11 +68,18 @@ 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 getDeepseekStreamDelay = () =>
|
||||
loadConfig().MODELS.DEEPSEEK.STREAM_DELAY || 5; // Default to 5ms if not specified
|
||||
|
||||
export const getSearxngApiEndpoint = () =>
|
||||
process.env.SEARXNG_API_URL || loadConfig().API_ENDPOINTS.SEARXNG;
|
||||
|
||||
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;
|
||||
|
||||
|
@ -26,3 +26,16 @@ 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`),
|
||||
});
|
||||
|
89
src/lib/providers/deepseek.ts
Normal file
89
src/lib/providers/deepseek.ts
Normal file
@ -0,0 +1,89 @@
|
||||
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 {};
|
||||
}
|
||||
};
|
@ -4,6 +4,8 @@ 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,
|
||||
@ -17,6 +19,8 @@ const chatModelProviders = {
|
||||
ollama: loadOllamaChatModels,
|
||||
anthropic: loadAnthropicChatModels,
|
||||
gemini: loadGeminiChatModels,
|
||||
deepseek: loadDeepSeekChatModels,
|
||||
lm_studio: loadLMStudioChatModels,
|
||||
};
|
||||
|
||||
const embeddingModelProviders = {
|
||||
@ -24,6 +28,7 @@ const embeddingModelProviders = {
|
||||
local: loadTransformersEmbeddingsModels,
|
||||
ollama: loadOllamaEmbeddingsModels,
|
||||
gemini: loadGeminiEmbeddingsModels,
|
||||
lm_studio: loadLMStudioEmbeddingsModels,
|
||||
};
|
||||
|
||||
export const getAvailableChatModelProviders = async () => {
|
||||
|
96
src/lib/providers/lmstudio.ts
Normal file
96
src/lib/providers/lmstudio.ts
Normal file
@ -0,0 +1,96 @@
|
||||
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 {};
|
||||
}
|
||||
};
|
278
src/lib/reasoningChatModel.ts
Normal file
278
src/lib/reasoningChatModel.ts
Normal file
@ -0,0 +1,278 @@
|
||||
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';
|
||||
}
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { getSearxngApiEndpoint } from '../config';
|
||||
|
||||
interface SearxngSearchOptions {
|
||||
export interface SearxngSearchOptions {
|
||||
categories?: string[];
|
||||
engines?: string[];
|
||||
language?: string;
|
||||
pageno?: number;
|
||||
}
|
||||
|
||||
interface SearxngSearchResult {
|
||||
export interface SearxngSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
img_src?: string;
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
getAnthropicApiKey,
|
||||
getGeminiApiKey,
|
||||
getOpenaiApiKey,
|
||||
getDeepseekApiKey,
|
||||
updateConfig,
|
||||
getCustomOpenaiApiUrl,
|
||||
getCustomOpenaiApiKey,
|
||||
@ -57,6 +58,7 @@ 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();
|
||||
@ -85,6 +87,9 @@ router.post('/', async (req, res) => {
|
||||
GEMINI: {
|
||||
API_KEY: config.geminiApiKey,
|
||||
},
|
||||
DEEPSEEK: {
|
||||
API_KEY: config.deepseekApiKey,
|
||||
},
|
||||
OLLAMA: {
|
||||
API_URL: config.ollamaApiUrl,
|
||||
},
|
||||
|
@ -1,42 +1,207 @@
|
||||
import express from 'express';
|
||||
import { searchSearxng } from '../lib/searxng';
|
||||
import { searchSearxng, SearxngSearchOptions } from '../lib/searxng';
|
||||
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'];
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Main discover route - supports category, preferences, and languages parameters
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const data = (
|
||||
await Promise.all([
|
||||
searchSearxng('site:businessinsider.com AI', {
|
||||
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,
|
||||
}),
|
||||
searchSearxng('site:www.exchangewire.com AI', {
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
}),
|
||||
searchSearxng('site:yahoo.com AI', {
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
}),
|
||||
searchSearxng('site:businessinsider.com tech', {
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
}),
|
||||
searchSearxng('site:www.exchangewire.com tech', {
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
}),
|
||||
searchSearxng('site:yahoo.com tech', {
|
||||
engines: ['bing news'],
|
||||
pageno: 1,
|
||||
}),
|
||||
])
|
||||
)
|
||||
.map((result) => result.results)
|
||||
.flat()
|
||||
.sort(() => Math.random() - 0.5);
|
||||
};
|
||||
|
||||
// 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),
|
||||
])
|
||||
)
|
||||
.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);
|
||||
|
||||
return res.json({ blogs: data });
|
||||
} catch (err: any) {
|
||||
@ -45,4 +210,97 @@ 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;
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
RunnableMap,
|
||||
RunnableSequence,
|
||||
} from '@langchain/core/runnables';
|
||||
import { BaseMessage } from '@langchain/core/messages';
|
||||
import { BaseMessage, SystemMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import LineListOutputParser from '../lib/outputParsers/listLineOutputParser';
|
||||
import LineOutputParser from '../lib/outputParsers/lineOutputParser';
|
||||
@ -23,6 +23,7 @@ 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';
|
||||
|
||||
@ -475,10 +476,41 @@ 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: history,
|
||||
query: message,
|
||||
chat_history: chatHistory,
|
||||
query: queryContent,
|
||||
},
|
||||
{
|
||||
version: 'v1',
|
||||
|
88
src/utils/alternatingMessageValidator.ts
Normal file
88
src/utils/alternatingMessageValidator.ts
Normal file
@ -0,0 +1,88 @@
|
||||
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;
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, Sliders, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useState, useRef, memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -12,14 +12,135 @@ interface Discover {
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
const Page = () => {
|
||||
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 [discover, setDiscover] = useState<Discover[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setContentLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover`, {
|
||||
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, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -39,74 +160,309 @@ const Page = () => {
|
||||
console.error('Error fetching data:', err.message);
|
||||
toast.error('Error fetching data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [activeCategory, userPreferences, preferredLanguages]);
|
||||
|
||||
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>
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<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')}`
|
||||
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'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));
|
||||
}
|
||||
alt={item.title}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -11,3 +11,14 @@
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import DeleteChat from '@/components/DeleteChat';
|
||||
import BatchDeleteChats from '@/components/BatchDeleteChats';
|
||||
import { cn, formatTimeDifference } from '@/lib/utils';
|
||||
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
|
||||
import { BookOpenText, Check, ClockIcon, Search, Trash, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
@ -15,7 +17,13 @@ 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 () => {
|
||||
@ -31,12 +39,71 @@ 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
|
||||
@ -64,32 +131,145 @@ 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>
|
||||
{chats.length === 0 && (
|
||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||
|
||||
{filteredChats.length === 0 && (
|
||||
<div className="flex flex-row items-center justify-center min-h-[50vh]">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
No chats found.
|
||||
{searchQuery ? 'No threads found matching your search.' : 'No threads found.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chats.length > 0 && (
|
||||
|
||||
{filteredChats.length > 0 && (
|
||||
<div className="flex flex-col pb-20 lg:pb-2">
|
||||
{chats.map((chat, i) => (
|
||||
{filteredChats.map((chat, i) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-4 py-6',
|
||||
i !== chats.length - 1
|
||||
i !== filteredChats.length - 1
|
||||
? 'border-b border-white-200 dark:border-dark-200'
|
||||
: '',
|
||||
)}
|
||||
key={i}
|
||||
onMouseEnter={() => setHoveredChatId(chat.id)}
|
||||
onMouseLeave={() => setHoveredChatId(null)}
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<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} />
|
||||
@ -97,16 +277,30 @@ const Page = () => {
|
||||
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
||||
</p>
|
||||
</div>
|
||||
<DeleteChat
|
||||
chatId={chat.id}
|
||||
chats={chats}
|
||||
setChats={setChats}
|
||||
/>
|
||||
|
||||
{/* Delete button - only visible when not in selection mode */}
|
||||
{!selectionMode && (
|
||||
<DeleteChat
|
||||
chatId={chat.id}
|
||||
chats={chats}
|
||||
setChats={updateChatsAfterDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Delete Confirmation Dialog */}
|
||||
<BatchDeleteChats
|
||||
chatIds={selectedChats}
|
||||
chats={chats}
|
||||
setChats={updateChatsAfterDelete}
|
||||
onComplete={handleBatchDeleteComplete}
|
||||
isOpen={isDeleteDialogOpen}
|
||||
setIsOpen={setIsDeleteDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { Settings as SettingsIcon, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, formatProviderName } from '@/lib/utils';
|
||||
import { Switch } from '@headlessui/react';
|
||||
import ThemeSwitcher from '@/components/theme/Switcher';
|
||||
import { ImagesIcon, VideoIcon } from 'lucide-react';
|
||||
@ -19,6 +19,7 @@ interface SettingsType {
|
||||
groqApiKey: string;
|
||||
anthropicApiKey: string;
|
||||
geminiApiKey: string;
|
||||
deepseekApiKey: string;
|
||||
ollamaApiUrl: string;
|
||||
customOpenaiApiKey: string;
|
||||
customOpenaiApiUrl: string;
|
||||
@ -499,9 +500,7 @@ const Page = () => {
|
||||
options={Object.keys(config.chatModelProviders).map(
|
||||
(provider) => ({
|
||||
value: provider,
|
||||
label:
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1),
|
||||
label: formatProviderName(provider),
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
@ -641,9 +640,7 @@ const Page = () => {
|
||||
options={Object.keys(config.embeddingModelProviders).map(
|
||||
(provider) => ({
|
||||
value: provider,
|
||||
label:
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1),
|
||||
label: formatProviderName(provider),
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
@ -791,6 +788,25 @@ const Page = () => {
|
||||
onSave={(value) => saveConfig('geminiApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
DeepSeek API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="DeepSeek API key"
|
||||
value={config.deepseekApiKey}
|
||||
isSaving={savingStates['deepseekApiKey']}
|
||||
onChange={(e) => {
|
||||
setConfig((prev) => ({
|
||||
...prev!,
|
||||
deepseekApiKey: e.target.value,
|
||||
}));
|
||||
}}
|
||||
onSave={(value) => saveConfig('deepseekApiKey', value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
118
ui/components/BatchDeleteChats.tsx
Normal file
118
ui/components/BatchDeleteChats.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
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;
|
@ -39,11 +39,11 @@ const useSocket = (
|
||||
const retryCountRef = useRef(0);
|
||||
const isCleaningUpRef = useRef(false);
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_BACKOFF = 1000; // 1 second
|
||||
const INITIAL_BACKOFF = 1000;
|
||||
const isConnectionErrorRef = useRef(false);
|
||||
|
||||
const getBackoffDelay = (retryCount: number) => {
|
||||
return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000); // Cap at 10 seconds
|
||||
return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
108
ui/components/MessageActions/ReasoningPanel.tsx
Normal file
108
ui/components/MessageActions/ReasoningPanel.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
'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;
|
@ -4,6 +4,7 @@
|
||||
import React, { MutableRefObject, useEffect, useState } from 'react';
|
||||
import { Message } from './ChatWindow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import logger from '@/lib/logger';
|
||||
import {
|
||||
BookCopy,
|
||||
Disc3,
|
||||
@ -12,6 +13,7 @@ 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';
|
||||
@ -41,26 +43,52 @@ 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;
|
||||
|
||||
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>`,
|
||||
),
|
||||
);
|
||||
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, ''));
|
||||
}
|
||||
|
||||
setSpeechMessage(message.content.replace(regex, ''));
|
||||
setParsedMessage(message.content);
|
||||
}, [message.content, message.sources, message.role]);
|
||||
|
||||
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
||||
@ -81,6 +109,7 @@ 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">
|
||||
|
13
ui/lib/logger.ts
Normal file
13
ui/lib/logger.ts
Normal file
@ -0,0 +1,13 @@
|
||||
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;
|
@ -3,6 +3,71 @@ 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
Normal file
6961
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2018",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
1090
ui/yarn.lock
1090
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user