mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-06-24 02:38:36 +00:00
Merge branch 'master' into admin-password
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import MessageInput from './MessageInput';
|
||||
import { Message } from './ChatWindow';
|
||||
import { File, Message } from './ChatWindow';
|
||||
import MessageBox from './MessageBox';
|
||||
import MessageBoxLoading from './MessageBoxLoading';
|
||||
|
||||
@ -12,12 +12,20 @@ const Chat = ({
|
||||
sendMessage,
|
||||
messageAppeared,
|
||||
rewrite,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
messages: Message[];
|
||||
sendMessage: (message: string) => void;
|
||||
loading: boolean;
|
||||
messageAppeared: boolean;
|
||||
rewrite: (messageId: string) => void;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [dividerWidth, setDividerWidth] = useState(0);
|
||||
const dividerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -78,7 +86,14 @@ const Chat = ({
|
||||
className="bottom-24 lg:bottom-10 fixed z-40"
|
||||
style={{ width: dividerWidth }}
|
||||
>
|
||||
<MessageInput loading={loading} sendMessage={sendMessage} />
|
||||
<MessageInput
|
||||
loading={loading}
|
||||
sendMessage={sendMessage}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -9,7 +9,9 @@ import crypto from 'crypto';
|
||||
import { toast } from 'sonner';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { getSuggestions } from '@/lib/actions';
|
||||
import Error from 'next/error';
|
||||
import { Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import NextError from 'next/error';
|
||||
|
||||
export type Message = {
|
||||
messageId: string;
|
||||
@ -21,16 +23,36 @@ export type Message = {
|
||||
sources?: Document[];
|
||||
};
|
||||
|
||||
export interface File {
|
||||
fileName: string;
|
||||
fileExtension: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
const useSocket = (
|
||||
url: string,
|
||||
setIsWSReady: (ready: boolean) => void,
|
||||
setError: (error: boolean) => void,
|
||||
) => {
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const retryCountRef = useRef(0);
|
||||
const isCleaningUpRef = useRef(false);
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_BACKOFF = 1000; // 1 second
|
||||
const isConnectionErrorRef = useRef(false);
|
||||
|
||||
const getBackoffDelay = (retryCount: number) => {
|
||||
return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000); // Cap at 10 seconds
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) {
|
||||
const connectWs = async () => {
|
||||
const connectWs = async () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
try {
|
||||
let chatModel = localStorage.getItem('chatModel');
|
||||
let chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
let embeddingModel = localStorage.getItem('embeddingModel');
|
||||
@ -38,6 +60,17 @@ const useSocket = (
|
||||
'embeddingModelProvider',
|
||||
);
|
||||
|
||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
||||
|
||||
if (!autoImageSearch) {
|
||||
localStorage.setItem('autoImageSearch', 'true');
|
||||
}
|
||||
|
||||
if (!autoVideoSearch) {
|
||||
localStorage.setItem('autoVideoSearch', 'false');
|
||||
}
|
||||
|
||||
const providers = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/models`,
|
||||
{
|
||||
@ -45,7 +78,13 @@ const useSocket = (
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
).then(async (res) => await res.json());
|
||||
).then(async (res) => {
|
||||
if (!res.ok)
|
||||
throw new Error(
|
||||
`Failed to fetch models: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
if (
|
||||
!chatModel ||
|
||||
@ -56,20 +95,16 @@ const useSocket = (
|
||||
if (!chatModel || !chatModelProvider) {
|
||||
const chatModelProviders = providers.chatModelProviders;
|
||||
|
||||
chatModelProvider = Object.keys(chatModelProviders)[0];
|
||||
chatModelProvider =
|
||||
chatModelProvider || Object.keys(chatModelProviders)[0];
|
||||
|
||||
if (chatModelProvider === 'custom_openai') {
|
||||
toast.error('Seems like you are using the custom OpenAI provider, please open the settings and configure the API key and base URL');
|
||||
setError(true);
|
||||
return;
|
||||
} else {
|
||||
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
if (
|
||||
!chatModelProviders ||
|
||||
Object.keys(chatModelProviders).length === 0
|
||||
)
|
||||
return toast.error('No chat models available');
|
||||
}
|
||||
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
|
||||
if (
|
||||
!chatModelProviders ||
|
||||
Object.keys(chatModelProviders).length === 0
|
||||
)
|
||||
return toast.error('No chat models available');
|
||||
}
|
||||
|
||||
if (!embeddingModel || !embeddingModelProvider) {
|
||||
@ -102,16 +137,26 @@ const useSocket = (
|
||||
Object.keys(chatModelProviders).length > 0 &&
|
||||
!chatModelProviders[chatModelProvider]
|
||||
) {
|
||||
chatModelProvider = Object.keys(chatModelProviders)[0];
|
||||
const chatModelProvidersKeys = Object.keys(chatModelProviders);
|
||||
chatModelProvider =
|
||||
chatModelProvidersKeys.find(
|
||||
(key) => Object.keys(chatModelProviders[key]).length > 0,
|
||||
) || chatModelProvidersKeys[0];
|
||||
|
||||
localStorage.setItem('chatModelProvider', chatModelProvider);
|
||||
}
|
||||
|
||||
if (
|
||||
chatModelProvider &&
|
||||
chatModelProvider != 'custom_openai' &&
|
||||
!chatModelProviders[chatModelProvider][chatModel]
|
||||
) {
|
||||
chatModel = Object.keys(chatModelProviders[chatModelProvider])[0];
|
||||
chatModel = Object.keys(
|
||||
chatModelProviders[
|
||||
Object.keys(chatModelProviders[chatModelProvider]).length > 0
|
||||
? chatModelProvider
|
||||
: Object.keys(chatModelProviders)[0]
|
||||
],
|
||||
)[0];
|
||||
localStorage.setItem('chatModel', chatModel);
|
||||
}
|
||||
|
||||
@ -160,6 +205,7 @@ const useSocket = (
|
||||
wsURL.search = searchParams.toString();
|
||||
|
||||
const ws = new WebSocket(wsURL.toString());
|
||||
wsRef.current = ws;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (ws.readyState !== 1) {
|
||||
@ -169,46 +215,94 @@ const useSocket = (
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[DEBUG] open');
|
||||
clearTimeout(timeoutId);
|
||||
setIsWSReady(true);
|
||||
};
|
||||
ws.addEventListener('message', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'signal' && data.data === 'open') {
|
||||
const interval = setInterval(() => {
|
||||
if (ws.readyState === 1) {
|
||||
setIsWSReady(true);
|
||||
setError(false);
|
||||
if (retryCountRef.current > 0) {
|
||||
toast.success('Connection restored.');
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 5);
|
||||
clearTimeout(timeoutId);
|
||||
console.debug(new Date(), 'ws:connected');
|
||||
}
|
||||
if (data.type === 'error') {
|
||||
isConnectionErrorRef.current = true;
|
||||
setError(true);
|
||||
toast.error(data.data);
|
||||
}
|
||||
});
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
setError(true);
|
||||
setIsWSReady(false);
|
||||
toast.error('WebSocket connection error.');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timeoutId);
|
||||
setError(true);
|
||||
console.log('[DEBUG] closed');
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'error') {
|
||||
toast.error(data.data);
|
||||
setIsWSReady(false);
|
||||
console.debug(new Date(), 'ws:disconnected');
|
||||
if (!isCleaningUpRef.current && !isConnectionErrorRef.current) {
|
||||
toast.error('Connection lost. Attempting to reconnect...');
|
||||
attemptReconnect();
|
||||
}
|
||||
})
|
||||
|
||||
setWs(ws);
|
||||
};
|
||||
|
||||
connectWs();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ws?.readyState === 1) {
|
||||
ws?.close();
|
||||
console.log('[DEBUG] closed');
|
||||
};
|
||||
} catch (error) {
|
||||
console.debug(new Date(), 'ws:error', error);
|
||||
setIsWSReady(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
};
|
||||
}, [ws, url, setIsWSReady, setError]);
|
||||
|
||||
return ws;
|
||||
const attemptReconnect = () => {
|
||||
retryCountRef.current += 1;
|
||||
|
||||
if (retryCountRef.current > MAX_RETRIES) {
|
||||
console.debug(new Date(), 'ws:max_retries');
|
||||
setError(true);
|
||||
toast.error(
|
||||
'Unable to connect to server after multiple attempts. Please refresh the page to try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const backoffDelay = getBackoffDelay(retryCountRef.current);
|
||||
console.debug(
|
||||
new Date(),
|
||||
`ws:retry attempt=${retryCountRef.current}/${MAX_RETRIES} delay=${backoffDelay}ms`,
|
||||
);
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectWs();
|
||||
}, backoffDelay);
|
||||
};
|
||||
|
||||
connectWs();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
isCleaningUpRef.current = true;
|
||||
console.debug(new Date(), 'ws:cleanup');
|
||||
}
|
||||
};
|
||||
}, [url, setIsWSReady, setError]);
|
||||
|
||||
return wsRef.current;
|
||||
};
|
||||
|
||||
const loadMessages = async (
|
||||
@ -218,6 +312,8 @@ const loadMessages = async (
|
||||
setChatHistory: (history: [string, string][]) => void,
|
||||
setFocusMode: (mode: string) => void,
|
||||
setNotFound: (notFound: boolean) => void,
|
||||
setFiles: (files: File[]) => void,
|
||||
setFileIds: (fileIds: string[]) => void,
|
||||
) => {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/chats/${chatId}`,
|
||||
@ -250,10 +346,21 @@ const loadMessages = async (
|
||||
return [msg.role, msg.content];
|
||||
}) as [string, string][];
|
||||
|
||||
console.log('[DEBUG] messages loaded');
|
||||
console.debug(new Date(), 'app:messages_loaded');
|
||||
|
||||
document.title = messages[0].content;
|
||||
|
||||
const files = data.chat.files.map((file: any) => {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileExtension: file.name.split('.').pop(),
|
||||
fileId: file.fileId,
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(files);
|
||||
setFileIds(files.map((file: File) => file.fileId));
|
||||
|
||||
setChatHistory(history);
|
||||
setFocusMode(data.chat.focusMode);
|
||||
setIsMessagesLoaded(true);
|
||||
@ -282,12 +389,18 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||
|
||||
const [focusMode, setFocusMode] = useState('webSearch');
|
||||
const [optimizationMode, setOptimizationMode] = useState('speed');
|
||||
|
||||
const [isMessagesLoaded, setIsMessagesLoaded] = useState(false);
|
||||
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
chatId &&
|
||||
@ -302,6 +415,8 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
setChatHistory,
|
||||
setFocusMode,
|
||||
setNotFound,
|
||||
setFiles,
|
||||
setFileIds,
|
||||
);
|
||||
} else if (!chatId) {
|
||||
setNewChatCreated(true);
|
||||
@ -311,6 +426,16 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ws?.readyState === 1) {
|
||||
ws.close();
|
||||
console.debug(new Date(), 'ws:cleanup');
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -320,11 +445,19 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
useEffect(() => {
|
||||
if (isMessagesLoaded && isWSReady) {
|
||||
setIsReady(true);
|
||||
console.debug(new Date(), 'app:ready');
|
||||
} else {
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [isMessagesLoaded, isWSReady]);
|
||||
|
||||
const sendMessage = async (message: string) => {
|
||||
const sendMessage = async (message: string, messageId?: string) => {
|
||||
if (loading) return;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
toast.error('Cannot send message while disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessageAppeared(false);
|
||||
|
||||
@ -332,16 +465,19 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
let recievedMessage = '';
|
||||
let added = false;
|
||||
|
||||
const messageId = crypto.randomBytes(7).toString('hex');
|
||||
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
|
||||
|
||||
ws?.send(
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: {
|
||||
messageId: messageId,
|
||||
chatId: chatId!,
|
||||
content: message,
|
||||
},
|
||||
files: fileIds,
|
||||
focusMode: focusMode,
|
||||
optimizationMode: optimizationMode,
|
||||
history: [...chatHistory, ['human', message]],
|
||||
}),
|
||||
);
|
||||
@ -443,6 +579,17 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
||||
|
||||
if (autoImageSearch === 'true') {
|
||||
document.getElementById('search-images')?.click();
|
||||
}
|
||||
|
||||
if (autoVideoSearch === 'true') {
|
||||
document.getElementById('search-videos')?.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -463,40 +610,51 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
return [...prev.slice(0, messages.length > 2 ? index - 1 : 0)];
|
||||
});
|
||||
|
||||
sendMessage(message.content);
|
||||
sendMessage(message.content, message.messageId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && initialMessage) {
|
||||
if (isReady && initialMessage && ws?.readyState === 1) {
|
||||
sendMessage(initialMessage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isReady, initialMessage]);
|
||||
}, [ws?.readyState, isReady, initialMessage, isWSReady]);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="dark:text-white/70 text-black/70 text-sm">
|
||||
Failed to connect to the server. Please try again later.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
||||
<Link href="/settings">
|
||||
<Settings className="cursor-pointer lg:hidden" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="dark:text-white/70 text-black/70 text-sm">
|
||||
Failed to connect to the server. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isReady ? (
|
||||
notFound ? (
|
||||
<Error statusCode={404} />
|
||||
<NextError statusCode={404} />
|
||||
) : (
|
||||
<div>
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<Navbar messages={messages} />
|
||||
<Navbar chatId={chatId!} messages={messages} />
|
||||
<Chat
|
||||
loading={loading}
|
||||
messages={messages}
|
||||
sendMessage={sendMessage}
|
||||
messageAppeared={messageAppeared}
|
||||
rewrite={rewrite}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -504,6 +662,12 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
||||
sendMessage={sendMessage}
|
||||
focusMode={focusMode}
|
||||
setFocusMode={setFocusMode}
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,5 +1,13 @@
|
||||
import { Delete, Trash } from 'lucide-react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Trash } from 'lucide-react';
|
||||
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';
|
||||
@ -8,10 +16,12 @@ const DeleteChat = ({
|
||||
chatId,
|
||||
chats,
|
||||
setChats,
|
||||
redirect = false,
|
||||
}: {
|
||||
chatId: string;
|
||||
chats: Chat[];
|
||||
setChats: (chats: Chat[]) => void;
|
||||
redirect?: boolean;
|
||||
}) => {
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -36,6 +46,10 @@ const DeleteChat = ({
|
||||
const newChats = chats.filter((chat) => chat.id !== chatId);
|
||||
|
||||
setChats(newChats);
|
||||
|
||||
if (redirect) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
@ -64,10 +78,10 @@ const DeleteChat = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Backdrop className="fixed inset-0 bg-black/30" />
|
||||
<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">
|
||||
<Transition.Child
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
@ -76,13 +90,13 @@ const DeleteChat = ({
|
||||
leaveFrom="opacity-100 scale-200"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel 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">
|
||||
<Dialog.Title className="text-lg font-medium leading-6 dark:text-white">
|
||||
<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
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm dark:text-white/70 text-black/70">
|
||||
</DialogTitle>
|
||||
<Description className="text-sm dark:text-white/70 text-black/70">
|
||||
Are you sure you want to delete this chat?
|
||||
</Dialog.Description>
|
||||
</Description>
|
||||
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -101,8 +115,8 @@ const DeleteChat = ({
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
@ -1,16 +1,39 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import EmptyChatMessageInput from './EmptyChatMessageInput';
|
||||
import { useState } from 'react';
|
||||
import { File } from './ChatWindow';
|
||||
import Link from 'next/link';
|
||||
|
||||
const EmptyChat = ({
|
||||
sendMessage,
|
||||
focusMode,
|
||||
setFocusMode,
|
||||
optimizationMode,
|
||||
setOptimizationMode,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
sendMessage: (message: string) => void;
|
||||
focusMode: string;
|
||||
setFocusMode: (mode: string) => void;
|
||||
optimizationMode: string;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
|
||||
<Link href="/settings">
|
||||
<Settings className="cursor-pointer lg:hidden" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-8">
|
||||
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
|
||||
Research begins here.
|
||||
@ -19,6 +42,12 @@ const EmptyChat = ({
|
||||
sendMessage={sendMessage}
|
||||
focusMode={focusMode}
|
||||
setFocusMode={setFocusMode}
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,31 +3,55 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import CopilotToggle from './MessageInputActions/Copilot';
|
||||
import Focus from './MessageInputActions/Focus';
|
||||
import Optimization from './MessageInputActions/Optimization';
|
||||
import Attach from './MessageInputActions/Attach';
|
||||
import { File } from './ChatWindow';
|
||||
|
||||
const EmptyChatMessageInput = ({
|
||||
sendMessage,
|
||||
focusMode,
|
||||
setFocusMode,
|
||||
optimizationMode,
|
||||
setOptimizationMode,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
sendMessage: (message: string) => void;
|
||||
focusMode: string;
|
||||
setFocusMode: (mode: string) => void;
|
||||
optimizationMode: string;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [copilotEnabled, setCopilotEnabled] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
const isInputFocused =
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
activeElement?.hasAttribute('contenteditable');
|
||||
|
||||
if (e.key === '/' && !isInputFocused) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
inputRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
@ -59,14 +83,20 @@ const EmptyChatMessageInput = ({
|
||||
placeholder="Ask anything..."
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between mt-4">
|
||||
<div className="flex flex-row items-center space-x-1 -mx-2">
|
||||
<div className="flex flex-row items-center space-x-2 lg:space-x-4">
|
||||
<Focus focusMode={focusMode} setFocusMode={setFocusMode} />
|
||||
{/* <Attach /> */}
|
||||
<Attach
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-4 -mx-2">
|
||||
<CopilotToggle
|
||||
copilotEnabled={copilotEnabled}
|
||||
setCopilotEnabled={setCopilotEnabled}
|
||||
<div className="flex flex-row items-center space-x-1 sm:space-x-4">
|
||||
<Optimization
|
||||
optimizationMode={optimizationMode}
|
||||
setOptimizationMode={setOptimizationMode}
|
||||
/>
|
||||
<button
|
||||
disabled={message.trim().length === 0}
|
||||
|
@ -68,7 +68,7 @@ const MessageBox = ({
|
||||
return (
|
||||
<div>
|
||||
{message.role === 'user' && (
|
||||
<div className={cn('w-full', messageIndex === 0 ? 'pt-16' : 'pt-8')}>
|
||||
<div className={cn('w-full', messageIndex === 0 ? 'pt-16' : 'pt-8', 'break-words')}>
|
||||
<h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12">
|
||||
{message.content}
|
||||
</h2>
|
||||
@ -107,8 +107,8 @@ const MessageBox = ({
|
||||
</div>
|
||||
<Markdown
|
||||
className={cn(
|
||||
'prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0',
|
||||
'max-w-none break-words text-black dark:text-white text-sm md:text-base font-medium',
|
||||
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark:text-white',
|
||||
)}
|
||||
>
|
||||
{parsedMessage}
|
||||
@ -186,10 +186,10 @@ const MessageBox = ({
|
||||
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
|
||||
<SearchImages
|
||||
query={history[messageIndex - 1].content}
|
||||
chat_history={history.slice(0, messageIndex - 1)}
|
||||
chatHistory={history.slice(0, messageIndex - 1)}
|
||||
/>
|
||||
<SearchVideos
|
||||
chat_history={history.slice(0, messageIndex - 1)}
|
||||
chatHistory={history.slice(0, messageIndex - 1)}
|
||||
query={history[messageIndex - 1].content}
|
||||
/>
|
||||
</div>
|
||||
|
@ -4,13 +4,23 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import Attach from './MessageInputActions/Attach';
|
||||
import CopilotToggle from './MessageInputActions/Copilot';
|
||||
import { File } from './ChatWindow';
|
||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||
|
||||
const MessageInput = ({
|
||||
sendMessage,
|
||||
loading,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
sendMessage: (message: string) => void;
|
||||
loading: boolean;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: File[];
|
||||
setFiles: (files: File[]) => void;
|
||||
}) => {
|
||||
const [copilotEnabled, setCopilotEnabled] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
@ -27,14 +37,21 @@ const MessageInput = ({
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
const isInputFocused =
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
activeElement?.hasAttribute('contenteditable');
|
||||
|
||||
if (e.key === '/' && !isInputFocused) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
@ -62,7 +79,14 @@ const MessageInput = ({
|
||||
mode === 'multi' ? 'flex-col rounded-lg' : 'flex-row rounded-full',
|
||||
)}
|
||||
>
|
||||
{mode === 'single' && <Attach />}
|
||||
{mode === 'single' && (
|
||||
<AttachSmall
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
)}
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
@ -89,7 +113,12 @@ const MessageInput = ({
|
||||
)}
|
||||
{mode === 'multi' && (
|
||||
<div className="flex flex-row items-center justify-between w-full pt-2">
|
||||
<Attach />
|
||||
<AttachSmall
|
||||
fileIds={fileIds}
|
||||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<CopilotToggle
|
||||
copilotEnabled={copilotEnabled}
|
||||
|
@ -1,12 +1,183 @@
|
||||
import { CopyPlus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { File as FileType } from '../ChatWindow';
|
||||
|
||||
const Attach = () => {
|
||||
return (
|
||||
const Attach = ({
|
||||
fileIds,
|
||||
setFileIds,
|
||||
showText,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
showText?: boolean;
|
||||
files: FileType[];
|
||||
setFiles: (files: FileType[]) => void;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true);
|
||||
const data = new FormData();
|
||||
|
||||
for (let i = 0; i < e.target.files!.length; i++) {
|
||||
data.append('files', e.target.files![i]);
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProvider',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModel');
|
||||
|
||||
data.append('embedding_model_provider', embeddingModelProvider!);
|
||||
data.append('embedding_model', embeddingModel!);
|
||||
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-between space-x-1">
|
||||
<LoaderCircle size={18} className="text-sky-400 animate-spin" />
|
||||
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
|
||||
Uploading..
|
||||
</p>
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-between space-x-1 p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
|
||||
files.length > 0 ? '-ml-2 lg:-ml-3' : '',
|
||||
)}
|
||||
>
|
||||
{files.length > 1 && (
|
||||
<>
|
||||
<File size={19} className="text-sky-400" />
|
||||
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
|
||||
{files.length} files
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{files.length === 1 && (
|
||||
<>
|
||||
<File size={18} className="text-sky-400" />
|
||||
<p className="text-sky-400 text-xs font-medium">
|
||||
{files[0].fileName.length > 10
|
||||
? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) +
|
||||
'...' +
|
||||
files[0].fileExtension
|
||||
: files[0].fileName}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white',
|
||||
showText ? '' : 'p-2',
|
||||
)}
|
||||
>
|
||||
<CopyPlus />
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<CopyPlus size={showText ? 18 : undefined} />
|
||||
{showText && <p className="text-xs font-medium pl-[1px]">Attach</p>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
153
ui/components/MessageInputActions/AttachSmall.tsx
Normal file
153
ui/components/MessageInputActions/AttachSmall.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { File as FileType } from '../ChatWindow';
|
||||
|
||||
const AttachSmall = ({
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: FileType[];
|
||||
setFiles: (files: FileType[]) => void;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true);
|
||||
const data = new FormData();
|
||||
|
||||
for (let i = 0; i < e.target.files!.length; i++) {
|
||||
data.append('files', e.target.files![i]);
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProvider',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModel');
|
||||
|
||||
data.append('embedding_model_provider', embeddingModelProvider!);
|
||||
data.append('embedding_model', embeddingModel!);
|
||||
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-between space-x-1 p-1">
|
||||
<LoaderCircle size={20} className="text-sky-400 animate-spin" />
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={20} className="text-sky-400" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] bottom-14 -ml-3">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white p-1"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<CopyPlus size={20} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachSmall;
|
@ -7,7 +7,12 @@ import {
|
||||
SwatchBook,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { SiReddit, SiYoutube } from '@icons-pack/react-simple-icons';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
@ -70,23 +75,26 @@ const Focus = ({
|
||||
setFocusMode: (mode: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Popover className="fixed w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<Popover.Button
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg mt-[6.5px]">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
className=" text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{focusMode !== 'webSearch' ? (
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{focusModes.find((mode) => mode.key === focusMode)?.icon}
|
||||
<p className="text-xs font-medium">
|
||||
<p className="text-xs font-medium hidden lg:block">
|
||||
{focusModes.find((mode) => mode.key === focusMode)?.title}
|
||||
</p>
|
||||
<ChevronDown size={20} />
|
||||
<ChevronDown size={20} className="-translate-x-1" />
|
||||
</div>
|
||||
) : (
|
||||
<ScanEye />
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<ScanEye size={20} />
|
||||
<p className="text-xs font-medium hidden lg:block">Focus</p>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Button>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
@ -96,10 +104,10 @@ const Focus = ({
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute z-10 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-1 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-2 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] left-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
{focusModes.map((mode, i) => (
|
||||
<Popover.Button
|
||||
<PopoverButton
|
||||
onClick={() => setFocusMode(mode.key)}
|
||||
key={i}
|
||||
className={cn(
|
||||
@ -123,10 +131,10 @@ const Focus = ({
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</Popover.Button>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
|
104
ui/components/MessageInputActions/Optimization.tsx
Normal file
104
ui/components/MessageInputActions/Optimization.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { ChevronDown, Sliders, Star, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const OptimizationModes = [
|
||||
{
|
||||
key: 'speed',
|
||||
title: 'Speed',
|
||||
description: 'Prioritize speed and get the quickest possible answer.',
|
||||
icon: <Zap size={20} className="text-[#FF9800]" />,
|
||||
},
|
||||
{
|
||||
key: 'balanced',
|
||||
title: 'Balanced',
|
||||
description: 'Find the right balance between speed and accuracy',
|
||||
icon: <Sliders size={20} className="text-[#4CAF50]" />,
|
||||
},
|
||||
{
|
||||
key: 'quality',
|
||||
title: 'Quality (Soon)',
|
||||
description: 'Get the most thorough and accurate answer',
|
||||
icon: (
|
||||
<Star
|
||||
size={16}
|
||||
className="text-[#2196F3] dark:text-[#BBDEFB] fill-[#BBDEFB] dark:fill-[#2196F3]"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Optimization = ({
|
||||
optimizationMode,
|
||||
setOptimizationMode,
|
||||
}: {
|
||||
optimizationMode: string;
|
||||
setOptimizationMode: (mode: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.icon
|
||||
}
|
||||
<p className="text-xs font-medium">
|
||||
{
|
||||
OptimizationModes.find((mode) => mode.key === optimizationMode)
|
||||
?.title
|
||||
}
|
||||
</p>
|
||||
<ChevronDown size={20} />
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] right-0">
|
||||
<div className="flex flex-col gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
|
||||
{OptimizationModes.map((mode, i) => (
|
||||
<PopoverButton
|
||||
onClick={() => setOptimizationMode(mode.key)}
|
||||
key={i}
|
||||
disabled={mode.key === 'quality'}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
mode.key === 'quality' && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
|
||||
{mode.icon}
|
||||
<p className="text-sm font-medium">{mode.title}</p>
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
||||
{mode.description}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Optimization;
|
@ -1,6 +1,13 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react';
|
||||
import { Document } from '@langchain/core/documents';
|
||||
import { File } from 'lucide-react';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
@ -30,13 +37,19 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
</p>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
{source.metadata.url === 'File' ? (
|
||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
||||
<File size={12} className="text-white/70" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
|
||||
</p>
|
||||
@ -54,16 +67,21 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
{sources.slice(3, 6).map((source, i) => (
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
key={i}
|
||||
/>
|
||||
))}
|
||||
{sources.slice(3, 6).map((source, i) => {
|
||||
return source.metadata.url === 'File' ? (
|
||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
||||
<File size={12} className="text-white/70" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
View {sources.length - 3} more
|
||||
@ -74,7 +92,7 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
@ -83,10 +101,10 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
leaveFrom="opacity-100 scale-200"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel 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">
|
||||
<Dialog.Title className="text-lg font-medium leading-6 dark:text-white">
|
||||
<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">
|
||||
Sources
|
||||
</Dialog.Title>
|
||||
</DialogTitle>
|
||||
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
|
||||
{sources.map((source, i) => (
|
||||
<a
|
||||
@ -100,13 +118,19 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
</p>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
{source.metadata.url === 'File' ? (
|
||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
||||
<File size={12} className="text-white/70" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt="favicon"
|
||||
className="rounded-lg h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{source.metadata.url.replace(
|
||||
/.+\/\/|www.|\..+/g,
|
||||
@ -122,8 +146,8 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
@ -2,8 +2,15 @@ import { Clock, Edit, Share, Trash } from 'lucide-react';
|
||||
import { Message } from './ChatWindow';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { formatTimeDifference } from '@/lib/utils';
|
||||
import DeleteChat from './DeleteChat';
|
||||
|
||||
const Navbar = ({ messages }: { messages: Message[] }) => {
|
||||
const Navbar = ({
|
||||
chatId,
|
||||
messages,
|
||||
}: {
|
||||
messages: Message[];
|
||||
chatId: string;
|
||||
}) => {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [timeAgo, setTimeAgo] = useState<string>('');
|
||||
|
||||
@ -39,10 +46,12 @@ const Navbar = ({ messages }: { messages: Message[] }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-[104px] lg:pr-6 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm text-black dark:text-white/70 border-b bg-light-primary dark:bg-dark-primary border-light-100 dark:border-dark-200">
|
||||
<Edit
|
||||
size={17}
|
||||
<a
|
||||
href="/"
|
||||
className="active:scale-95 transition duration-100 cursor-pointer lg:hidden"
|
||||
/>
|
||||
>
|
||||
<Edit size={17} />
|
||||
</a>
|
||||
<div className="hidden lg:flex flex-row items-center justify-center space-x-2">
|
||||
<Clock size={17} />
|
||||
<p className="text-xs">{timeAgo} ago</p>
|
||||
@ -54,10 +63,7 @@ const Navbar = ({ messages }: { messages: Message[] }) => {
|
||||
size={17}
|
||||
className="active:scale-95 transition duration-100 cursor-pointer"
|
||||
/>
|
||||
<Trash
|
||||
size={17}
|
||||
className="text-red-400 active:scale-95 transition duration-100 cursor-pointer"
|
||||
/>
|
||||
<DeleteChat redirect chatId={chatId} chats={[]} setChats={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -13,10 +13,10 @@ type Image = {
|
||||
|
||||
const SearchImages = ({
|
||||
query,
|
||||
chat_history,
|
||||
chatHistory,
|
||||
}: {
|
||||
query: string;
|
||||
chat_history: Message[];
|
||||
chatHistory: Message[];
|
||||
}) => {
|
||||
const [images, setImages] = useState<Image[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -27,12 +27,16 @@ const SearchImages = ({
|
||||
<>
|
||||
{!loading && images === null && (
|
||||
<button
|
||||
id="search-images"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
|
||||
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
|
||||
const customOpenAIKey = localStorage.getItem('openAIApiKey');
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/images`,
|
||||
{
|
||||
@ -42,16 +46,22 @@ const SearchImages = ({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
chat_history: chat_history,
|
||||
chat_model_provider: chatModelProvider,
|
||||
chat_model: chatModel,
|
||||
chatHistory: chatHistory,
|
||||
chatModel: {
|
||||
provider: chatModelProvider,
|
||||
model: chatModel,
|
||||
...(chatModelProvider === 'custom_openai' && {
|
||||
customOpenAIBaseURL: customOpenAIBaseURL,
|
||||
customOpenAIKey: customOpenAIKey,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const images = data.images;
|
||||
const images = data.images ?? [];
|
||||
setImages(images);
|
||||
setSlides(
|
||||
images.map((image: Image) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { PlayCircle, PlayIcon, PlusIcon, VideoIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
|
||||
import 'yet-another-react-lightbox/styles.css';
|
||||
import { Message } from './ChatWindow';
|
||||
@ -26,26 +26,32 @@ declare module 'yet-another-react-lightbox' {
|
||||
|
||||
const Searchvideos = ({
|
||||
query,
|
||||
chat_history,
|
||||
chatHistory,
|
||||
}: {
|
||||
query: string;
|
||||
chat_history: Message[];
|
||||
chatHistory: Message[];
|
||||
}) => {
|
||||
const [videos, setVideos] = useState<Video[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [slides, setSlides] = useState<VideoSlide[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loading && videos === null && (
|
||||
<button
|
||||
id="search-videos"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
|
||||
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
|
||||
const customOpenAIKey = localStorage.getItem('openAIApiKey');
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/videos`,
|
||||
{
|
||||
@ -55,16 +61,22 @@ const Searchvideos = ({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
chat_history: chat_history,
|
||||
chat_model_provider: chatModelProvider,
|
||||
chat_model: chatModel,
|
||||
chatHistory: chatHistory,
|
||||
chatModel: {
|
||||
provider: chatModelProvider,
|
||||
model: chatModel,
|
||||
...(chatModelProvider === 'custom_openai' && {
|
||||
customOpenAIBaseURL: customOpenAIBaseURL,
|
||||
customOpenAIKey: customOpenAIKey,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const videos = data.videos;
|
||||
const videos = data.videos ?? [];
|
||||
setVideos(videos);
|
||||
setSlides(
|
||||
videos.map((video: Video) => {
|
||||
@ -173,18 +185,39 @@ const Searchvideos = ({
|
||||
open={open}
|
||||
close={() => setOpen(false)}
|
||||
slides={slides}
|
||||
index={currentIndex}
|
||||
on={{
|
||||
view: ({ index }) => {
|
||||
const previousIframe = videoRefs.current[currentIndex];
|
||||
if (previousIframe?.contentWindow) {
|
||||
previousIframe.contentWindow.postMessage(
|
||||
'{"event":"command","func":"pauseVideo","args":""}',
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentIndex(index);
|
||||
},
|
||||
}}
|
||||
render={{
|
||||
slide: ({ slide }) =>
|
||||
slide.type === 'video-slide' ? (
|
||||
slide: ({ slide }) => {
|
||||
const index = slides.findIndex((s) => s === slide);
|
||||
return slide.type === 'video-slide' ? (
|
||||
<div className="h-full w-full flex flex-row items-center justify-center">
|
||||
<iframe
|
||||
src={slide.iframe_src}
|
||||
src={`${slide.iframe_src}${slide.iframe_src.includes('?') ? '&' : '?'}enablejsapi=1`}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
videoRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className="aspect-video max-h-[95vh] w-[95vw] rounded-2xl md:w-[80vw]"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
) : null;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -1,628 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, Switch, Transition } from '@headlessui/react';
|
||||
import { CloudUpload, RefreshCcw, RefreshCw } from 'lucide-react';
|
||||
import React, {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useState,
|
||||
type SelectHTMLAttributes,
|
||||
} from 'react';
|
||||
import ThemeSwitcher from './theme/Switcher';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = ({ className, ...restProps }: InputProps) => {
|
||||
return (
|
||||
<input
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: { value: string; label: string; disabled?: boolean }[];
|
||||
}
|
||||
|
||||
export const Select = ({ className, options, ...restProps }: SelectProps) => {
|
||||
return (
|
||||
<select
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map(({ label, value, disabled }) => {
|
||||
return (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsType {
|
||||
chatModelProviders: {
|
||||
[key: string]: string[];
|
||||
};
|
||||
embeddingModelProviders: {
|
||||
[key: string]: string[];
|
||||
};
|
||||
openaiApiKey: string;
|
||||
groqApiKey: string;
|
||||
anthropicApiKey: string;
|
||||
ollamaApiUrl: string;
|
||||
isCopilotEnabled: boolean;
|
||||
isDiscoverEnabled: boolean;
|
||||
isLibraryEnabled: boolean;
|
||||
}
|
||||
|
||||
const SettingsDialog = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) => {
|
||||
const [config, setConfig] = useState<SettingsType | null>(null);
|
||||
const [selectedChatModelProvider, setSelectedChatModelProvider] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedEmbeddingModelProvider, setSelectedEmbeddingModelProvider] =
|
||||
useState<string | null>(null);
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [customOpenAIApiKey, setCustomOpenAIApiKey] = useState<string>('');
|
||||
const [customOpenAIBaseURL, setCustomOpenAIBaseURL] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
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]?.[0]) ||
|
||||
'';
|
||||
const embeddingModelProvider =
|
||||
localStorage.getItem('embeddingModelProvider') ||
|
||||
defaultEmbeddingModelProvider ||
|
||||
'';
|
||||
const embeddingModel =
|
||||
localStorage.getItem('embeddingModel') ||
|
||||
(data.embeddingModelProviders &&
|
||||
data.embeddingModelProviders[embeddingModelProvider]?.[0]) ||
|
||||
'';
|
||||
|
||||
setSelectedChatModelProvider(chatModelProvider);
|
||||
setSelectedChatModel(chatModel);
|
||||
setSelectedEmbeddingModelProvider(embeddingModelProvider);
|
||||
setSelectedEmbeddingModel(embeddingModel);
|
||||
setCustomOpenAIApiKey(localStorage.getItem('openAIApiKey') || '');
|
||||
setCustomOpenAIBaseURL(localStorage.getItem('openAIBaseURL') || '');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${password}`,
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
toast.error('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('chatModelProvider', selectedChatModelProvider!);
|
||||
localStorage.setItem('chatModel', selectedChatModel!);
|
||||
localStorage.setItem(
|
||||
'embeddingModelProvider',
|
||||
selectedEmbeddingModelProvider!,
|
||||
);
|
||||
localStorage.setItem('embeddingModel', selectedEmbeddingModel!);
|
||||
localStorage.setItem('openAIApiKey', customOpenAIApiKey!);
|
||||
localStorage.setItem('openAIBaseURL', customOpenAIBaseURL!);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setIsOpen(false);
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-white/50 dark:bg-black/50" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
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"
|
||||
>
|
||||
<Dialog.Panel 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">
|
||||
{isPasswordValid && passwordSubmitted && (
|
||||
<>
|
||||
<Dialog.Title className="text-xl font-medium leading-6 dark:text-white">
|
||||
Settings
|
||||
</Dialog.Title>
|
||||
{config && !isLoading && (
|
||||
<div className="flex flex-col space-y-4 mt-6">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Theme
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Copilot enabled
|
||||
</p>
|
||||
<Switch
|
||||
checked={config.isCopilotEnabled}
|
||||
onChange={(checked) => {
|
||||
setConfig({
|
||||
...config,
|
||||
isCopilotEnabled: checked,
|
||||
});
|
||||
}}
|
||||
className="bg-light-secondary dark:bg-dark-secondary border border-light-200/70 dark:border-dark-200 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full active:scale-95 duration-200 transition cursor-pointer"
|
||||
>
|
||||
<span className="sr-only">Copilot</span>
|
||||
<span
|
||||
className={cn(
|
||||
config.isCopilotEnabled
|
||||
? 'translate-x-6 bg-[#24A0ED]'
|
||||
: 'translate-x-1 bg-black/50 dark:bg-white/50',
|
||||
'inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Discover enabled
|
||||
</p>
|
||||
<Switch
|
||||
checked={config.isDiscoverEnabled}
|
||||
onChange={(checked) => {
|
||||
setConfig({
|
||||
...config,
|
||||
isDiscoverEnabled: checked,
|
||||
});
|
||||
}}
|
||||
className="bg-light-secondary dark:bg-dark-secondary border border-light-200/70 dark:border-dark-200 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full active:scale-95 duration-200 transition cursor-pointer"
|
||||
>
|
||||
<span className="sr-only">Discover</span>
|
||||
<span
|
||||
className={cn(
|
||||
config.isDiscoverEnabled
|
||||
? 'translate-x-6 bg-[#24A0ED]'
|
||||
: 'translate-x-1 bg-black/50 dark:bg-white/50',
|
||||
'inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Library enabled
|
||||
</p>
|
||||
<Switch
|
||||
checked={config.isLibraryEnabled}
|
||||
onChange={(checked) => {
|
||||
setConfig({
|
||||
...config,
|
||||
isLibraryEnabled: checked,
|
||||
});
|
||||
}}
|
||||
className="bg-light-secondary dark:bg-dark-secondary border border-light-200/70 dark:border-dark-200 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full active:scale-95 duration-200 transition cursor-pointer"
|
||||
>
|
||||
<span className="sr-only">Library</span>
|
||||
<span
|
||||
className={cn(
|
||||
config.isLibraryEnabled
|
||||
? 'translate-x-6 bg-[#24A0ED]'
|
||||
: 'translate-x-1 bg-black/50 dark:bg-white/50',
|
||||
'inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
{config.chatModelProviders && (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Chat model Provider
|
||||
</p>
|
||||
<Select
|
||||
value={selectedChatModelProvider ?? undefined}
|
||||
onChange={(e) => {
|
||||
setSelectedChatModelProvider(e.target.value);
|
||||
if (e.target.value === 'custom_openai') {
|
||||
setSelectedChatModel('');
|
||||
} else {
|
||||
setSelectedChatModel(
|
||||
config.chatModelProviders[
|
||||
e.target.value
|
||||
][0],
|
||||
);
|
||||
}
|
||||
}}
|
||||
options={Object.keys(
|
||||
config.chatModelProviders,
|
||||
).map((provider) => ({
|
||||
value: provider,
|
||||
label:
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedChatModelProvider &&
|
||||
selectedChatModelProvider != 'custom_openai' && (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Chat Model
|
||||
</p>
|
||||
<Select
|
||||
value={selectedChatModel ?? undefined}
|
||||
onChange={(e) =>
|
||||
setSelectedChatModel(e.target.value)
|
||||
}
|
||||
options={(() => {
|
||||
const chatModelProvider =
|
||||
config.chatModelProviders[
|
||||
selectedChatModelProvider
|
||||
];
|
||||
|
||||
return chatModelProvider
|
||||
? chatModelProvider.length > 0
|
||||
? chatModelProvider.map((model) => ({
|
||||
value: model,
|
||||
label: model,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
value: '',
|
||||
label: 'No models available',
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: '',
|
||||
label:
|
||||
'Invalid provider, please check backend logs',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedChatModelProvider &&
|
||||
selectedChatModelProvider === 'custom_openai' && (
|
||||
<>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Model name
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Model name"
|
||||
defaultValue={selectedChatModel!}
|
||||
onChange={(e) =>
|
||||
setSelectedChatModel(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Custom OpenAI API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Custom OpenAI API Key"
|
||||
defaultValue={customOpenAIApiKey!}
|
||||
onChange={(e) =>
|
||||
setCustomOpenAIApiKey(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Custom OpenAI Base URL
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Custom OpenAI Base URL"
|
||||
defaultValue={customOpenAIBaseURL!}
|
||||
onChange={(e) =>
|
||||
setCustomOpenAIBaseURL(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Embedding models */}
|
||||
{config.embeddingModelProviders && (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Embedding model Provider
|
||||
</p>
|
||||
<Select
|
||||
value={
|
||||
selectedEmbeddingModelProvider ?? undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
setSelectedEmbeddingModelProvider(
|
||||
e.target.value,
|
||||
);
|
||||
setSelectedEmbeddingModel(
|
||||
config.embeddingModelProviders[
|
||||
e.target.value
|
||||
][0],
|
||||
);
|
||||
}}
|
||||
options={Object.keys(
|
||||
config.embeddingModelProviders,
|
||||
).map((provider) => ({
|
||||
label:
|
||||
provider.charAt(0).toUpperCase() +
|
||||
provider.slice(1),
|
||||
value: provider,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedEmbeddingModelProvider && (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Embedding Model
|
||||
</p>
|
||||
<Select
|
||||
value={selectedEmbeddingModel ?? undefined}
|
||||
onChange={(e) =>
|
||||
setSelectedEmbeddingModel(e.target.value)
|
||||
}
|
||||
options={(() => {
|
||||
const embeddingModelProvider =
|
||||
config.embeddingModelProviders[
|
||||
selectedEmbeddingModelProvider
|
||||
];
|
||||
|
||||
return embeddingModelProvider
|
||||
? embeddingModelProvider.length > 0
|
||||
? embeddingModelProvider.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
label:
|
||||
'No embedding models available',
|
||||
value: '',
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label:
|
||||
'Invalid provider, please check backend logs',
|
||||
value: '',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
OpenAI API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="OpenAI API Key"
|
||||
defaultValue={config.openaiApiKey}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
openaiApiKey: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Ollama API URL
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Ollama API URL"
|
||||
defaultValue={config.ollamaApiUrl}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
ollamaApiUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
GROQ API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="GROQ API Key"
|
||||
defaultValue={config.groqApiKey}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
groqApiKey: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Anthropic API Key
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Anthropic API key"
|
||||
defaultValue={config.anthropicApiKey}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
anthropicApiKey: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="w-full flex items-center justify-center mt-6 text-black/70 dark:text-white/70 py-6">
|
||||
<RefreshCcw className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full mt-6 space-y-2">
|
||||
<p className="text-xs text-black/50 dark:text-white/50">
|
||||
We'll refresh the page after updating the settings.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="bg-[#24A0ED] flex flex-row items-center space-x-2 text-white disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#ececec21] rounded-full px-4 py-2"
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<RefreshCw size={20} className="animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!passwordSubmitted && (
|
||||
<>
|
||||
<Dialog.Title className="text-sm dark:font-white/80 font-black/80">
|
||||
Enter the password to access the settings
|
||||
</Dialog.Title>
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="mt-4"
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!isPasswordValid && (
|
||||
<p className="text-xs text-red-500 mt-2">
|
||||
Password is incorrect
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={isLoading}
|
||||
className="bg-[#24A0ED] flex flex-row items-center text-xs mt-4 text-white disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#ececec21] rounded-full px-4 py-2"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDialog;
|
@ -6,7 +6,6 @@ import Link from 'next/link';
|
||||
import { useSelectedLayoutSegments } from 'next/navigation';
|
||||
import React, { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import Layout from './Layout';
|
||||
import SettingsDialog from './SettingsDialog';
|
||||
|
||||
export type Preferences = {
|
||||
isLibraryEnabled: boolean;
|
||||
@ -123,15 +122,9 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
)}
|
||||
</VerticalIconContainer>
|
||||
|
||||
<Settings
|
||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
|
||||
<SettingsDialog
|
||||
isOpen={isSettingsOpen}
|
||||
setIsOpen={setIsSettingsOpen}
|
||||
/>
|
||||
<Link href="/settings">
|
||||
<Settings className="cursor-pointer" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { SunIcon, MoonIcon, MonitorIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Select } from '../SettingsDialog';
|
||||
import Select from '../ui/Select';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
|
28
ui/components/ui/Select.tsx
Normal file
28
ui/components/ui/Select.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: { value: string; label: string; disabled?: boolean }[];
|
||||
}
|
||||
|
||||
export const Select = ({ className, options, ...restProps }: SelectProps) => {
|
||||
return (
|
||||
<select
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map(({ label, value, disabled }) => {
|
||||
return (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
Reference in New Issue
Block a user