diff --git a/sample.config.toml b/sample.config.toml index 7b09d67..9e3a06f 100644 --- a/sample.config.toml +++ b/sample.config.toml @@ -1,6 +1,10 @@ [GENERAL] PORT = 3001 # Port to run the server on SIMILARITY_MEASURE = "cosine" # "cosine" or "dot" +CONFIG_PASSWORD = "lorem_ipsum" # Password to access config +DISCOVER_ENABLED = true +LIBRARY_ENABLED = true +COPILOT_ENABLED = true KEEP_ALIVE = "5m" # How long to keep Ollama models loaded into memory. (Instead of using -1 use "-1m") [MODELS.OPENAI] @@ -18,6 +22,7 @@ API_KEY = "" [MODELS.CUSTOM_OPENAI] API_KEY = "" API_URL = "" +MODEL_NAME = "" [MODELS.OLLAMA] API_URL = "" # Ollama API URL - http://host.docker.internal:11434 diff --git a/src/config.ts b/src/config.ts index ab2a5db..42f52b8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,10 @@ interface Config { GENERAL: { PORT: number; SIMILARITY_MEASURE: string; + CONFIG_PASSWORD: string; + DISCOVER_ENABLED: boolean; + LIBRARY_ENABLED: boolean; + COPILOT_ENABLED: boolean; KEEP_ALIVE: string; }; MODELS: { @@ -51,6 +55,14 @@ export const getPort = () => loadConfig().GENERAL.PORT; export const getSimilarityMeasure = () => loadConfig().GENERAL.SIMILARITY_MEASURE; +export const getConfigPassword = () => loadConfig().GENERAL.CONFIG_PASSWORD; + +export const isDiscoverEnabled = () => loadConfig().GENERAL.DISCOVER_ENABLED; + +export const isLibraryEnabled = () => loadConfig().GENERAL.LIBRARY_ENABLED; + +export const isCopilotEnabled = () => loadConfig().GENERAL.COPILOT_ENABLED; + export const getKeepAlive = () => loadConfig().GENERAL.KEEP_ALIVE; export const getOpenaiApiKey = () => loadConfig().MODELS.OPENAI.API_KEY; diff --git a/src/routes/config.ts b/src/routes/config.ts index 18b370d..d1773b4 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -10,6 +10,10 @@ import { getGeminiApiKey, getOpenaiApiKey, updateConfig, + getConfigPassword, + isLibraryEnabled, + isCopilotEnabled, + isDiscoverEnabled, getCustomOpenaiApiUrl, getCustomOpenaiApiKey, getCustomOpenaiModelName, @@ -18,8 +22,16 @@ import logger from '../utils/logger'; const router = express.Router(); -router.get('/', async (_, res) => { +router.get('/', async (req, res) => { try { + const authHeader = req.headers['authorization']?.split(' ')[1]; + const password = getConfigPassword(); + + if (authHeader !== password) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + const config = {}; const [chatModelProviders, embeddingModelProviders] = await Promise.all([ @@ -69,9 +81,22 @@ router.get('/', async (_, res) => { }); router.post('/', async (req, res) => { + const authHeader = req.headers['authorization']?.split(' ')[1]; + const password = getConfigPassword(); + + if (authHeader !== password) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + const config = req.body; const updatedConfig = { + GENERAL: { + DISCOVER_ENABLED: config.isDiscoverEnabled, + LIBRARY_ENABLED: config.isLibraryEnabled, + COPILOT_ENABLED: config.isCopilotEnabled, + }, MODELS: { OPENAI: { API_KEY: config.openaiApiKey, @@ -101,4 +126,14 @@ router.post('/', async (req, res) => { res.status(200).json({ message: 'Config updated' }); }); +router.get('/preferences', (_, res) => { + const preferences = { + isLibraryEnabled: isLibraryEnabled(), + isCopilotEnabled: isCopilotEnabled(), + isDiscoverEnabled: isDiscoverEnabled(), + }; + + res.status(200).json(preferences); +}); + export default router; diff --git a/src/routes/models.ts b/src/routes/models.ts index b5fbe12..32ad813 100644 --- a/src/routes/models.ts +++ b/src/routes/models.ts @@ -9,10 +9,33 @@ const router = express.Router(); router.get('/', async (req, res) => { try { - const [chatModelProviders, embeddingModelProviders] = await Promise.all([ - getAvailableChatModelProviders(), - getAvailableEmbeddingModelProviders(), - ]); + const [chatModelProvidersRaw, embeddingModelProvidersRaw] = + await Promise.all([ + getAvailableChatModelProviders(), + getAvailableEmbeddingModelProviders(), + ]); + + const chatModelProviders = {}; + + const chatModelProvidersKeys = Object.keys(chatModelProvidersRaw); + chatModelProvidersKeys.forEach((provider) => { + chatModelProviders[provider] = {}; + const models = Object.keys(chatModelProvidersRaw[provider]); + models.forEach((model) => { + chatModelProviders[provider][model] = {}; + }); + }); + + const embeddingModelProviders = {}; + + const embeddingModelProvidersKeys = Object.keys(embeddingModelProvidersRaw); + embeddingModelProvidersKeys.forEach((provider) => { + embeddingModelProviders[provider] = {}; + const models = Object.keys(embeddingModelProvidersRaw[provider]); + models.forEach((model) => { + embeddingModelProviders[provider][model] = {}; + }); + }); Object.keys(chatModelProviders).forEach((provider) => { Object.keys(chatModelProviders[provider]).forEach((model) => { diff --git a/src/websocket/messageHandler.ts b/src/websocket/messageHandler.ts index 395c0de..e53ab6a 100644 --- a/src/websocket/messageHandler.ts +++ b/src/websocket/messageHandler.ts @@ -5,8 +5,9 @@ import type { Embeddings } from '@langchain/core/embeddings'; import logger from '../utils/logger'; import db from '../db'; import { chats, messages as messagesSchema } from '../db/schema'; -import { eq, asc, gt, and } from 'drizzle-orm'; +import { eq, gt, and } from 'drizzle-orm'; import crypto from 'crypto'; +import { isLibraryEnabled } from '../config'; import { getFileDetails } from '../utils/files'; import MetaSearchAgent, { MetaSearchAgentType, @@ -94,6 +95,8 @@ const handleEmitterEvents = ( let recievedMessage = ''; let sources = []; + const libraryEnabled = isLibraryEnabled(); + emitter.on('data', (data) => { const parsedData = JSON.parse(data); if (parsedData.type === 'response') { @@ -119,18 +122,20 @@ const handleEmitterEvents = ( emitter.on('end', () => { ws.send(JSON.stringify({ type: 'messageEnd', messageId: messageId })); - db.insert(messagesSchema) - .values({ - content: recievedMessage, - chatId: chatId, - messageId: messageId, - role: 'assistant', - metadata: JSON.stringify({ - createdAt: new Date(), - ...(sources && sources.length > 0 && { sources }), - }), - }) - .execute(); + if (libraryEnabled) { + db.insert(messagesSchema) + .values({ + content: recievedMessage, + chatId: chatId, + messageId: messageId, + role: 'assistant', + metadata: JSON.stringify({ + createdAt: new Date(), + ...(sources && sources.length > 0 && { sources }), + }), + }) + .execute(); + } }); emitter.on('error', (data) => { const parsedData = JSON.parse(data); @@ -188,6 +193,8 @@ export const handleMessage = async ( const handler: MetaSearchAgentType = searchHandlers[parsedWSMessage.focusMode]; + const libraryEnabled = isLibraryEnabled(); + if (handler) { try { const emitter = await handler.searchAndAnswer( @@ -201,50 +208,52 @@ export const handleMessage = async ( handleEmitterEvents(emitter, ws, aiMessageId, parsedMessage.chatId); - const chat = await db.query.chats.findFirst({ - where: eq(chats.id, parsedMessage.chatId), - }); + if (libraryEnabled) { + const chat = await db.query.chats.findFirst({ + where: eq(chats.id, parsedMessage.chatId), + }); - if (!chat) { - await db - .insert(chats) - .values({ - id: parsedMessage.chatId, - title: parsedMessage.content, - createdAt: new Date().toString(), - focusMode: parsedWSMessage.focusMode, - files: parsedWSMessage.files.map(getFileDetails), - }) - .execute(); - } + if (!chat) { + await db + .insert(chats) + .values({ + id: parsedMessage.chatId, + title: parsedMessage.content, + createdAt: new Date().toString(), + focusMode: parsedWSMessage.focusMode, + files: parsedWSMessage.files.map(getFileDetails), + }) + .execute(); + } - const messageExists = await db.query.messages.findFirst({ - where: eq(messagesSchema.messageId, humanMessageId), - }); + const messageExists = await db.query.messages.findFirst({ + where: eq(messagesSchema.messageId, humanMessageId), + }); - if (!messageExists) { - await db - .insert(messagesSchema) - .values({ - content: parsedMessage.content, - chatId: parsedMessage.chatId, - messageId: humanMessageId, - role: 'user', - metadata: JSON.stringify({ - createdAt: new Date(), - }), - }) - .execute(); - } else { - await db - .delete(messagesSchema) - .where( - and( - gt(messagesSchema.id, messageExists.id), - eq(messagesSchema.chatId, parsedMessage.chatId), - ), - ) - .execute(); + if (!messageExists) { + await db + .insert(messagesSchema) + .values({ + content: parsedMessage.content, + chatId: parsedMessage.chatId, + messageId: humanMessageId, + role: 'user', + metadata: JSON.stringify({ + createdAt: new Date(), + }), + }) + .execute(); + } else { + await db + .delete(messagesSchema) + .where( + and( + gt(messagesSchema.id, messageExists.id), + eq(messagesSchema.chatId, parsedMessage.chatId), + ), + ) + .execute(); + } } } catch (err) { console.log(err); diff --git a/ui/app/library/layout.tsx b/ui/app/library/layout.tsx index 00d4a3b..d1a1cf6 100644 --- a/ui/app/library/layout.tsx +++ b/ui/app/library/layout.tsx @@ -5,7 +5,31 @@ export const metadata: Metadata = { title: 'Library - Perplexica', }; -const Layout = ({ children }: { children: React.ReactNode }) => { +const Layout = async ({ children }: { children: React.ReactNode }) => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/config/preferences`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + const data = await res.json(); + + const { isLibraryEnabled } = data; + + if (!isLibraryEnabled) { + return ( +
+

