Compare commits

..

6 Commits

Author SHA1 Message Date
ItzCrazyKns
295334b195 feat(app): fix empty message being sent 2025-10-24 23:40:01 +05:30
ItzCrazyKns
b106abd77f feat(package): bump version 2025-10-24 23:00:07 +05:30
ItzCrazyKns
2d80fc400d feat(app): lint & beautify 2025-10-24 22:58:10 +05:30
ItzCrazyKns
097a5c55c6 feat(layout): add everything inside chat provider 2025-10-24 22:57:56 +05:30
ItzCrazyKns
d0719429b4 feat(app): fix issues with model selection 2025-10-24 22:56:23 +05:30
ItzCrazyKns
600d4ceb29 feat(hf-transformer): use langchain's inbuilt transformer class 2025-10-23 23:06:05 +05:30
13 changed files with 103 additions and 166 deletions

View File

@@ -2,7 +2,7 @@ services:
perplexica: perplexica:
image: itzcrazykns1337/perplexica:latest image: itzcrazykns1337/perplexica:latest
ports: ports:
- "3000:3000" - '3000:3000'
volumes: volumes:
- data:/home/perplexica/data - data:/home/perplexica/data
- uploads:/home/perplexica/uploads - uploads:/home/perplexica/uploads

View File

@@ -17,6 +17,7 @@ Before making search requests, you'll need to get the available providers and th
Returns a list of all active providers with their available chat and embedding models. Returns a list of all active providers with their available chat and embedding models.
**Response Example:** **Response Example:**
```json ```json
{ {
"providers": [ "providers": [

View File

@@ -1,6 +1,6 @@
{ {
"name": "perplexica-frontend", "name": "perplexica-frontend",
"version": "1.11.1", "version": "1.11.2",
"license": "MIT", "license": "MIT",
"author": "ItzCrazyKns", "author": "ItzCrazyKns",
"scripts": { "scripts": {

View File

@@ -1,17 +1,10 @@
'use client'; 'use client';
import ChatWindow from '@/components/ChatWindow'; import ChatWindow from '@/components/ChatWindow';
import { useParams } from 'next/navigation';
import React from 'react'; import React from 'react';
import { ChatProvider } from '@/lib/hooks/useChat';
const Page = () => { const Page = () => {
const { chatId }: { chatId: string } = useParams(); return <ChatWindow />;
return (
<ChatProvider id={chatId}>
<ChatWindow />
</ChatProvider>
);
}; };
export default Page; export default Page;

View File

@@ -9,6 +9,7 @@ import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider'; import ThemeProvider from '@/components/theme/Provider';
import configManager from '@/lib/config'; import configManager from '@/lib/config';
import SetupWizard from '@/components/Setup/SetupWizard'; import SetupWizard from '@/components/Setup/SetupWizard';
import { ChatProvider } from '@/lib/hooks/useChat';
const montserrat = Montserrat({ const montserrat = Montserrat({
weight: ['300', '400', '500', '700'], weight: ['300', '400', '500', '700'],
@@ -36,7 +37,7 @@ export default function RootLayout({
<body className={cn('h-full', montserrat.className)}> <body className={cn('h-full', montserrat.className)}>
<ThemeProvider> <ThemeProvider>
{setupComplete ? ( {setupComplete ? (
<> <ChatProvider>
<Sidebar>{children}</Sidebar> <Sidebar>{children}</Sidebar>
<Toaster <Toaster
toastOptions={{ toastOptions={{
@@ -47,7 +48,7 @@ export default function RootLayout({
}, },
}} }}
/> />
</> </ChatProvider>
) : ( ) : (
<SetupWizard configSections={configSections} /> <SetupWizard configSections={configSections} />
)} )}

View File

@@ -1,7 +1,5 @@
import ChatWindow from '@/components/ChatWindow'; import ChatWindow from '@/components/ChatWindow';
import { ChatProvider } from '@/lib/hooks/useChat';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { Suspense } from 'react';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Chat - Perplexica', title: 'Chat - Perplexica',
@@ -9,15 +7,7 @@ export const metadata: Metadata = {
}; };
const Home = () => { const Home = () => {
return ( return <ChatWindow />;
<div>
<Suspense>
<ChatProvider>
<ChatWindow />
</ChatProvider>
</Suspense>
</div>
);
}; };
export default Home; export default Home;

View File

@@ -9,6 +9,7 @@ import Link from 'next/link';
import NextError from 'next/error'; import NextError from 'next/error';
import { useChat } from '@/lib/hooks/useChat'; import { useChat } from '@/lib/hooks/useChat';
import Loader from './ui/Loader'; import Loader from './ui/Loader';
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
export interface BaseMessage { export interface BaseMessage {
chatId: string; chatId: string;
@@ -56,9 +57,7 @@ const ChatWindow = () => {
return ( return (
<div className="relative"> <div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5"> <div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
<Link href="/settings"> <SettingsButtonMobile />
<Settings className="cursor-pointer lg:hidden" />
</Link>
</div> </div>
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="dark:text-white/70 text-black/70 text-sm"> <p className="dark:text-white/70 text-black/70 text-sm">

View File

@@ -8,17 +8,16 @@ import {
PopoverPanel, PopoverPanel,
Transition, Transition,
} from '@headlessui/react'; } from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { MinimalProvider } from '@/lib/models/types'; import { MinimalProvider } from '@/lib/models/types';
import { useChat } from '@/lib/hooks/useChat';
const ModelSelector = () => { const ModelSelector = () => {
const [providers, setProviders] = useState<MinimalProvider[]>([]); const [providers, setProviders] = useState<MinimalProvider[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedModel, setSelectedModel] = useState<{
providerId: string; const { setChatModelProvider, chatModelProvider } = useChat();
modelKey: string;
} | null>(null);
useEffect(() => { useEffect(() => {
const loadProviders = async () => { const loadProviders = async () => {
@@ -30,28 +29,8 @@ const ModelSelector = () => {
throw new Error('Failed to fetch providers'); throw new Error('Failed to fetch providers');
} }
const data = await res.json(); const data: { providers: MinimalProvider[] } = await res.json();
setProviders(data.providers || []); setProviders(data.providers);
const savedProviderId = localStorage.getItem('chatModelProviderId');
const savedModelKey = localStorage.getItem('chatModelKey');
if (savedProviderId && savedModelKey) {
setSelectedModel({
providerId: savedProviderId,
modelKey: savedModelKey,
});
} else if (data.providers && data.providers.length > 0) {
const firstProvider = data.providers.find(
(p: MinimalProvider) => p.chatModels.length > 0,
);
if (firstProvider && firstProvider.chatModels[0]) {
setSelectedModel({
providerId: firstProvider.id,
modelKey: firstProvider.chatModels[0].key,
});
}
}
} catch (error) { } catch (error) {
console.error('Error loading providers:', error); console.error('Error loading providers:', error);
} finally { } finally {
@@ -62,13 +41,32 @@ const ModelSelector = () => {
loadProviders(); loadProviders();
}, []); }, []);
const orderedProviders = useMemo(() => {
if (!chatModelProvider?.providerId) return providers;
const currentProviderIndex = providers.findIndex(
(p) => p.id === chatModelProvider.providerId,
);
if (currentProviderIndex === -1) {
return providers;
}
const selectedProvider = providers[currentProviderIndex];
const remainingProviders = providers.filter(
(_, index) => index !== currentProviderIndex,
);
return [selectedProvider, ...remainingProviders];
}, [providers, chatModelProvider]);
const handleModelSelect = (providerId: string, modelKey: string) => { const handleModelSelect = (providerId: string, modelKey: string) => {
setSelectedModel({ providerId, modelKey }); setChatModelProvider({ providerId, key: modelKey });
localStorage.setItem('chatModelProviderId', providerId); localStorage.setItem('chatModelProviderId', providerId);
localStorage.setItem('chatModelKey', modelKey); localStorage.setItem('chatModelKey', modelKey);
}; };
const filteredProviders = providers const filteredProviders = orderedProviders
.map((provider) => ({ .map((provider) => ({
...provider, ...provider,
chatModels: provider.chatModels.filter( chatModels: provider.chatModels.filter(
@@ -140,15 +138,16 @@ const ModelSelector = () => {
<div className="flex flex-col px-2 py-2 space-y-0.5"> <div className="flex flex-col px-2 py-2 space-y-0.5">
{provider.chatModels.map((model) => ( {provider.chatModels.map((model) => (
<PopoverButton <button
key={model.key} key={model.key}
onClick={() => onClick={() =>
handleModelSelect(provider.id, model.key) handleModelSelect(provider.id, model.key)
} }
type="button"
className={cn( className={cn(
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group', 'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
selectedModel?.providerId === provider.id && chatModelProvider?.providerId === provider.id &&
selectedModel?.modelKey === model.key chatModelProvider?.key === model.key
? 'bg-light-secondary dark:bg-dark-secondary' ? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary', : 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)} )}
@@ -158,8 +157,9 @@ const ModelSelector = () => {
size={15} size={15}
className={cn( className={cn(
'shrink-0', 'shrink-0',
selectedModel?.providerId === provider.id && chatModelProvider?.providerId ===
selectedModel?.modelKey === model.key provider.id &&
chatModelProvider?.key === model.key
? 'text-sky-500' ? 'text-sky-500'
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70', : 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
)} )}
@@ -167,8 +167,9 @@ const ModelSelector = () => {
<p <p
className={cn( className={cn(
'text-sm truncate', 'text-sm truncate',
selectedModel?.providerId === provider.id && chatModelProvider?.providerId ===
selectedModel?.modelKey === model.key provider.id &&
chatModelProvider?.key === model.key
? 'text-sky-500 font-medium' ? 'text-sky-500 font-medium'
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white', : 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
)} )}
@@ -176,7 +177,7 @@ const ModelSelector = () => {
{model.name} {model.name}
</p> </p>
</div> </div>
</PopoverButton> </button>
))} ))}
</div> </div>

View File

@@ -1,5 +1,6 @@
import Select from '@/components/ui/Select'; import Select from '@/components/ui/Select';
import { ConfigModelProvider } from '@/lib/config/types'; import { ConfigModelProvider } from '@/lib/config/types';
import { useChat } from '@/lib/hooks/useChat';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -16,6 +17,7 @@ const ModelSelect = ({
: `${localStorage.getItem('embeddingModelProviderId')}/${localStorage.getItem('embeddingModelKey')}`, : `${localStorage.getItem('embeddingModelProviderId')}/${localStorage.getItem('embeddingModelKey')}`,
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { setChatModelProvider, setEmbeddingModelProvider } = useChat();
const handleSave = async (newValue: string) => { const handleSave = async (newValue: string) => {
setLoading(true); setLoading(true);
@@ -23,20 +25,27 @@ const ModelSelect = ({
try { try {
if (type === 'chat') { if (type === 'chat') {
localStorage.setItem('chatModelProviderId', newValue.split('/')[0]); const providerId = newValue.split('/')[0];
localStorage.setItem( const modelKey = newValue.split('/').slice(1).join('/');
'chatModelKey',
newValue.split('/').slice(1).join('/'), localStorage.setItem('chatModelProviderId', providerId);
); localStorage.setItem('chatModelKey', modelKey);
setChatModelProvider({
providerId: providerId,
key: modelKey,
});
} else { } else {
localStorage.setItem( const providerId = newValue.split('/')[0];
'embeddingModelProviderId', const modelKey = newValue.split('/').slice(1).join('/');
newValue.split('/')[0],
); localStorage.setItem('embeddingModelProviderId', providerId);
localStorage.setItem( localStorage.setItem('embeddingModelKey', modelKey);
'embeddingModelKey',
newValue.split('/').slice(1).join('/'), setEmbeddingModelProvider({
); providerId: providerId,
key: modelKey,
});
} }
} catch (error) { } catch (error) {
console.error('Error saving config:', error); console.error('Error saving config:', error);

View File

@@ -17,7 +17,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import crypto from 'crypto'; import crypto from 'crypto';
import { useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getSuggestions } from '../actions'; import { getSuggestions } from '../actions';
import { MinimalProvider } from '../models/types'; import { MinimalProvider } from '../models/types';
@@ -48,6 +48,8 @@ type ChatContext = {
messageAppeared: boolean; messageAppeared: boolean;
isReady: boolean; isReady: boolean;
hasError: boolean; hasError: boolean;
chatModelProvider: ChatModelProvider;
embeddingModelProvider: EmbeddingModelProvider;
setOptimizationMode: (mode: string) => void; setOptimizationMode: (mode: string) => void;
setFocusMode: (mode: string) => void; setFocusMode: (mode: string) => void;
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void;
@@ -58,6 +60,8 @@ type ChatContext = {
rewrite?: boolean, rewrite?: boolean,
) => Promise<void>; ) => Promise<void>;
rewrite: (messageId: string) => void; rewrite: (messageId: string) => void;
setChatModelProvider: (provider: ChatModelProvider) => void;
setEmbeddingModelProvider: (provider: EmbeddingModelProvider) => void;
}; };
export interface File { export interface File {
@@ -256,25 +260,24 @@ export const chatContext = createContext<ChatContext>({
sections: [], sections: [],
notFound: false, notFound: false,
optimizationMode: '', optimizationMode: '',
chatModelProvider: { key: '', providerId: '' },
embeddingModelProvider: { key: '', providerId: '' },
rewrite: () => {}, rewrite: () => {},
sendMessage: async () => {}, sendMessage: async () => {},
setFileIds: () => {}, setFileIds: () => {},
setFiles: () => {}, setFiles: () => {},
setFocusMode: () => {}, setFocusMode: () => {},
setOptimizationMode: () => {}, setOptimizationMode: () => {},
setChatModelProvider: () => {},
setEmbeddingModelProvider: () => {},
}); });
export const ChatProvider = ({ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
children, const params: { chatId: string } = useParams();
id,
}: {
children: React.ReactNode;
id?: string;
}) => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const initialMessage = searchParams.get('q'); const initialMessage = searchParams.get('q');
const [chatId, setChatId] = useState<string | undefined>(id); const [chatId, setChatId] = useState<string | undefined>(params.chatId);
const [newChatCreated, setNewChatCreated] = useState(false); const [newChatCreated, setNewChatCreated] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -443,6 +446,19 @@ export const ChatProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
if (params.chatId && params.chatId !== chatId) {
setChatId(params.chatId);
setMessages([]);
setChatHistory([]);
setFiles([]);
setFileIds([]);
setIsMessagesLoaded(false);
setNotFound(false);
setNewChatCreated(false);
}
}, [params.chatId, chatId]);
useEffect(() => { useEffect(() => {
if ( if (
chatId && chatId &&
@@ -466,7 +482,7 @@ export const ChatProvider = ({
setChatId(crypto.randomBytes(20).toString('hex')); setChatId(crypto.randomBytes(20).toString('hex'));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [chatId, isMessagesLoaded, newChatCreated, messages.length]);
useEffect(() => { useEffect(() => {
messagesRef.current = messages; messagesRef.current = messages;
@@ -519,7 +535,7 @@ export const ChatProvider = ({
messageId, messageId,
rewrite = false, rewrite = false,
) => { ) => {
if (loading) return; if (loading || !message) return;
setLoading(true); setLoading(true);
setMessageAppeared(false); setMessageAppeared(false);
@@ -743,6 +759,10 @@ export const ChatProvider = ({
setOptimizationMode, setOptimizationMode,
rewrite, rewrite,
sendMessage, sendMessage,
setChatModelProvider,
chatModelProvider,
embeddingModelProvider,
setEmbeddingModelProvider,
}} }}
> >
{children} {children}

View File

@@ -1,76 +0,0 @@
import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings';
import { chunkArray } from '@langchain/core/utils/chunk_array';
export interface HuggingFaceTransformersEmbeddingsParams
extends EmbeddingsParams {
modelName: string;
model: string;
timeout?: number;
batchSize?: number;
stripNewLines?: boolean;
}
export class HuggingFaceTransformersEmbeddings
extends Embeddings
implements HuggingFaceTransformersEmbeddingsParams
{
modelName = 'Xenova/all-MiniLM-L6-v2';
model = 'Xenova/all-MiniLM-L6-v2';
batchSize = 512;
stripNewLines = true;
timeout?: number;
constructor(fields?: Partial<HuggingFaceTransformersEmbeddingsParams>) {
super(fields ?? {});
this.modelName = fields?.model ?? fields?.modelName ?? this.model;
this.model = this.modelName;
this.stripNewLines = fields?.stripNewLines ?? this.stripNewLines;
this.timeout = fields?.timeout;
}
async embedDocuments(texts: string[]): Promise<number[][]> {
const batches = chunkArray(
this.stripNewLines ? texts.map((t) => t.replace(/\n/g, ' ')) : texts,
this.batchSize,
);
const batchRequests = batches.map((batch) => this.runEmbedding(batch));
const batchResponses = await Promise.all(batchRequests);
const embeddings: number[][] = [];
for (let i = 0; i < batchResponses.length; i += 1) {
const batchResponse = batchResponses[i];
for (let j = 0; j < batchResponse.length; j += 1) {
embeddings.push(batchResponse[j]);
}
}
return embeddings;
}
async embedQuery(text: string): Promise<number[]> {
const data = await this.runEmbedding([
this.stripNewLines ? text.replace(/\n/g, ' ') : text,
]);
return data[0];
}
private async runEmbedding(texts: string[]) {
const { pipeline } = await import('@huggingface/transformers');
const pipe = await pipeline('feature-extraction', this.model);
return this.caller.call(async () => {
const output = await pipe(texts, { pooling: 'mean', normalize: true });
return output.tolist();
});
}
}

View File

@@ -4,8 +4,7 @@ import BaseModelProvider from './baseProvider';
import { Embeddings } from '@langchain/core/embeddings'; import { Embeddings } from '@langchain/core/embeddings';
import { UIConfigField } from '@/lib/config/types'; import { UIConfigField } from '@/lib/config/types';
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry'; import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
import { HuggingFaceTransformersEmbeddings } from '@/lib/huggingfaceTransformer'; import { HuggingFaceTransformersEmbeddings } from '@langchain/community/embeddings/huggingface_transformers';
interface TransformersConfig {} interface TransformersConfig {}
const defaultEmbeddingModels: Model[] = [ const defaultEmbeddingModels: Model[] = [