+ Library is disabled +

+
+ ); + } + return
{children}
; }; diff --git a/ui/app/library/page.tsx b/ui/app/library/page.tsx index 379596c..6f08079 100644 --- a/ui/app/library/page.tsx +++ b/ui/app/library/page.tsx @@ -1,8 +1,9 @@ 'use client'; import DeleteChat from '@/components/DeleteChat'; -import { cn, formatTimeDifference } from '@/lib/utils'; -import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react'; +import { formatTimeDifference } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { BookOpenText, ClockIcon } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useState } from 'react'; diff --git a/ui/app/settings/page.tsx b/ui/app/settings/page.tsx index 6aff1b0..8e6afdf 100644 --- a/ui/app/settings/page.tsx +++ b/ui/app/settings/page.tsx @@ -112,6 +112,83 @@ const Page = () => { const [automaticImageSearch, setAutomaticImageSearch] = useState(false); const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false); const [savingStates, setSavingStates] = useState>({}); + + const [password, setPassword] = useState(''); + const [passwordSubmitted, setPasswordSubmitted] = useState(false); + const [isPasswordValid, setIsPasswordValid] = useState(true); + + const handlePasswordSubmit = async () => { + setIsLoading(true); + setPasswordSubmitted(true); + + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${password}`, + }, + }); + + if (res.status === 401) { + setIsPasswordValid(false); + setPasswordSubmitted(false); + setIsLoading(false); + return; + } else { + setIsPasswordValid(true); + } + + const data = (await res.json()) as SettingsType; + setConfig(data); + + const chatModelProvidersKeys = Object.keys(data.chatModelProviders || {}); + const embeddingModelProvidersKeys = Object.keys( + data.embeddingModelProviders || {}, + ); + + const defaultChatModelProvider = + chatModelProvidersKeys.length > 0 ? chatModelProvidersKeys[0] : ''; + const defaultEmbeddingModelProvider = + embeddingModelProvidersKeys.length > 0 + ? embeddingModelProvidersKeys[0] + : ''; + + const chatModelProvider = + localStorage.getItem('chatModelProvider') || + defaultChatModelProvider || + ''; + const chatModel = + localStorage.getItem('chatModel') || + (data.chatModelProviders && + data.chatModelProviders[chatModelProvider]?.length > 0 + ? data.chatModelProviders[chatModelProvider][0].name + : undefined) || + ''; + const embeddingModelProvider = + localStorage.getItem('embeddingModelProvider') || + defaultEmbeddingModelProvider || + ''; + const embeddingModel = + localStorage.getItem('embeddingModel') || + (data.embeddingModelProviders && + data.embeddingModelProviders[embeddingModelProvider]?.[0].name) || + ''; + + setSelectedChatModelProvider(chatModelProvider); + setSelectedChatModel(chatModel); + setSelectedEmbeddingModelProvider(embeddingModelProvider); + setSelectedEmbeddingModel(embeddingModel); + setChatModels(data.chatModelProviders || {}); + setEmbeddingModels(data.embeddingModelProviders || {}); + + setAutomaticImageSearch( + localStorage.getItem('autoImageSearch') === 'true', + ); + setAutomaticVideoSearch( + localStorage.getItem('autoVideoSearch') === 'true', + ); + + setIsLoading(false); + }; useEffect(() => { const fetchConfig = async () => { @@ -119,6 +196,7 @@ const Page = () => { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${password}`, }, }); @@ -193,11 +271,16 @@ const Page = () => { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${password}`, }, body: JSON.stringify(updatedConfig), }, ); + if (response.status === 401) { + throw new Error('Unauthorized'); + } + if (!response.ok) { throw new Error('Failed to update config'); } @@ -211,6 +294,7 @@ const Page = () => { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${password}`, }, }); @@ -376,6 +460,33 @@ const Page = () => { /> + ) : !passwordSubmitted ? ( +
+

+ Enter the password to access the settings +

+
+ setPassword(e.target.value)} + /> +
+ {!isPasswordValid && ( +

+ Password is incorrect +

+ )} + +
) : ( config && (
diff --git a/ui/components/MessageInputActions/Copilot.tsx b/ui/components/MessageInputActions/Copilot.tsx index 5a3e476..63f0607 100644 --- a/ui/components/MessageInputActions/Copilot.tsx +++ b/ui/components/MessageInputActions/Copilot.tsx @@ -1,5 +1,6 @@ import { cn } from '@/lib/utils'; import { Switch } from '@headlessui/react'; +import { useEffect } from 'react'; const CopilotToggle = ({ copilotEnabled, @@ -8,11 +9,33 @@ const CopilotToggle = ({ copilotEnabled: boolean; setCopilotEnabled: (enabled: boolean) => void; }) => { + const fetchAndSetCopilotEnabled = async () => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/config/preferences`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + const preferences = await res.json(); + + setCopilotEnabled(preferences.isCopilotEnabled); + }; + + useEffect(() => { + fetchAndSetCopilotEnabled(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
Copilot diff --git a/ui/components/Sidebar.tsx b/ui/components/Sidebar.tsx index 81db8ba..7f7f5f9 100644 --- a/ui/components/Sidebar.tsx +++ b/ui/components/Sidebar.tsx @@ -4,9 +4,15 @@ import { cn } from '@/lib/utils'; import { BookOpenText, Home, Search, SquarePen, Settings } from 'lucide-react'; import Link from 'next/link'; import { useSelectedLayoutSegments } from 'next/navigation'; -import React, { useState, type ReactNode } from 'react'; +import React, { useEffect, useMemo, useState, type ReactNode } from 'react'; import Layout from './Layout'; +export type Preferences = { + isLibraryEnabled: boolean; + isDiscoverEnabled: boolean; + isCopilotEnabled: boolean; +}; + const VerticalIconContainer = ({ children }: { children: ReactNode }) => { return (
{children}
@@ -17,6 +23,31 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { const segments = useSelectedLayoutSegments(); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [preferences, setPreferences] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPreferences = async () => { + setLoading(true); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/config/preferences`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + const data = await res.json(); + + setPreferences(data); + setLoading(false); + }; + + fetchPreferences(); + }, []); const navLinks = [ { @@ -24,22 +55,44 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { href: '/', active: segments.length === 0 || segments.includes('c'), label: 'Home', + show: true, }, { icon: Search, href: '/discover', active: segments.includes('discover'), label: 'Discover', + show: preferences?.isDiscoverEnabled, }, { icon: BookOpenText, href: '/library', active: segments.includes('library'), label: 'Library', + show: preferences?.isLibraryEnabled, }, ]; - return ( + return loading ? ( +
+ +
+ ) : (
@@ -47,23 +100,26 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { - {navLinks.map((link, i) => ( - - - {link.active && ( -
- )} - - ))} + {navLinks.map( + (link, i) => + link.show === true && ( + + + {link.active && ( +
+ )} + + ), + )}