feat: add in prettier and format code for consistency (#168)

This commit is contained in:
Simon Holmes 2023-03-26 05:13:18 +00:00 committed by GitHub
parent b843f6e0e0
commit d6973b9ccc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 1140 additions and 4573 deletions

View File

@ -1,12 +1,25 @@
import { Conversation, ErrorMessage, KeyValuePair, Message, OpenAIModel } from "@/types"; import {
import { FC, MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; Conversation,
import { useTranslation } from "next-i18next"; ErrorMessage,
import { ChatInput } from "./ChatInput"; KeyValuePair,
import { ChatLoader } from "./ChatLoader"; Message,
import { ChatMessage } from "./ChatMessage"; OpenAIModel,
import { ErrorMessageDiv } from "./ErrorMessageDiv"; } from '@/types';
import { ModelSelect } from "./ModelSelect"; import {
import { SystemPrompt } from "./SystemPrompt"; FC,
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'next-i18next';
import { ChatInput } from './ChatInput';
import { ChatLoader } from './ChatLoader';
import { ChatMessage } from './ChatMessage';
import { ErrorMessageDiv } from './ErrorMessageDiv';
import { ModelSelect } from './ModelSelect';
import { SystemPrompt } from './SystemPrompt';
interface Props { interface Props {
conversation: Conversation; conversation: Conversation;
@ -17,14 +30,31 @@ interface Props {
modelError: ErrorMessage | null; modelError: ErrorMessage | null;
messageError: boolean; messageError: boolean;
loading: boolean; loading: boolean;
lightMode: "light" | "dark"; lightMode: 'light' | 'dark';
onSend: (message: Message, deleteCount?: number) => void; onSend: (message: Message, deleteCount?: number) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
onEditMessage: (message: Message, messageIndex: number) => void; onEditMessage: (message: Message, messageIndex: number) => void;
stopConversationRef: MutableRefObject<boolean>; stopConversationRef: MutableRefObject<boolean>;
} }
export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKeyIsSet, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, onEditMessage, stopConversationRef }) => { export const Chat: FC<Props> = ({
conversation,
models,
apiKey,
serverSideApiKeyIsSet,
messageIsStreaming,
modelError,
messageError,
loading,
lightMode,
onSend,
onUpdateConversation,
onEditMessage,
stopConversationRef,
}) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const [currentMessage, setCurrentMessage] = useState<Message>(); const [currentMessage, setCurrentMessage] = useState<Message>();
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
@ -42,7 +72,8 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
const handleScroll = () => { const handleScroll = () => {
if (chatContainerRef.current) { if (chatContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current; const { scrollTop, scrollHeight, clientHeight } =
chatContainerRef.current;
const bottomTolerance = 5; const bottomTolerance = 5;
if (scrollTop + clientHeight < scrollHeight - bottomTolerance) { if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
@ -62,43 +93,60 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
const chatContainer = chatContainerRef.current; const chatContainer = chatContainerRef.current;
if (chatContainer) { if (chatContainer) {
chatContainer.addEventListener("scroll", handleScroll); chatContainer.addEventListener('scroll', handleScroll);
return () => { return () => {
chatContainer.removeEventListener("scroll", handleScroll); chatContainer.removeEventListener('scroll', handleScroll);
}; };
} }
}, []); }, []);
return ( return (
<div className="relative flex-1 overflow-none dark:bg-[#343541] bg-white"> <div className="overflow-none relative flex-1 bg-white dark:bg-[#343541]">
{!(apiKey || serverSideApiKeyIsSet) ? ( {!(apiKey || serverSideApiKeyIsSet) ? (
<div className="flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6"> <div className="mx-auto flex h-full w-[300px] flex-col justify-center space-y-6 sm:w-[500px]">
<div className="text-2xl font-semibold text-center text-gray-800 dark:text-gray-100">{t('OpenAI API Key Required')}</div> <div className="text-center text-2xl font-semibold text-gray-800 dark:text-gray-100">
<div className="text-center text-gray-500 dark:text-gray-400">{t('Please set your OpenAI API key in the bottom left of the sidebar.')}</div> {t('OpenAI API Key Required')}
</div> </div>
) : modelError ? <ErrorMessageDiv error={modelError} /> : ( <div className="text-center text-gray-500 dark:text-gray-400">
{t(
'Please set your OpenAI API key in the bottom left of the sidebar.',
)}
</div>
</div>
) : modelError ? (
<ErrorMessageDiv error={modelError} />
) : (
<> <>
<div <div className="max-h-full overflow-scroll" ref={chatContainerRef}>
className="overflow-scroll max-h-full"
ref={chatContainerRef}
>
{conversation.messages.length === 0 ? ( {conversation.messages.length === 0 ? (
<> <>
<div className="flex flex-col mx-auto pt-12 space-y-10 w-[350px] sm:w-[600px]"> <div className="mx-auto flex w-[350px] flex-col space-y-10 pt-12 sm:w-[600px]">
<div className="text-4xl font-semibold text-center text-gray-800 dark:text-gray-100">{models.length === 0 ? t("Loading...") : "Chatbot UI"}</div> <div className="text-center text-4xl font-semibold text-gray-800 dark:text-gray-100">
{models.length === 0 ? t('Loading...') : 'Chatbot UI'}
</div>
{models.length > 0 && ( {models.length > 0 && (
<div className="flex flex-col h-full space-y-4 border p-4 rounded border-neutral-500"> <div className="flex h-full flex-col space-y-4 rounded border border-neutral-500 p-4">
<ModelSelect <ModelSelect
model={conversation.model} model={conversation.model}
models={models} models={models}
onModelChange={(model) => onUpdateConversation(conversation, { key: "model", value: model })} onModelChange={(model) =>
onUpdateConversation(conversation, {
key: 'model',
value: model,
})
}
/> />
<SystemPrompt <SystemPrompt
conversation={conversation} conversation={conversation}
onChangePrompt={(prompt) => onUpdateConversation(conversation, { key: "prompt", value: prompt })} onChangePrompt={(prompt) =>
onUpdateConversation(conversation, {
key: 'prompt',
value: prompt,
})
}
/> />
</div> </div>
)} )}
@ -106,7 +154,9 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
</> </>
) : ( ) : (
<> <>
<div className="flex justify-center py-2 text-neutral-500 bg-neutral-100 dark:bg-[#444654] dark:text-neutral-200 text-sm border border-b-neutral-300 dark:border-none">{t('Model')}: {conversation.model.name}</div> <div className="flex justify-center border border-b-neutral-300 bg-neutral-100 py-2 text-sm text-neutral-500 dark:border-none dark:bg-[#444654] dark:text-neutral-200">
{t('Model')}: {conversation.model.name}
</div>
{conversation.messages.map((message, index) => ( {conversation.messages.map((message, index) => (
<ChatMessage <ChatMessage
@ -121,7 +171,7 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
{loading && <ChatLoader />} {loading && <ChatLoader />}
<div <div
className="bg-white dark:bg-[#343541] h-[162px]" className="h-[162px] bg-white dark:bg-[#343541]"
ref={messagesEndRef} ref={messagesEndRef}
/> />
</> </>

View File

@ -1,7 +1,13 @@
import { Message, OpenAIModel, OpenAIModelID } from "@/types"; import { Message, OpenAIModel, OpenAIModelID } from '@/types';
import { IconPlayerStop, IconRepeat, IconSend } from "@tabler/icons-react"; import { IconPlayerStop, IconRepeat, IconSend } from '@tabler/icons-react';
import { FC, KeyboardEvent, MutableRefObject, useEffect, useState } from "react"; import {
import { useTranslation } from "next-i18next"; FC,
KeyboardEvent,
MutableRefObject,
useEffect,
useState,
} from 'react';
import { useTranslation } from 'next-i18next';
interface Props { interface Props {
messageIsStreaming: boolean; messageIsStreaming: boolean;
@ -13,7 +19,15 @@ interface Props {
textareaRef: MutableRefObject<HTMLTextAreaElement | null>; textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
} }
export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSend, onRegenerate, stopConversationRef, textareaRef }) => { export const ChatInput: FC<Props> = ({
messageIsStreaming,
model,
messages,
onSend,
onRegenerate,
stopConversationRef,
textareaRef,
}) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const [content, setContent] = useState<string>(); const [content, setContent] = useState<string>();
const [isTyping, setIsTyping] = useState<boolean>(false); const [isTyping, setIsTyping] = useState<boolean>(false);
@ -23,7 +37,12 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
const maxLength = model.id === OpenAIModelID.GPT_3_5 ? 12000 : 24000; const maxLength = model.id === OpenAIModelID.GPT_3_5 ? 12000 : 24000;
if (value.length > maxLength) { if (value.length > maxLength) {
alert(t(`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`, { maxLength, valueLength: value.length })); alert(
t(
`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
{ maxLength, valueLength: value.length },
),
);
return; return;
} }
@ -36,12 +55,12 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
} }
if (!content) { if (!content) {
alert(t("Please enter a message")); alert(t('Please enter a message'));
return; return;
} }
onSend({ role: "user", content }); onSend({ role: 'user', content });
setContent(""); setContent('');
if (window.innerWidth < 640 && textareaRef && textareaRef.current) { if (window.innerWidth < 640 && textareaRef && textareaRef.current) {
textareaRef.current.blur(); textareaRef.current.blur();
@ -49,14 +68,16 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
}; };
const isMobile = () => { const isMobile = () => {
const userAgent = typeof window.navigator === "undefined" ? "" : navigator.userAgent; const userAgent =
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i; typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
const mobileRegex =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
return mobileRegex.test(userAgent); return mobileRegex.test(userAgent);
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!isTyping) { if (!isTyping) {
if (e.key === "Enter" && !e.shiftKey && !isMobile()) { if (e.key === 'Enter' && !e.shiftKey && !isMobile()) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
@ -65,9 +86,11 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
useEffect(() => { useEffect(() => {
if (textareaRef && textareaRef.current) { if (textareaRef && textareaRef.current) {
textareaRef.current.style.height = "inherit"; textareaRef.current.style.height = 'inherit';
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`; textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
textareaRef.current.style.overflow = `${textareaRef?.current?.scrollHeight > 400 ? "auto" : "hidden"}`; textareaRef.current.style.overflow = `${
textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
}`;
} }
}, [content]); }, [content]);
@ -79,45 +102,43 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
} }
return ( return (
<div className="absolute bottom-0 left-0 w-full dark:border-white/20 border-transparent dark:bg-[#444654] dark:bg-gradient-to-t from-[#343541] via-[#343541] to-[#343541]/0 bg-white dark:!bg-transparent dark:bg-vert-dark-gradient pt-6 md:pt-2"> <div className="dark:bg-vert-dark-gradient absolute bottom-0 left-0 w-full border-transparent bg-white from-[#343541] via-[#343541] to-[#343541]/0 pt-6 dark:border-white/20 dark:bg-[#444654] dark:!bg-transparent dark:bg-gradient-to-t md:pt-2">
<div className="stretch mx-2 md:mt-[52px] mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-3xl"> <div className="stretch mx-2 mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:mt-[52px] md:last:mb-6 lg:mx-auto lg:max-w-3xl">
{messageIsStreaming && ( {messageIsStreaming && (
<button <button
className="absolute -top-2 md:top-0 left-0 right-0 mx-auto dark:bg-[#343541] border w-fit border-gray-500 py-2 px-4 rounded text-black dark:text-white hover:opacity-50" className="absolute -top-2 left-0 right-0 mx-auto w-fit rounded border border-gray-500 py-2 px-4 text-black hover:opacity-50 dark:bg-[#343541] dark:text-white md:top-0"
onClick={handleStopConversation} onClick={handleStopConversation}
> >
<IconPlayerStop <IconPlayerStop size={16} className="mb-[2px] inline-block" />{' '}
size={16}
className="inline-block mb-[2px]"
/>{" "}
{t('Stop Generating')} {t('Stop Generating')}
</button> </button>
)} )}
{!messageIsStreaming && messages.length > 0 && ( {!messageIsStreaming && messages.length > 0 && (
<button <button
className="absolute -top-2 md:top-0 left-0 right-0 mx-auto dark:bg-[#343541] border w-fit border-gray-500 py-2 px-4 rounded text-black dark:text-white hover:opacity-50" className="absolute -top-2 left-0 right-0 mx-auto w-fit rounded border border-gray-500 py-2 px-4 text-black hover:opacity-50 dark:bg-[#343541] dark:text-white md:top-0"
onClick={onRegenerate} onClick={onRegenerate}
> >
<IconRepeat <IconRepeat size={16} className="mb-[2px] inline-block" />{' '}
size={16} {t('Regenerate response')}
className="inline-block mb-[2px]"
/>{" "}
{t("Regenerate response")}
</button> </button>
)} )}
<div className="flex flex-col w-full py-2 flex-grow md:py-3 md:pl-4 relative border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-[#40414F] rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]"> <div className="relative flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="text-black dark:text-white m-0 w-full resize-none outline-none border-0 bg-transparent p-0 pr-7 focus:ring-0 focus-visible:ring-0 dark:bg-transparent pl-2 md:pl-0" className="m-0 w-full resize-none border-0 bg-transparent p-0 pr-7 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
style={{ style={{
resize: "none", resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`, bottom: `${textareaRef?.current?.scrollHeight}px`,
maxHeight: "400px", maxHeight: '400px',
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 ? "auto" : "hidden"}` overflow: `${
textareaRef.current && textareaRef.current.scrollHeight > 400
? 'auto'
: 'hidden'
}`,
}} }}
placeholder={t("Type a message...") || ''} placeholder={t('Type a message...') || ''}
value={content} value={content}
rows={1} rows={1}
onCompositionStart={() => setIsTyping(true)} onCompositionStart={() => setIsTyping(true)}
@ -127,13 +148,10 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
/> />
<button <button
className="absolute right-5 focus:outline-none text-neutral-800 hover:text-neutral-900 dark:text-neutral-100 dark:hover:text-neutral-200 dark:bg-opacity-50 hover:bg-neutral-200 p-1 rounded-sm" className="absolute right-5 rounded-sm p-1 text-neutral-800 hover:bg-neutral-200 hover:text-neutral-900 focus:outline-none dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
onClick={handleSend} onClick={handleSend}
> >
<IconSend <IconSend size={16} className="opacity-60" />
size={16}
className="opacity-60"
/>
</button> </button>
</div> </div>
</div> </div>
@ -146,7 +164,10 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
> >
ChatBot UI ChatBot UI
</a> </a>
. {t("Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.")} .{' '}
{t(
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.",
)}
</div> </div>
</div> </div>
); );

View File

@ -1,16 +1,16 @@
import { IconDots } from "@tabler/icons-react"; import { IconDots } from '@tabler/icons-react';
import { FC } from "react"; import { FC } from 'react';
interface Props {} interface Props {}
export const ChatLoader: FC<Props> = () => { export const ChatLoader: FC<Props> = () => {
return ( return (
<div <div
className="group text-gray-800 dark:text-gray-100 border-b border-black/10 dark:border-gray-900/50 bg-gray-50 dark:bg-[#444654]" className="group border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100"
style={{ overflowWrap: "anywhere" }} style={{ overflowWrap: 'anywhere' }}
> >
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-2xl xl:max-w-3xl p-4 md:py-6 flex lg:px-0 m-auto"> <div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="font-bold min-w-[40px]">AI:</div> <div className="min-w-[40px] font-bold">AI:</div>
<IconDots className="animate-pulse" /> <IconDots className="animate-pulse" />
</div> </div>
</div> </div>

View File

@ -1,23 +1,28 @@
import { Message } from "@/types"; import { Message } from '@/types';
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from '@tabler/icons-react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from 'react';
import ReactMarkdown from "react-markdown"; import ReactMarkdown from 'react-markdown';
import rehypeMathjax from "rehype-mathjax"; import rehypeMathjax from 'rehype-mathjax';
import remarkGfm from "remark-gfm"; import remarkGfm from 'remark-gfm';
import remarkMath from "remark-math"; import remarkMath from 'remark-math';
import { CodeBlock } from "../Markdown/CodeBlock"; import { CodeBlock } from '../Markdown/CodeBlock';
import { CopyButton } from "./CopyButton"; import { CopyButton } from './CopyButton';
interface Props { interface Props {
message: Message; message: Message;
messageIndex: number; messageIndex: number;
lightMode: "light" | "dark"; lightMode: 'light' | 'dark';
onEditMessage: (message: Message, messageIndex: number) => void; onEditMessage: (message: Message, messageIndex: number) => void;
} }
export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEditMessage }) => { export const ChatMessage: FC<Props> = ({
const { t } = useTranslation("chat"); message,
messageIndex,
lightMode,
onEditMessage,
}) => {
const { t } = useTranslation('chat');
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isHovering, setIsHovering] = useState<boolean>(false); const [isHovering, setIsHovering] = useState<boolean>(false);
const [messageContent, setMessageContent] = useState(message.content); const [messageContent, setMessageContent] = useState(message.content);
@ -32,7 +37,7 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessageContent(event.target.value); setMessageContent(event.target.value);
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = "inherit"; textareaRef.current.style.height = 'inherit';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
} }
}; };
@ -45,7 +50,7 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
}; };
const handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleEditMessage(); handleEditMessage();
} }
@ -64,23 +69,29 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = "inherit"; textareaRef.current.style.height = 'inherit';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
} }
}, [isEditing]); }, [isEditing]);
return ( return (
<div <div
className={`group ${message.role === "assistant" ? "border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100" : "border-b border-black/10 bg-white text-gray-800 dark:border-gray-900/50 dark:bg-[#343541] dark:text-gray-100"}`} className={`group ${
style={{ overflowWrap: "anywhere" }} message.role === 'assistant'
? 'border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100'
: 'border-b border-black/10 bg-white text-gray-800 dark:border-gray-900/50 dark:bg-[#343541] dark:text-gray-100'
}`}
style={{ overflowWrap: 'anywhere' }}
onMouseEnter={() => setIsHovering(true)} onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)} onMouseLeave={() => setIsHovering(false)}
> >
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl"> <div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="min-w-[40px] font-bold">{message.role === "assistant" ? t("AI") : t("You")}:</div> <div className="min-w-[40px] font-bold">
{message.role === 'assistant' ? t('AI') : t('You')}:
</div>
<div className="prose mt-[-2px] w-full dark:prose-invert"> <div className="prose mt-[-2px] w-full dark:prose-invert">
{message.role === "user" ? ( {message.role === 'user' ? (
<div className="flex w-full"> <div className="flex w-full">
{isEditing ? ( {isEditing ? (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
@ -91,12 +102,12 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handlePressEnter} onKeyDown={handlePressEnter}
style={{ style={{
fontFamily: "inherit", fontFamily: 'inherit',
fontSize: "inherit", fontSize: 'inherit',
lineHeight: "inherit", lineHeight: 'inherit',
padding: "0", padding: '0',
margin: "0", margin: '0',
overflow: "hidden" overflow: 'hidden',
}} }}
/> />
@ -120,11 +131,19 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
</div> </div>
</div> </div>
) : ( ) : (
<div className="prose whitespace-pre-wrap dark:prose-invert">{message.content}</div> <div className="prose whitespace-pre-wrap dark:prose-invert">
{message.content}
</div>
)} )}
{(isHovering || window.innerWidth < 640) && !isEditing && ( {(isHovering || window.innerWidth < 640) && !isEditing && (
<button className={`absolute ${window.innerWidth < 640 ? "right-3 bottom-1" : "right-[-20px] top-[26px]"}`}> <button
className={`absolute ${
window.innerWidth < 640
? 'right-3 bottom-1'
: 'right-[-20px] top-[26px]'
}`}
>
<IconEdit <IconEdit
size={20} size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@ -141,33 +160,42 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
rehypePlugins={[rehypeMathjax]} rehypePlugins={[rehypeMathjax]}
components={{ components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || '');
return !inline && match ? ( return !inline && match ? (
<CodeBlock <CodeBlock
key={Math.random()} key={Math.random()}
language={match[1]} language={match[1]}
value={String(children).replace(/\n$/, "")} value={String(children).replace(/\n$/, '')}
lightMode={lightMode} lightMode={lightMode}
{...props} {...props}
/> />
) : ( ) : (
<code <code className={className} {...props}>
className={className}
{...props}
>
{children} {children}
</code> </code>
); );
}, },
table({ children }) { table({ children }) {
return <table className="border-collapse border border-black py-1 px-3 dark:border-white">{children}</table>; return (
<table className="border-collapse border border-black py-1 px-3 dark:border-white">
{children}
</table>
);
}, },
th({ children }) { th({ children }) {
return <th className="break-words border border-black bg-gray-500 py-1 px-3 text-white dark:border-white">{children}</th>; return (
<th className="break-words border border-black bg-gray-500 py-1 px-3 text-white dark:border-white">
{children}
</th>
);
}, },
td({ children }) { td({ children }) {
return <td className="break-words border border-black py-1 px-3 dark:border-white">{children}</td>; return (
} <td className="break-words border border-black py-1 px-3 dark:border-white">
{children}
</td>
);
},
}} }}
> >
{message.content} {message.content}

View File

@ -1,16 +1,21 @@
import { ErrorMessage } from "@/types"; import { ErrorMessage } from '@/types';
import { FC } from "react"; import { FC } from 'react';
interface Props { interface Props {
error: ErrorMessage error: ErrorMessage;
} }
export const ErrorMessageDiv: FC<Props> = ({ error }) => { export const ErrorMessageDiv: FC<Props> = ({ error }) => {
return ( return (
<div className= "flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6" > <div className="mx-auto flex h-full w-[300px] flex-col justify-center space-y-6 sm:w-[500px]">
<div className="text-center text-red-500" >{error.title} {error.code ? <i>({error.code}) </i> : "" }</div > <div className="text-center text-red-500">
{error.title} {error.code ? <i>({error.code}) </i> : ''}
</div>
{error.messageLines.map((line, index) => ( {error.messageLines.map((line, index) => (
<div key={index} className="text-center text-red-500" > {line} </div> <div key={index} className="text-center text-red-500">
{' '}
{line}{' '}
</div>
))} ))}
</div> </div>
); );

View File

@ -1,6 +1,6 @@
import { OpenAIModel } from "@/types"; import { OpenAIModel } from '@/types';
import { FC } from "react"; import { FC } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
interface Props { interface Props {
model: OpenAIModel; model: OpenAIModel;
@ -9,23 +9,24 @@ interface Props {
} }
export const ModelSelect: FC<Props> = ({ model, models, onModelChange }) => { export const ModelSelect: FC<Props> = ({ model, models, onModelChange }) => {
const {t} = useTranslation('chat') const { t } = useTranslation('chat');
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-left mb-2 dark:text-neutral-400 text-neutral-700">{t('Model')}</label> <label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
{t('Model')}
</label>
<select <select
className="w-full p-3 dark:text-white dark:bg-[#343541] border border-neutral-500 rounded-lg appearance-none focus:shadow-outline text-neutral-900 cursor-pointer" className="focus:shadow-outline w-full cursor-pointer appearance-none rounded-lg border border-neutral-500 p-3 text-neutral-900 dark:bg-[#343541] dark:text-white"
placeholder={t("Select a model") || ''} placeholder={t('Select a model') || ''}
value={model.id} value={model.id}
onChange={(e) => { onChange={(e) => {
onModelChange(models.find((model) => model.id === e.target.value) as OpenAIModel); onModelChange(
models.find((model) => model.id === e.target.value) as OpenAIModel,
);
}} }}
> >
{models.map((model) => ( {models.map((model) => (
<option <option key={model.id} value={model.id}>
key={model.id}
value={model.id}
>
{model.name} {model.name}
</option> </option>
))} ))}

View File

@ -1,18 +1,20 @@
import { IconRefresh } from "@tabler/icons-react"; import { IconRefresh } from '@tabler/icons-react';
import { FC } from "react"; import { FC } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
interface Props { interface Props {
onRegenerate: () => void; onRegenerate: () => void;
} }
export const Regenerate: FC<Props> = ({ onRegenerate }) => { export const Regenerate: FC<Props> = ({ onRegenerate }) => {
const { t } = useTranslation('chat') const { t } = useTranslation('chat');
return ( return (
<div className="fixed sm:absolute bottom-4 sm:bottom-8 w-full sm:w-1/2 px-2 left-0 sm:left-[280px] lg:left-[200px] right-0 ml-auto mr-auto"> <div className="fixed bottom-4 left-0 right-0 ml-auto mr-auto w-full px-2 sm:absolute sm:bottom-8 sm:left-[280px] sm:w-1/2 lg:left-[200px]">
<div className="text-center mb-4 text-red-500">{t('Sorry, there was an error.')}</div> <div className="mb-4 text-center text-red-500">
{t('Sorry, there was an error.')}
</div>
<button <button
className="flex items-center justify-center w-full h-12 bg-neutral-100 dark:bg-[#444654] text-neutral-500 dark:text-neutral-200 text-sm font-semibold rounded-lg border border-b-neutral-300 dark:border-none" className="flex h-12 w-full items-center justify-center rounded-lg border border-b-neutral-300 bg-neutral-100 text-sm font-semibold text-neutral-500 dark:border-none dark:bg-[#444654] dark:text-neutral-200"
onClick={onRegenerate} onClick={onRegenerate}
> >
<IconRefresh className="mr-2" /> <IconRefresh className="mr-2" />

View File

@ -1,7 +1,7 @@
import { Conversation } from "@/types"; import { Conversation } from '@/types';
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const"; import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
interface Props { interface Props {
conversation: Conversation; conversation: Conversation;
@ -9,8 +9,8 @@ interface Props {
} }
export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => { export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
const { t } = useTranslation('chat') const { t } = useTranslation('chat');
const [value, setValue] = useState<string>(""); const [value, setValue] = useState<string>('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -32,7 +32,7 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
useEffect(() => { useEffect(() => {
if (textareaRef && textareaRef.current) { if (textareaRef && textareaRef.current) {
textareaRef.current.style.height = "inherit"; textareaRef.current.style.height = 'inherit';
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`; textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
} }
}, [value]); }, [value]);
@ -47,17 +47,23 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-left dark:text-neutral-400 text-neutral-700 mb-2">{t('System Prompt')}</label> <label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
{t('System Prompt')}
</label>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="w-full rounded-lg px-4 py-2 focus:outline-none dark:bg-[#40414F] dark:border-opacity-50 dark:border-neutral-800 dark:text-neutral-100 border border-neutral-500 shadow text-neutral-900" className="w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
style={{ style={{
resize: "none", resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`, bottom: `${textareaRef?.current?.scrollHeight}px`,
maxHeight: "300px", maxHeight: '300px',
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 ? "auto" : "hidden"}` overflow: `${
textareaRef.current && textareaRef.current.scrollHeight > 400
? 'auto'
: 'hidden'
}`,
}} }}
placeholder={t("Enter a prompt") || ''} placeholder={t('Enter a prompt') || ''}
value={t(value) || ''} value={t(value) || ''}
rows={1} rows={1}
onChange={handleChange} onChange={handleChange}

View File

@ -1,14 +1,20 @@
import { generateRandomString, programmingLanguages } from "@/utils/app/codeblock"; import {
import { IconCheck, IconClipboard, IconDownload } from "@tabler/icons-react"; generateRandomString,
import { FC, useState } from "react"; programmingLanguages,
import { useTranslation } from "next-i18next"; } from '@/utils/app/codeblock';
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { FC, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
interface Props { interface Props {
language: string; language: string;
value: string; value: string;
lightMode: "light" | "dark"; lightMode: 'light' | 'dark';
} }
export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => { export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
@ -29,40 +35,50 @@ export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
}); });
}; };
const downloadAsFile = () => { const downloadAsFile = () => {
const fileExtension = programmingLanguages[language] || ".file"; const fileExtension = programmingLanguages[language] || '.file';
const suggestedFileName = `file-${generateRandomString(3, true)}${fileExtension}`; const suggestedFileName = `file-${generateRandomString(
const fileName = window.prompt(t("Enter file name") || '', suggestedFileName); 3,
true,
)}${fileExtension}`;
const fileName = window.prompt(
t('Enter file name') || '',
suggestedFileName,
);
if (!fileName) { if (!fileName) {
// user pressed cancel on prompt // user pressed cancel on prompt
return; return;
} }
const blob = new Blob([value], { type: "text/plain" }); const blob = new Blob([value], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement('a');
link.download = fileName; link.download = fileName;
link.href = url; link.href = url;
link.style.display = "none"; link.style.display = 'none';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
return ( return (
<div className="codeblock relative text-[16px] font-sans"> <div className="codeblock relative font-sans text-[16px]">
<div className="flex items-center justify-between py-1.5 px-4"> <div className="flex items-center justify-between py-1.5 px-4">
<span className="text-xs text-white lowercase">{language}</span> <span className="text-xs lowercase text-white">{language}</span>
<div className="flex items-center"> <div className="flex items-center">
<button <button
className="text-white bg-none py-0.5 px-2 rounded focus:outline-none text-xs flex items-center" className="flex items-center rounded bg-none py-0.5 px-2 text-xs text-white focus:outline-none"
onClick={copyToClipboard} onClick={copyToClipboard}
> >
{isCopied ? <IconCheck size={18} className="mr-1.5"/> : <IconClipboard size={18} className="mr-1.5"/>} {isCopied ? (
{isCopied ? t("Copied!") : t("Copy code")} <IconCheck size={18} className="mr-1.5" />
) : (
<IconClipboard size={18} className="mr-1.5" />
)}
{isCopied ? t('Copied!') : t('Copy code')}
</button> </button>
<button <button
className="text-white bg-none py-0.5 pl-2 rounded focus:outline-none text-xs flex items-center" className="flex items-center rounded bg-none py-0.5 pl-2 text-xs text-white focus:outline-none"
onClick={downloadAsFile} onClick={downloadAsFile}
> >
<IconDownload size={18} /> <IconDownload size={18} />
@ -72,7 +88,7 @@ export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={lightMode === "light" ? oneLight : oneDark} style={lightMode === 'light' ? oneLight : oneDark}
customStyle={{ margin: 0 }} customStyle={{ margin: 0 }}
> >
{value} {value}

View File

@ -1,18 +1,23 @@
import { Conversation } from "@/types"; import { Conversation } from '@/types';
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from '@tabler/icons-react';
import { FC } from "react"; import { FC } from 'react';
interface Props { interface Props {
selectedConversation: Conversation; selectedConversation: Conversation;
onNewConversation: () => void; onNewConversation: () => void;
} }
export const Navbar: FC<Props> = ({ selectedConversation, onNewConversation }) => { export const Navbar: FC<Props> = ({
selectedConversation,
onNewConversation,
}) => {
return ( return (
<nav className="flex justify-between bg-[#202123] py-3 px-4 w-full"> <nav className="flex w-full justify-between bg-[#202123] py-3 px-4">
<div className="mr-4"></div> <div className="mr-4"></div>
<div className="max-w-[240px] whitespace-nowrap overflow-hidden text-ellipsis">{selectedConversation.name}</div> <div className="max-w-[240px] overflow-hidden text-ellipsis whitespace-nowrap">
{selectedConversation.name}
</div>
<IconPlus <IconPlus
className="cursor-pointer hover:text-neutral-400" className="cursor-pointer hover:text-neutral-400"

View File

@ -1,7 +1,7 @@
import { IconCheck, IconTrash, IconX } from "@tabler/icons-react"; import { IconCheck, IconTrash, IconX } from '@tabler/icons-react';
import { FC, useState } from "react"; import { FC, useState } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
import { SidebarButton } from "./SidebarButton"; import { SidebarButton } from './SidebarButton';
interface Props { interface Props {
onClearConversations: () => void; onClearConversations: () => void;
@ -10,7 +10,7 @@ interface Props {
export const ClearConversations: FC<Props> = ({ onClearConversations }) => { export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
const [isConfirming, setIsConfirming] = useState<boolean>(false); const [isConfirming, setIsConfirming] = useState<boolean>(false);
const { t } = useTranslation('sidebar') const { t } = useTranslation('sidebar');
const handleClearConversations = () => { const handleClearConversations = () => {
onClearConversations(); onClearConversations();
@ -18,10 +18,12 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
}; };
return isConfirming ? ( return isConfirming ? (
<div className="flex hover:bg-[#343541] py-3 px-3 rounded-md cursor-pointer w-full items-center"> <div className="flex w-full cursor-pointer items-center rounded-md py-3 px-3 hover:bg-[#343541]">
<IconTrash size={16} /> <IconTrash size={16} />
<div className="ml-3 flex-1 text-left text-white">{t('Are you sure?')}</div> <div className="ml-3 flex-1 text-left text-white">
{t('Are you sure?')}
</div>
<div className="flex w-[40px]"> <div className="flex w-[40px]">
<IconCheck <IconCheck
@ -45,7 +47,7 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
</div> </div>
) : ( ) : (
<SidebarButton <SidebarButton
text={t("Clear conversations")} text={t('Clear conversations')}
icon={<IconTrash size={16} />} icon={<IconTrash size={16} />}
onClick={() => setIsConfirming(true)} onClick={() => setIsConfirming(true)}
/> />

View File

@ -1,6 +1,12 @@
import { Conversation, KeyValuePair } from "@/types"; import { Conversation, KeyValuePair } from '@/types';
import { IconCheck, IconMessage, IconPencil, IconTrash, IconX } from "@tabler/icons-react"; import {
import { DragEvent, FC, KeyboardEvent, useEffect, useState } from "react"; IconCheck,
IconMessage,
IconPencil,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { DragEvent, FC, KeyboardEvent, useEffect, useState } from 'react';
interface Props { interface Props {
selectedConversation: Conversation; selectedConversation: Conversation;
@ -8,30 +14,43 @@ interface Props {
loading: boolean; loading: boolean;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
} }
export const ConversationComponent: FC<Props> = ({ selectedConversation, conversation, loading, onSelectConversation, onDeleteConversation, onUpdateConversation }) => { export const ConversationComponent: FC<Props> = ({
selectedConversation,
conversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation,
}) => {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState('');
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => { const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleRename(selectedConversation); handleRename(selectedConversation);
} }
}; };
const handleDragStart = (e: DragEvent<HTMLButtonElement>, conversation: Conversation) => { const handleDragStart = (
e: DragEvent<HTMLButtonElement>,
conversation: Conversation,
) => {
if (e.dataTransfer) { if (e.dataTransfer) {
e.dataTransfer.setData("conversation", JSON.stringify(conversation)); e.dataTransfer.setData('conversation', JSON.stringify(conversation));
} }
}; };
const handleRename = (conversation: Conversation) => { const handleRename = (conversation: Conversation) => {
onUpdateConversation(conversation, { key: "name", value: renameValue }); onUpdateConversation(conversation, { key: 'name', value: renameValue });
setRenameValue(""); setRenameValue('');
setIsRenaming(false); setIsRenaming(false);
}; };
@ -45,7 +64,11 @@ export const ConversationComponent: FC<Props> = ({ selectedConversation, convers
return ( return (
<button <button
className={`flex w-full gap-3 items-center p-3 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer ${loading ? "disabled:cursor-not-allowed" : ""} ${selectedConversation.id === conversation.id ? "bg-[#343541]/90" : ""}`} className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90 ${
loading ? 'disabled:cursor-not-allowed' : ''
} ${
selectedConversation.id === conversation.id ? 'bg-[#343541]/90' : ''
}`}
onClick={() => onSelectConversation(conversation)} onClick={() => onSelectConversation(conversation)}
disabled={loading} disabled={loading}
draggable="true" draggable="true"
@ -55,7 +78,7 @@ export const ConversationComponent: FC<Props> = ({ selectedConversation, convers
{isRenaming && selectedConversation.id === conversation.id ? ( {isRenaming && selectedConversation.id === conversation.id ? (
<input <input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white" className="flex-1 overflow-hidden overflow-ellipsis border-b border-neutral-400 bg-transparent pr-1 text-left text-white outline-none focus:border-neutral-100"
type="text" type="text"
value={renameValue} value={renameValue}
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
@ -63,11 +86,14 @@ export const ConversationComponent: FC<Props> = ({ selectedConversation, convers
autoFocus autoFocus
/> />
) : ( ) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{conversation.name}</div> <div className="flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap pr-1 text-left">
{conversation.name}
</div>
)} )}
{(isDeleting || isRenaming) && selectedConversation.id === conversation.id && ( {(isDeleting || isRenaming) &&
<div className="flex gap-1 -ml-2"> selectedConversation.id === conversation.id && (
<div className="-ml-2 flex gap-1">
<IconCheck <IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100" className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16} size={16}
@ -97,8 +123,10 @@ export const ConversationComponent: FC<Props> = ({ selectedConversation, convers
</div> </div>
)} )}
{selectedConversation.id === conversation.id && !isDeleting && !isRenaming && ( {selectedConversation.id === conversation.id &&
<div className="flex gap-1 -ml-2"> !isDeleting &&
!isRenaming && (
<div className="-ml-2 flex gap-1">
<IconPencil <IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100" className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18} size={18}

View File

@ -1,6 +1,6 @@
import { Conversation, KeyValuePair } from "@/types"; import { Conversation, KeyValuePair } from '@/types';
import { FC } from "react"; import { FC } from 'react';
import { ConversationComponent } from "./Conversation"; import { ConversationComponent } from './Conversation';
interface Props { interface Props {
loading: boolean; loading: boolean;
@ -8,12 +8,22 @@ interface Props {
selectedConversation: Conversation; selectedConversation: Conversation;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
} }
export const Conversations: FC<Props> = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onUpdateConversation }) => { export const Conversations: FC<Props> = ({
loading,
conversations,
selectedConversation,
onSelectConversation,
onDeleteConversation,
onUpdateConversation,
}) => {
return ( return (
<div className="flex flex-col gap-1 w-full pt-2"> <div className="flex w-full flex-col gap-1 pt-2">
{conversations.slice().reverse().map((conversation, index) => ( {conversations.slice().reverse().map((conversation, index) => (
<ConversationComponent <ConversationComponent
key={index} key={index}

View File

@ -1,7 +1,14 @@
import { ChatFolder, Conversation, KeyValuePair } from "@/types"; import { ChatFolder, Conversation, KeyValuePair } from '@/types';
import { IconCaretDown, IconCaretRight, IconCheck, IconPencil, IconTrash, IconX } from "@tabler/icons-react"; import {
import { FC, KeyboardEvent, useEffect, useState } from "react"; IconCaretDown,
import { ConversationComponent } from "./Conversation"; IconCaretRight,
IconCheck,
IconPencil,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { ConversationComponent } from './Conversation';
interface Props { interface Props {
searchTerm: string; searchTerm: string;
@ -14,7 +21,10 @@ interface Props {
loading: boolean; loading: boolean;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
} }
export const Folder: FC<Props> = ({ export const Folder: FC<Props> = ({
@ -28,15 +38,15 @@ export const Folder: FC<Props> = ({
loading, loading,
onSelectConversation, onSelectConversation,
onDeleteConversation, onDeleteConversation,
onUpdateConversation onUpdateConversation,
}) => { }) => {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState('');
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => { const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleRename(); handleRename();
} }
@ -44,7 +54,7 @@ export const Folder: FC<Props> = ({
const handleRename = () => { const handleRename = () => {
onUpdateFolder(currentFolder.id, renameValue); onUpdateFolder(currentFolder.id, renameValue);
setRenameValue(""); setRenameValue('');
setIsRenaming(false); setIsRenaming(false);
}; };
@ -52,10 +62,10 @@ export const Folder: FC<Props> = ({
if (e.dataTransfer) { if (e.dataTransfer) {
setIsOpen(true); setIsOpen(true);
const conversation = JSON.parse(e.dataTransfer.getData("conversation")); const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
onUpdateConversation(conversation, { key: "folderId", value: folder.id }); onUpdateConversation(conversation, { key: 'folderId', value: folder.id });
e.target.style.background = "none"; e.target.style.background = 'none';
} }
}; };
@ -64,11 +74,11 @@ export const Folder: FC<Props> = ({
}; };
const highlightDrop = (e: any) => { const highlightDrop = (e: any) => {
e.target.style.background = "#343541"; e.target.style.background = '#343541';
}; };
const removeHighlight = (e: any) => { const removeHighlight = (e: any) => {
e.target.style.background = "none"; e.target.style.background = 'none';
}; };
useEffect(() => { useEffect(() => {
@ -90,7 +100,7 @@ export const Folder: FC<Props> = ({
return ( return (
<div> <div>
<div <div
className={`mb-1 flex gap-3 items-center px-3 py-2 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer`} className={`mb-1 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors duration-200 hover:bg-[#343541]/90`}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
onDrop={(e) => handleDrop(e, currentFolder)} onDrop={(e) => handleDrop(e, currentFolder)}
onDragOver={allowDrop} onDragOver={allowDrop}
@ -101,7 +111,7 @@ export const Folder: FC<Props> = ({
{isRenaming ? ( {isRenaming ? (
<input <input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white" className="flex-1 overflow-hidden overflow-ellipsis border-b border-neutral-400 bg-transparent pr-1 text-left text-white outline-none focus:border-neutral-100"
type="text" type="text"
value={renameValue} value={renameValue}
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
@ -109,11 +119,13 @@ export const Folder: FC<Props> = ({
autoFocus autoFocus
/> />
) : ( ) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{currentFolder.name}</div> <div className="flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap pr-1 text-left">
{currentFolder.name}
</div>
)} )}
{(isDeleting || isRenaming) && ( {(isDeleting || isRenaming) && (
<div className="flex gap-1 -ml-2"> <div className="-ml-2 flex gap-1">
<IconCheck <IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100" className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16} size={16}
@ -144,7 +156,7 @@ export const Folder: FC<Props> = ({
)} )}
{!isDeleting && !isRenaming && ( {!isDeleting && !isRenaming && (
<div className="flex gap-1 ml-2"> <div className="ml-2 flex gap-1">
<IconPencil <IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100" className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18} size={18}
@ -171,10 +183,7 @@ export const Folder: FC<Props> = ({
? conversations.map((conversation, index) => { ? conversations.map((conversation, index) => {
if (conversation.folderId === currentFolder.id) { if (conversation.folderId === currentFolder.id) {
return ( return (
<div <div key={index} className="ml-5 gap-2 border-l pl-2 pt-2">
key={index}
className="ml-5 pl-2 border-l gap-2 pt-2"
>
<ConversationComponent <ConversationComponent
selectedConversation={selectedConversation} selectedConversation={selectedConversation}
conversation={conversation} conversation={conversation}

View File

@ -1,6 +1,6 @@
import { ChatFolder, Conversation, KeyValuePair } from "@/types"; import { ChatFolder, Conversation, KeyValuePair } from '@/types';
import { FC } from "react"; import { FC } from 'react';
import { Folder } from "./Folder"; import { Folder } from './Folder';
interface Props { interface Props {
searchTerm: string; searchTerm: string;
@ -13,7 +13,10 @@ interface Props {
loading: boolean; loading: boolean;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
} }
export const Folders: FC<Props> = ({ export const Folders: FC<Props> = ({
@ -27,10 +30,10 @@ export const Folders: FC<Props> = ({
loading, loading,
onSelectConversation, onSelectConversation,
onDeleteConversation, onDeleteConversation,
onUpdateConversation onUpdateConversation,
}) => { }) => {
return ( return (
<div className="flex flex-col gap-1 w-full pt-2"> <div className="flex w-full flex-col gap-1 pt-2">
{folders.map((folder, index) => ( {folders.map((folder, index) => (
<Folder <Folder
key={index} key={index}

View File

@ -1,16 +1,19 @@
import { ChatFolder, Conversation } from "@/types"; import { ChatFolder, Conversation } from '@/types';
import { cleanConversationHistory } from "@/utils/app/clean"; import { cleanConversationHistory } from '@/utils/app/clean';
import { IconFileImport } from "@tabler/icons-react"; import { IconFileImport } from '@tabler/icons-react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
import { FC } from "react"; import { FC } from 'react';
import { SidebarButton } from "./SidebarButton"; import { SidebarButton } from './SidebarButton';
interface Props { interface Props {
onImport: (data: { conversations: Conversation[]; folders: ChatFolder[] }) => void; onImport: (data: {
conversations: Conversation[];
folders: ChatFolder[];
}) => void;
} }
export const Import: FC<Props> = ({ onImport }) => { export const Import: FC<Props> = ({ onImport }) => {
const { t } = useTranslation("sidebar"); const { t } = useTranslation('sidebar');
return ( return (
<> <>
<input <input
@ -38,10 +41,12 @@ export const Import: FC<Props> = ({ onImport }) => {
/> />
<SidebarButton <SidebarButton
text={t("Import conversations")} text={t('Import conversations')}
icon={<IconFileImport size={16} />} icon={<IconFileImport size={16} />}
onClick={() => { onClick={() => {
const importFile = document.querySelector("#import-file") as HTMLInputElement; const importFile = document.querySelector(
'#import-file',
) as HTMLInputElement;
if (importFile) { if (importFile) {
importFile.click(); importFile.click();
} }

View File

@ -1,7 +1,7 @@
import { IconCheck, IconKey, IconX } from "@tabler/icons-react"; import { IconCheck, IconKey, IconX } from '@tabler/icons-react';
import { FC, KeyboardEvent, useState } from "react"; import { FC, KeyboardEvent, useState } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
import { SidebarButton } from "./SidebarButton"; import { SidebarButton } from './SidebarButton';
interface Props { interface Props {
apiKey: string; apiKey: string;
@ -14,7 +14,7 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
const [newKey, setNewKey] = useState(apiKey); const [newKey, setNewKey] = useState(apiKey);
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => { const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleUpdateKey(newKey); handleUpdateKey(newKey);
} }
@ -26,11 +26,11 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
}; };
return isChanging ? ( return isChanging ? (
<div className="flex transition-colors duration:200 hover:bg-gray-500/10 py-3 px-3 rounded-md cursor-pointer w-full items-center"> <div className="duration:200 flex w-full cursor-pointer items-center rounded-md py-3 px-3 transition-colors hover:bg-gray-500/10">
<IconKey size={16} /> <IconKey size={16} />
<input <input
className="ml-2 flex-1 h-[20px] bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white" className="ml-2 h-[20px] flex-1 overflow-hidden overflow-ellipsis border-b border-neutral-400 bg-transparent pr-1 text-left text-white outline-none focus:border-neutral-100"
type="password" type="password"
value={newKey} value={newKey}
onChange={(e) => setNewKey(e.target.value)} onChange={(e) => setNewKey(e.target.value)}
@ -60,7 +60,7 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
</div> </div>
) : ( ) : (
<SidebarButton <SidebarButton
text={t("OpenAI API Key")} text={t('OpenAI API Key')}
icon={<IconKey size={16} />} icon={<IconKey size={16} />}
onClick={() => setIsChanging(true)} onClick={() => setIsChanging(true)}
/> />

View File

@ -1,6 +1,6 @@
import { IconX } from "@tabler/icons-react"; import { IconX } from '@tabler/icons-react';
import { FC } from "react"; import { FC } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
interface Props { interface Props {
searchTerm: string; searchTerm: string;
@ -15,13 +15,13 @@ export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
}; };
const clearSearch = () => { const clearSearch = () => {
onSearch(""); onSearch('');
}; };
return ( return (
<div className="relative flex items-center"> <div className="relative flex items-center">
<input <input
className="flex-1 w-full pr-10 bg-[#202123] border border-neutral-600 text-sm rounded-md px-4 py-3 text-white" className="w-full flex-1 rounded-md border border-neutral-600 bg-[#202123] px-4 py-3 pr-10 text-sm text-white"
type="text" type="text"
placeholder={t('Search conversations...') || ''} placeholder={t('Search conversations...') || ''}
value={searchTerm} value={searchTerm}
@ -30,7 +30,7 @@ export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
{searchTerm && ( {searchTerm && (
<IconX <IconX
className="absolute right-4 text-neutral-300 cursor-pointer hover:text-neutral-400" className="absolute right-4 cursor-pointer text-neutral-300 hover:text-neutral-400"
size={24} size={24}
onClick={clearSearch} onClick={clearSearch}
/> />

View File

@ -1,16 +1,20 @@
import { ChatFolder, Conversation, KeyValuePair } from "@/types"; import { ChatFolder, Conversation, KeyValuePair } from '@/types';
import { IconArrowBarLeft, IconFolderPlus, IconPlus } from "@tabler/icons-react"; import {
import { FC, useEffect, useState } from "react"; IconArrowBarLeft,
import { useTranslation } from "next-i18next"; IconFolderPlus,
import { Conversations } from "./Conversations"; IconPlus,
import { Folders } from "./Folders"; } from '@tabler/icons-react';
import { Search } from "./Search"; import { FC, useEffect, useState } from 'react';
import { SidebarSettings } from "./SidebarSettings"; import { useTranslation } from 'next-i18next';
import { Conversations } from './Conversations';
import { Folders } from './Folders';
import { Search } from './Search';
import { SidebarSettings } from './SidebarSettings';
interface Props { interface Props {
loading: boolean; loading: boolean;
conversations: Conversation[]; conversations: Conversation[];
lightMode: "light" | "dark"; lightMode: 'light' | 'dark';
selectedConversation: Conversation; selectedConversation: Conversation;
apiKey: string; apiKey: string;
folders: ChatFolder[]; folders: ChatFolder[];
@ -18,39 +22,68 @@ interface Props {
onDeleteFolder: (folderId: number) => void; onDeleteFolder: (folderId: number) => void;
onUpdateFolder: (folderId: number, name: string) => void; onUpdateFolder: (folderId: number, name: string) => void;
onNewConversation: () => void; onNewConversation: () => void;
onToggleLightMode: (mode: "light" | "dark") => void; onToggleLightMode: (mode: 'light' | 'dark') => void;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void;
onToggleSidebar: () => void; onToggleSidebar: () => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
onApiKeyChange: (apiKey: string) => void; onApiKeyChange: (apiKey: string) => void;
onClearConversations: () => void; onClearConversations: () => void;
onExportConversations: () => void; onExportConversations: () => void;
onImportConversations: (data: { conversations: Conversation[]; folders: ChatFolder[] }) => void; onImportConversations: (data: {
conversations: Conversation[];
folders: ChatFolder[];
}) => void;
} }
export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, folders, onCreateFolder, onDeleteFolder, onUpdateFolder, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => { export const Sidebar: FC<Props> = ({
loading,
conversations,
lightMode,
selectedConversation,
apiKey,
folders,
onCreateFolder,
onDeleteFolder,
onUpdateFolder,
onNewConversation,
onToggleLightMode,
onSelectConversation,
onDeleteConversation,
onToggleSidebar,
onUpdateConversation,
onApiKeyChange,
onClearConversations,
onExportConversations,
onImportConversations,
}) => {
const { t } = useTranslation('sidebar'); const { t } = useTranslation('sidebar');
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>('');
const [filteredConversations, setFilteredConversations] = useState<Conversation[]>(conversations); const [filteredConversations, setFilteredConversations] =
useState<Conversation[]>(conversations);
const handleUpdateConversation = (conversation: Conversation, data: KeyValuePair) => { const handleUpdateConversation = (
conversation: Conversation,
data: KeyValuePair,
) => {
onUpdateConversation(conversation, data); onUpdateConversation(conversation, data);
setSearchTerm(""); setSearchTerm('');
}; };
const handleDeleteConversation = (conversation: Conversation) => { const handleDeleteConversation = (conversation: Conversation) => {
onDeleteConversation(conversation); onDeleteConversation(conversation);
setSearchTerm(""); setSearchTerm('');
}; };
const handleDrop = (e: any) => { const handleDrop = (e: any) => {
if (e.dataTransfer) { if (e.dataTransfer) {
const conversation = JSON.parse(e.dataTransfer.getData("conversation")); const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
onUpdateConversation(conversation, { key: "folderId", value: 0 }); onUpdateConversation(conversation, { key: 'folderId', value: 0 });
e.target.style.background = "none"; e.target.style.background = 'none';
} }
}; };
@ -59,20 +92,23 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
}; };
const highlightDrop = (e: any) => { const highlightDrop = (e: any) => {
e.target.style.background = "#343541"; e.target.style.background = '#343541';
}; };
const removeHighlight = (e: any) => { const removeHighlight = (e: any) => {
e.target.style.background = "none"; e.target.style.background = 'none';
}; };
useEffect(() => { useEffect(() => {
if (searchTerm) { if (searchTerm) {
setFilteredConversations( setFilteredConversations(
conversations.filter((conversation) => { conversations.filter((conversation) => {
const searchable = conversation.name.toLocaleLowerCase() + " " + conversation.messages.map((message) => message.content).join(" "); const searchable =
conversation.name.toLocaleLowerCase() +
' ' +
conversation.messages.map((message) => message.content).join(' ');
return searchable.toLowerCase().includes(searchTerm.toLowerCase()); return searchable.toLowerCase().includes(searchTerm.toLowerCase());
}) }),
); );
} else { } else {
setFilteredConversations(conversations); setFilteredConversations(conversations);
@ -80,13 +116,15 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
}, [searchTerm, conversations]); }, [searchTerm, conversations]);
return ( return (
<aside className={`h-full transition-all flex flex-none space-y-2 p-2 flex-col bg-[#202123] w-[260px] z-50 sm:relative sm:top-0 fixed top-0 bottom-0`}> <aside
className={`fixed top-0 bottom-0 z-50 flex h-full w-[260px] flex-none flex-col space-y-2 bg-[#202123] p-2 transition-all sm:relative sm:top-0`}
>
<header className="flex items-center"> <header className="flex items-center">
<button <button
className="flex gap-3 p-3 items-center w-[190px] rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20" className="flex w-[190px] flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={() => { onClick={() => {
onNewConversation(); onNewConversation();
setSearchTerm(""); setSearchTerm('');
}} }}
> >
<IconPlus size={16} /> <IconPlus size={16} />
@ -94,24 +132,21 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
</button> </button>
<button <button
className="ml-2 flex gap-3 p-3 items-center rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20" className="ml-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={() => onCreateFolder(t("New folder"))} onClick={() => onCreateFolder(t('New folder'))}
> >
<IconFolderPlus size={16} /> <IconFolderPlus size={16} />
</button> </button>
<IconArrowBarLeft <IconArrowBarLeft
className="ml-1 p-1 text-neutral-300 cursor-pointer hover:text-neutral-400 hidden sm:flex" className="ml-1 hidden cursor-pointer p-1 text-neutral-300 hover:text-neutral-400 sm:flex"
size={32} size={32}
onClick={onToggleSidebar} onClick={onToggleSidebar}
/> />
</header> </header>
{conversations.length > 1 && ( {conversations.length > 1 && (
<Search <Search searchTerm={searchTerm} onSearch={setSearchTerm} />
searchTerm={searchTerm}
onSearch={setSearchTerm}
/>
)} )}
<div className="flex-grow overflow-auto"> <div className="flex-grow overflow-auto">
@ -119,7 +154,9 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
<div className="flex border-b border-white/20 pb-2"> <div className="flex border-b border-white/20 pb-2">
<Folders <Folders
searchTerm={searchTerm} searchTerm={searchTerm}
conversations={filteredConversations.filter((conversation) => conversation.folderId !== 0)} conversations={filteredConversations.filter(
(conversation) => conversation.folderId !== 0,
)}
folders={folders} folders={folders}
onDeleteFolder={onDeleteFolder} onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder} onUpdateFolder={onUpdateFolder}
@ -134,7 +171,7 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
{conversations.length > 0 ? ( {conversations.length > 0 ? (
<div <div
className="pt-2 h-full" className="h-full pt-2"
onDrop={(e) => handleDrop(e)} onDrop={(e) => handleDrop(e)}
onDragOver={allowDrop} onDragOver={allowDrop}
onDragEnter={highlightDrop} onDragEnter={highlightDrop}
@ -142,7 +179,11 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
> >
<Conversations <Conversations
loading={loading} loading={loading}
conversations={filteredConversations.filter((conversation) => conversation.folderId === 0 || !folders[conversation.folderId - 1])} conversations={filteredConversations.filter(
(conversation) =>
conversation.folderId === 0 ||
!folders[conversation.folderId - 1],
)}
selectedConversation={selectedConversation} selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation} onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation} onDeleteConversation={handleDeleteConversation}
@ -150,7 +191,7 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
/> />
</div> </div>
) : ( ) : (
<div className="mt-4 text-white text-center"> <div className="mt-4 text-center text-white">
<div>{t('No conversations.')}</div> <div>{t('No conversations.')}</div>
</div> </div>
)} )}

View File

@ -1,4 +1,4 @@
import { FC } from "react"; import { FC } from 'react';
interface Props { interface Props {
text: string; text: string;
@ -9,7 +9,7 @@ interface Props {
export const SidebarButton: FC<Props> = ({ text, icon, onClick }) => { export const SidebarButton: FC<Props> = ({ text, icon, onClick }) => {
return ( return (
<button <button
className="flex py-3 px-3 gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer w-full items-center" className="flex w-full cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={onClick} onClick={onClick}
> >
<div>{icon}</div> <div>{icon}</div>

View File

@ -1,46 +1,58 @@
import { ChatFolder, Conversation } from "@/types"; import { ChatFolder, Conversation } from '@/types';
import { IconFileExport, IconMoon, IconSun } from "@tabler/icons-react"; import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
import { FC } from "react"; import { FC } from 'react';
import { useTranslation } from "next-i18next"; import { useTranslation } from 'next-i18next';
import { ClearConversations } from "./ClearConversations"; import { ClearConversations } from './ClearConversations';
import { Import } from "./Import"; import { Import } from './Import';
import { Key } from "./Key"; import { Key } from './Key';
import { SidebarButton } from "./SidebarButton"; import { SidebarButton } from './SidebarButton';
interface Props { interface Props {
lightMode: "light" | "dark"; lightMode: 'light' | 'dark';
apiKey: string; apiKey: string;
onToggleLightMode: (mode: "light" | "dark") => void; onToggleLightMode: (mode: 'light' | 'dark') => void;
onApiKeyChange: (apiKey: string) => void; onApiKeyChange: (apiKey: string) => void;
onClearConversations: () => void; onClearConversations: () => void;
onExportConversations: () => void; onExportConversations: () => void;
onImportConversations: (data: { conversations: Conversation[]; folders: ChatFolder[] }) => void; onImportConversations: (data: {
conversations: Conversation[];
folders: ChatFolder[];
}) => void;
} }
export const SidebarSettings: FC<Props> = ({ lightMode, apiKey, onToggleLightMode, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => { export const SidebarSettings: FC<Props> = ({
const { t} = useTranslation('sidebar') lightMode,
apiKey,
onToggleLightMode,
onApiKeyChange,
onClearConversations,
onExportConversations,
onImportConversations,
}) => {
const { t } = useTranslation('sidebar');
return ( return (
<div className="flex flex-col pt-1 items-center border-t border-white/20 text-sm space-y-1"> <div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
<ClearConversations onClearConversations={onClearConversations} /> <ClearConversations onClearConversations={onClearConversations} />
<Import onImport={onImportConversations} /> <Import onImport={onImportConversations} />
<SidebarButton <SidebarButton
text={t("Export conversations")} text={t('Export conversations')}
icon={<IconFileExport size={16} />} icon={<IconFileExport size={16} />}
onClick={() => onExportConversations()} onClick={() => onExportConversations()}
/> />
<SidebarButton <SidebarButton
text={lightMode === "light" ? t("Dark mode") : t("Light mode")} text={lightMode === 'light' ? t('Dark mode') : t('Light mode')}
icon={lightMode === "light" ? <IconMoon size={16} /> : <IconSun size={16} />} icon={
onClick={() => onToggleLightMode(lightMode === "light" ? "dark" : "light")} lightMode === 'light' ? <IconMoon size={16} /> : <IconSun size={16} />
}
onClick={() =>
onToggleLightMode(lightMode === 'light' ? 'dark' : 'light')
}
/> />
<Key <Key apiKey={apiKey} onApiKeyChange={onApiKeyChange} />
apiKey={apiKey}
onApiKeyChange={onApiKeyChange}
/>
</div> </div>
); );
}; };

View File

@ -17,7 +17,7 @@ module.exports = {
], ],
}, },
localePath: localePath:
typeof window === "undefined" typeof window === 'undefined'
? require("path").resolve("./public/locales") ? require('path').resolve('./public/locales')
: "/public/locales", : '/public/locales',
}; };

View File

@ -1,4 +1,4 @@
const { i18n } = require('./next-i18next.config') const { i18n } = require('./next-i18next.config');
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {

200
package-lock.json generated
View File

@ -34,6 +34,8 @@
"eslint": "8.36.0", "eslint": "8.36.0",
"eslint-config-next": "13.2.4", "eslint-config-next": "13.2.4",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "4.9.5" "typescript": "4.9.5"
} }
@ -461,19 +463,6 @@
"tailwindcss": ">=3.0.0 || insiders" "tailwindcss": ">=3.0.0 || insiders"
} }
}, },
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@ -1301,7 +1290,11 @@
"version": "3.29.1", "version": "3.29.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz",
"integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==", "integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==",
"hasInstallScript": true "hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
@ -2954,6 +2947,20 @@
"version": "22.4.13", "version": "22.4.13",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.4.13.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-22.4.13.tgz",
"integrity": "sha512-GX7flMHRRqQA0I1yGLmaZ4Hwt1JfLqagk8QPDPZsqekbKtXsuIngSVWM/s3SLgNkrEXjA+0sMGNuOEkkmyqmWg==", "integrity": "sha512-GX7flMHRRqQA0I1yGLmaZ4Hwt1JfLqagk8QPDPZsqekbKtXsuIngSVWM/s3SLgNkrEXjA+0sMGNuOEkkmyqmWg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.6" "@babel/runtime": "^7.20.6"
} }
@ -4675,6 +4682,20 @@
"version": "13.2.2", "version": "13.2.2",
"resolved": "https://registry.npmjs.org/next-i18next/-/next-i18next-13.2.2.tgz", "resolved": "https://registry.npmjs.org/next-i18next/-/next-i18next-13.2.2.tgz",
"integrity": "sha512-t0WU6K+HJoq2nVQ0n6OiiEZja9GyMqtDSU74FmOafgk4ljns+iZ18bsNJiI8rOUXfFfkW96ea1N7D5kbMyT+PA==", "integrity": "sha512-t0WU6K+HJoq2nVQ0n6OiiEZja9GyMqtDSU74FmOafgk4ljns+iZ18bsNJiI8rOUXfFfkW96ea1N7D5kbMyT+PA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://locize.com"
}
],
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.20.13",
"@types/hoist-non-react-statics": "^3.3.1", "@types/hoist-non-react-statics": "^3.3.1",
@ -5167,9 +5188,9 @@
} }
}, },
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "6.0.11", "version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
@ -5194,6 +5215,95 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
"integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.5.tgz",
"integrity": "sha512-vZ/iKieyCx0WTxHbkf5E1rBlv/ybFk8WTT4hL5W2jlVxum2Zbe0jMUpuQdDrpa4z2vnPiJ5KIWCqL/kd16fKYg==",
"dev": true,
"engines": {
"node": ">=12.17.0"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-php": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@shufo/prettier-plugin-blade": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"prettier": ">=2.2.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-import-sort": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-style-order": "*",
"prettier-plugin-svelte": "*",
"prettier-plugin-twig-melody": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-php": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@shufo/prettier-plugin-blade": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-import-sort": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-style-order": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
},
"prettier-plugin-twig-melody": {
"optional": true
}
}
},
"node_modules/prismjs": { "node_modules/prismjs": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
@ -5973,6 +6083,19 @@
"postcss": "^8.0.9" "postcss": "^8.0.9"
} }
}, },
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
"integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@ -6855,18 +6978,6 @@
"lodash.isplainobject": "^4.0.6", "lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10" "postcss-selector-parser": "6.0.10"
},
"dependencies": {
"postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
}
}
} }
}, },
"@tootallnate/once": { "@tootallnate/once": {
@ -10136,9 +10247,9 @@
} }
}, },
"postcss-selector-parser": { "postcss-selector-parser": {
"version": "6.0.11", "version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true, "dev": true,
"requires": { "requires": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
@ -10157,6 +10268,19 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true "dev": true
}, },
"prettier": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
"integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==",
"dev": true
},
"prettier-plugin-tailwindcss": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.5.tgz",
"integrity": "sha512-vZ/iKieyCx0WTxHbkf5E1rBlv/ybFk8WTT4hL5W2jlVxum2Zbe0jMUpuQdDrpa4z2vnPiJ5KIWCqL/kd16fKYg==",
"dev": true,
"requires": {}
},
"prismjs": { "prismjs": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
@ -10700,6 +10824,18 @@
"postcss-value-parser": "^4.2.0", "postcss-value-parser": "^4.2.0",
"quick-lru": "^5.1.1", "quick-lru": "^5.1.1",
"resolve": "^1.22.1" "resolve": "^1.22.1"
},
"dependencies": {
"postcss-selector-parser": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
"integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
}
}
} }
}, },
"tapable": { "tapable": {

View File

@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@dqbd/tiktoken": "^1.0.2", "@dqbd/tiktoken": "^1.0.2",
@ -35,6 +36,8 @@
"eslint": "8.36.0", "eslint": "8.36.0",
"eslint-config-next": "13.2.4", "eslint-config-next": "13.2.4",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "4.9.5" "typescript": "4.9.5"
} }

View File

@ -1,9 +1,9 @@
import "@/styles/globals.css"; import '@/styles/globals.css';
import { appWithTranslation } from "next-i18next"; import { appWithTranslation } from 'next-i18next';
import type { AppProps } from "next/app"; import type { AppProps } from 'next/app';
import { Inter } from "next/font/google"; import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ['latin'] });
function App({ Component, pageProps }: AppProps<{}>) { function App({ Component, pageProps }: AppProps<{}>) {
return ( return (

View File

@ -1,14 +1,13 @@
import { Html, Head, Main, NextScript, DocumentProps } from 'next/document' import { Html, Head, Main, NextScript, DocumentProps } from 'next/document';
import i18nextConfig from '../next-i18next.config' import i18nextConfig from '../next-i18next.config';
type Props = DocumentProps & { type Props = DocumentProps & {
// add custom document props // add custom document props
} };
export default function Document(props: Props) { export default function Document(props: Props) {
const currentLocale = const currentLocale =
props.__NEXT_DATA__.locale ?? props.__NEXT_DATA__.locale ?? i18nextConfig.i18n.defaultLocale;
i18nextConfig.i18n.defaultLocale
return ( return (
<Html lang={currentLocale}> <Html lang={currentLocale}>
<Head> <Head>
@ -20,5 +19,5 @@ export default function Document(props: Props) {
<NextScript /> <NextScript />
</body> </body>
</Html> </Html>
) );
} }

View File

@ -1,13 +1,13 @@
import { ChatBody, Message, OpenAIModelID } from "@/types"; import { ChatBody, Message, OpenAIModelID } from '@/types';
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const"; import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { OpenAIStream } from "@/utils/server"; import { OpenAIStream } from '@/utils/server';
import tiktokenModel from "@dqbd/tiktoken/encoders/cl100k_base.json"; import tiktokenModel from '@dqbd/tiktoken/encoders/cl100k_base.json';
import { init, Tiktoken } from "@dqbd/tiktoken/lite/init"; import { init, Tiktoken } from '@dqbd/tiktoken/lite/init';
// @ts-expect-error // @ts-expect-error
import wasm from "../../node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm?module"; import wasm from '../../node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm?module';
export const config = { export const config = {
runtime: "edge", runtime: 'edge',
}; };
const handler = async (req: Request): Promise<Response> => { const handler = async (req: Request): Promise<Response> => {
@ -18,7 +18,7 @@ const handler = async (req: Request): Promise<Response> => {
const encoding = new Tiktoken( const encoding = new Tiktoken(
tiktokenModel.bpe_ranks, tiktokenModel.bpe_ranks,
tiktokenModel.special_tokens, tiktokenModel.special_tokens,
tiktokenModel.pat_str tiktokenModel.pat_str,
); );
const tokenLimit = model.id === OpenAIModelID.GPT_4 ? 6000 : 3000; const tokenLimit = model.id === OpenAIModelID.GPT_4 ? 6000 : 3000;
@ -51,7 +51,7 @@ const handler = async (req: Request): Promise<Response> => {
return new Response(stream); return new Response(stream);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return new Response("Error", { status: 500 }); return new Response('Error', { status: 500 });
} }
}; };

View File

@ -1,8 +1,8 @@
import { OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types"; import { OpenAIModel, OpenAIModelID, OpenAIModels } from '@/types';
import { OPENAI_API_HOST } from "@/utils/app/const"; import { OPENAI_API_HOST } from '@/utils/app/const';
export const config = { export const config = {
runtime: "edge" runtime: 'edge',
}; };
const handler = async (req: Request): Promise<Response> => { const handler = async (req: Request): Promise<Response> => {
@ -13,22 +13,23 @@ const handler = async (req: Request): Promise<Response> => {
const response = await fetch(`${OPENAI_API_HOST}/v1/models`, { const response = await fetch(`${OPENAI_API_HOST}/v1/models`, {
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}` Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`,
} },
}); });
if (response.status === 401) { if (response.status === 401) {
return new Response( return new Response(response.body, {
response.body,
{
status: 500, status: 500,
headers: response.headers headers: response.headers,
} });
);
} else if (response.status !== 200) { } else if (response.status !== 200) {
console.error(`OpenAI API returned an error ${response.status}: ${await response.text()}`) console.error(
throw new Error("OpenAI API returned an error"); `OpenAI API returned an error ${
response.status
}: ${await response.text()}`,
);
throw new Error('OpenAI API returned an error');
} }
const json = await response.json(); const json = await response.json();
@ -39,7 +40,7 @@ const handler = async (req: Request): Promise<Response> => {
if (value === model.id) { if (value === model.id) {
return { return {
id: model.id, id: model.id,
name: OpenAIModels[value].name name: OpenAIModels[value].name,
}; };
} }
} }
@ -49,7 +50,7 @@ const handler = async (req: Request): Promise<Response> => {
return new Response(JSON.stringify(models), { status: 200 }); return new Response(JSON.stringify(models), { status: 200 });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return new Response("Error", { status: 500 }); return new Response('Error', { status: 500 });
} }
}; };

View File

@ -1,35 +1,52 @@
import { Chat } from "@/components/Chat/Chat"; import { Chat } from '@/components/Chat/Chat';
import { Navbar } from "@/components/Mobile/Navbar"; import { Navbar } from '@/components/Mobile/Navbar';
import { Sidebar } from "@/components/Sidebar/Sidebar"; import { Sidebar } from '@/components/Sidebar/Sidebar';
import { ChatBody, ChatFolder, Conversation, ErrorMessage, KeyValuePair, Message, OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types"; import {
import { cleanConversationHistory, cleanSelectedConversation } from "@/utils/app/clean"; ChatBody,
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const"; ChatFolder,
import { saveConversation, saveConversations, updateConversation } from "@/utils/app/conversation"; Conversation,
import { saveFolders } from "@/utils/app/folders"; ErrorMessage,
import { exportData, importData } from "@/utils/app/importExport"; KeyValuePair,
import { IconArrowBarLeft, IconArrowBarRight } from "@tabler/icons-react"; Message,
import { GetServerSideProps } from "next"; OpenAIModel,
import Head from "next/head"; OpenAIModelID,
import { useEffect, useRef, useState } from "react"; OpenAIModels,
import { serverSideTranslations } from 'next-i18next/serverSideTranslations' } from '@/types';
import { useTranslation } from "next-i18next"; import {
cleanConversationHistory,
cleanSelectedConversation,
} from '@/utils/app/clean';
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import {
saveConversation,
saveConversations,
updateConversation,
} from '@/utils/app/conversation';
import { saveFolders } from '@/utils/app/folders';
import { exportData, importData } from '@/utils/app/importExport';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import Head from 'next/head';
import { useEffect, useRef, useState } from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useTranslation } from 'next-i18next';
interface HomeProps { interface HomeProps {
serverSideApiKeyIsSet: boolean; serverSideApiKeyIsSet: boolean;
} }
const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => { const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const { t } = useTranslation('chat') const { t } = useTranslation('chat');
const [folders, setFolders] = useState<ChatFolder[]>([]); const [folders, setFolders] = useState<ChatFolder[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation>(); const [selectedConversation, setSelectedConversation] =
useState<Conversation>();
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [models, setModels] = useState<OpenAIModel[]>([]); const [models, setModels] = useState<OpenAIModel[]>([]);
const [lightMode, setLightMode] = useState<"dark" | "light">("dark"); const [lightMode, setLightMode] = useState<'dark' | 'light'>('dark');
const [messageIsStreaming, setMessageIsStreaming] = useState<boolean>(false); const [messageIsStreaming, setMessageIsStreaming] = useState<boolean>(false);
const [showSidebar, setShowSidebar] = useState<boolean>(true); const [showSidebar, setShowSidebar] = useState<boolean>(true);
const [apiKey, setApiKey] = useState<string>(""); const [apiKey, setApiKey] = useState<string>('');
const [messageError, setMessageError] = useState<boolean>(false); const [messageError, setMessageError] = useState<boolean>(false);
const [modelError, setModelError] = useState<ErrorMessage | null>(null); const [modelError, setModelError] = useState<ErrorMessage | null>(null);
const [currentMessage, setCurrentMessage] = useState<Message>(); const [currentMessage, setCurrentMessage] = useState<Message>();
@ -48,12 +65,12 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
updatedConversation = { updatedConversation = {
...selectedConversation, ...selectedConversation,
messages: [...updatedMessages, message] messages: [...updatedMessages, message],
}; };
} else { } else {
updatedConversation = { updatedConversation = {
...selectedConversation, ...selectedConversation,
messages: [...selectedConversation.messages, message] messages: [...selectedConversation.messages, message],
}; };
} }
@ -66,17 +83,17 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
model: updatedConversation.model, model: updatedConversation.model,
messages: updatedConversation.messages, messages: updatedConversation.messages,
key: apiKey, key: apiKey,
prompt: updatedConversation.prompt prompt: updatedConversation.prompt,
}; };
const controller = new AbortController(); const controller = new AbortController();
const response = await fetch("/api/chat", { const response = await fetch('/api/chat', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json" 'Content-Type': 'application/json',
}, },
signal: controller.signal, signal: controller.signal,
body: JSON.stringify(chatBody) body: JSON.stringify(chatBody),
}); });
if (!response.ok) { if (!response.ok) {
@ -98,11 +115,12 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
if (updatedConversation.messages.length === 1) { if (updatedConversation.messages.length === 1) {
const { content } = message; const { content } = message;
const customName = content.length > 30 ? content.substring(0, 30) + "..." : content; const customName =
content.length > 30 ? content.substring(0, 30) + '...' : content;
updatedConversation = { updatedConversation = {
...updatedConversation, ...updatedConversation,
name: customName name: customName,
}; };
} }
@ -112,7 +130,7 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let done = false; let done = false;
let isFirst = true; let isFirst = true;
let text = ""; let text = '';
while (!done) { while (!done) {
if (stopConversationRef.current === true) { if (stopConversationRef.current === true) {
@ -128,29 +146,34 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
if (isFirst) { if (isFirst) {
isFirst = false; isFirst = false;
const updatedMessages: Message[] = [...updatedConversation.messages, { role: "assistant", content: chunkValue }]; const updatedMessages: Message[] = [
...updatedConversation.messages,
{ role: 'assistant', content: chunkValue },
];
updatedConversation = { updatedConversation = {
...updatedConversation, ...updatedConversation,
messages: updatedMessages messages: updatedMessages,
}; };
setSelectedConversation(updatedConversation); setSelectedConversation(updatedConversation);
} else { } else {
const updatedMessages: Message[] = updatedConversation.messages.map((message, index) => { const updatedMessages: Message[] = updatedConversation.messages.map(
(message, index) => {
if (index === updatedConversation.messages.length - 1) { if (index === updatedConversation.messages.length - 1) {
return { return {
...message, ...message,
content: text content: text,
}; };
} }
return message; return message;
}); },
);
updatedConversation = { updatedConversation = {
...updatedConversation, ...updatedConversation,
messages: updatedMessages messages: updatedMessages,
}; };
setSelectedConversation(updatedConversation); setSelectedConversation(updatedConversation);
@ -159,13 +182,15 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
saveConversation(updatedConversation); saveConversation(updatedConversation);
const updatedConversations: Conversation[] = conversations.map((conversation) => { const updatedConversations: Conversation[] = conversations.map(
(conversation) => {
if (conversation.id === selectedConversation.id) { if (conversation.id === selectedConversation.id) {
return updatedConversation; return updatedConversation;
} }
return conversation; return conversation;
}); },
);
if (updatedConversations.length === 0) { if (updatedConversations.length === 0) {
updatedConversations.push(updatedConversation); updatedConversations.push(updatedConversation);
@ -184,19 +209,21 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
title: t('Error fetching models.'), title: t('Error fetching models.'),
code: null, code: null,
messageLines: [ messageLines: [
t('Make sure your OpenAI API key is set in the bottom left of the sidebar.'), t(
t('If you completed this step, OpenAI may be experiencing issues.') 'Make sure your OpenAI API key is set in the bottom left of the sidebar.',
] ),
t('If you completed this step, OpenAI may be experiencing issues.'),
],
} as ErrorMessage; } as ErrorMessage;
const response = await fetch("/api/models", { const response = await fetch('/api/models', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json" 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
key key,
}) }),
}); });
if (!response.ok) { if (!response.ok) {
@ -204,8 +231,8 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const data = await response.json(); const data = await response.json();
Object.assign(error, { Object.assign(error, {
code: data.error?.code, code: data.error?.code,
messageLines: [data.error?.message] messageLines: [data.error?.message],
}) });
} catch (e) {} } catch (e) {}
setModelError(error); setModelError(error);
return; return;
@ -222,21 +249,24 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
setModelError(null); setModelError(null);
}; };
const handleLightMode = (mode: "dark" | "light") => { const handleLightMode = (mode: 'dark' | 'light') => {
setLightMode(mode); setLightMode(mode);
localStorage.setItem("theme", mode); localStorage.setItem('theme', mode);
}; };
const handleApiKeyChange = (apiKey: string) => { const handleApiKeyChange = (apiKey: string) => {
setApiKey(apiKey); setApiKey(apiKey);
localStorage.setItem("apiKey", apiKey); localStorage.setItem('apiKey', apiKey);
}; };
const handleExportData = () => { const handleExportData = () => {
exportData(); exportData();
}; };
const handleImportConversations = (data: { conversations: Conversation[]; folders: ChatFolder[] }) => { const handleImportConversations = (data: {
conversations: Conversation[];
folders: ChatFolder[];
}) => {
importData(data.conversations, data.folders); importData(data.conversations, data.folders);
setConversations(data.conversations); setConversations(data.conversations);
setSelectedConversation(data.conversations[data.conversations.length - 1]); setSelectedConversation(data.conversations[data.conversations.length - 1]);
@ -253,7 +283,7 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const newFolder: ChatFolder = { const newFolder: ChatFolder = {
id: lastFolder ? lastFolder.id + 1 : 1, id: lastFolder ? lastFolder.id + 1 : 1,
name name,
}; };
const updatedFolders = [...folders, newFolder]; const updatedFolders = [...folders, newFolder];
@ -271,7 +301,7 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
if (c.folderId === folderId) { if (c.folderId === folderId) {
return { return {
...c, ...c,
folderId: 0 folderId: 0,
}; };
} }
@ -286,7 +316,7 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
if (f.id === folderId) { if (f.id === folderId) {
return { return {
...f, ...f,
name name,
}; };
} }
@ -302,11 +332,13 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const newConversation: Conversation = { const newConversation: Conversation = {
id: lastConversation ? lastConversation.id + 1 : 1, id: lastConversation ? lastConversation.id + 1 : 1,
name: `${t('Conversation')} ${lastConversation ? lastConversation.id + 1 : 1}`, name: `${t('Conversation')} ${
lastConversation ? lastConversation.id + 1 : 1
}`,
messages: [], messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5], model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT, prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0 folderId: 0,
}; };
const updatedConversations = [...conversations, newConversation]; const updatedConversations = [...conversations, newConversation];
@ -321,33 +353,43 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
}; };
const handleDeleteConversation = (conversation: Conversation) => { const handleDeleteConversation = (conversation: Conversation) => {
const updatedConversations = conversations.filter((c) => c.id !== conversation.id); const updatedConversations = conversations.filter(
(c) => c.id !== conversation.id,
);
setConversations(updatedConversations); setConversations(updatedConversations);
saveConversations(updatedConversations); saveConversations(updatedConversations);
if (updatedConversations.length > 0) { if (updatedConversations.length > 0) {
setSelectedConversation(updatedConversations[updatedConversations.length - 1]); setSelectedConversation(
updatedConversations[updatedConversations.length - 1],
);
saveConversation(updatedConversations[updatedConversations.length - 1]); saveConversation(updatedConversations[updatedConversations.length - 1]);
} else { } else {
setSelectedConversation({ setSelectedConversation({
id: 1, id: 1,
name: "New conversation", name: 'New conversation',
messages: [], messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5], model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT, prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0 folderId: 0,
}); });
localStorage.removeItem("selectedConversation"); localStorage.removeItem('selectedConversation');
} }
}; };
const handleUpdateConversation = (conversation: Conversation, data: KeyValuePair) => { const handleUpdateConversation = (
conversation: Conversation,
data: KeyValuePair,
) => {
const updatedConversation = { const updatedConversation = {
...conversation, ...conversation,
[data.key]: data.value [data.key]: data.value,
}; };
const { single, all } = updateConversation(updatedConversation, conversations); const { single, all } = updateConversation(
updatedConversation,
conversations,
);
setSelectedConversation(single); setSelectedConversation(single);
setConversations(all); setConversations(all);
@ -355,20 +397,20 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const handleClearConversations = () => { const handleClearConversations = () => {
setConversations([]); setConversations([]);
localStorage.removeItem("conversationHistory"); localStorage.removeItem('conversationHistory');
setSelectedConversation({ setSelectedConversation({
id: 1, id: 1,
name: "New conversation", name: 'New conversation',
messages: [], messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5], model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT, prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0 folderId: 0,
}); });
localStorage.removeItem("selectedConversation"); localStorage.removeItem('selectedConversation');
setFolders([]); setFolders([]);
localStorage.removeItem("folders"); localStorage.removeItem('folders');
}; };
const handleEditMessage = (message: Message, messageIndex: number) => { const handleEditMessage = (message: Message, messageIndex: number) => {
@ -383,10 +425,13 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
const updatedConversation = { const updatedConversation = {
...selectedConversation, ...selectedConversation,
messages: updatedMessages messages: updatedMessages,
}; };
const { single, all } = updateConversation(updatedConversation, conversations); const { single, all } = updateConversation(
updatedConversation,
conversations,
);
setSelectedConversation(single); setSelectedConversation(single);
setConversations(all); setConversations(all);
@ -415,48 +460,54 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
}, [apiKey]); }, [apiKey]);
useEffect(() => { useEffect(() => {
const theme = localStorage.getItem("theme"); const theme = localStorage.getItem('theme');
if (theme) { if (theme) {
setLightMode(theme as "dark" | "light"); setLightMode(theme as 'dark' | 'light');
} }
const apiKey = localStorage.getItem("apiKey"); const apiKey = localStorage.getItem('apiKey');
if (apiKey) { if (apiKey) {
setApiKey(apiKey); setApiKey(apiKey);
fetchModels(apiKey); fetchModels(apiKey);
} else if (serverSideApiKeyIsSet) { } else if (serverSideApiKeyIsSet) {
fetchModels(""); fetchModels('');
} }
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
setShowSidebar(false); setShowSidebar(false);
} }
const folders = localStorage.getItem("folders"); const folders = localStorage.getItem('folders');
if (folders) { if (folders) {
setFolders(JSON.parse(folders)); setFolders(JSON.parse(folders));
} }
const conversationHistory = localStorage.getItem("conversationHistory"); const conversationHistory = localStorage.getItem('conversationHistory');
if (conversationHistory) { if (conversationHistory) {
const parsedConversationHistory: Conversation[] = JSON.parse(conversationHistory); const parsedConversationHistory: Conversation[] =
const cleanedConversationHistory = cleanConversationHistory(parsedConversationHistory); JSON.parse(conversationHistory);
const cleanedConversationHistory = cleanConversationHistory(
parsedConversationHistory,
);
setConversations(cleanedConversationHistory); setConversations(cleanedConversationHistory);
} }
const selectedConversation = localStorage.getItem("selectedConversation"); const selectedConversation = localStorage.getItem('selectedConversation');
if (selectedConversation) { if (selectedConversation) {
const parsedSelectedConversation: Conversation = JSON.parse(selectedConversation); const parsedSelectedConversation: Conversation =
const cleanedSelectedConversation = cleanSelectedConversation(parsedSelectedConversation); JSON.parse(selectedConversation);
const cleanedSelectedConversation = cleanSelectedConversation(
parsedSelectedConversation,
);
setSelectedConversation(cleanedSelectedConversation); setSelectedConversation(cleanedSelectedConversation);
} else { } else {
setSelectedConversation({ setSelectedConversation({
id: 1, id: 1,
name: "New conversation", name: 'New conversation',
messages: [], messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5], model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT, prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0 folderId: 0,
}); });
} }
}, [serverSideApiKeyIsSet]); }, [serverSideApiKeyIsSet]);
@ -465,22 +516,15 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
<> <>
<Head> <Head>
<title>Chatbot UI</title> <title>Chatbot UI</title>
<meta <meta name="description" content="ChatGPT but better." />
name="description" <meta name="viewport" content="width=device-width, initial-scale=1" />
content="ChatGPT but better." <link rel="icon" href="/favicon.ico" />
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<link
rel="icon"
href="/favicon.ico"
/>
</Head> </Head>
{selectedConversation && ( {selectedConversation && (
<main className={`flex flex-col h-screen w-screen text-white dark:text-white text-sm ${lightMode}`}> <main
<div className="sm:hidden w-full fixed top-0"> className={`flex h-screen w-screen flex-col text-sm text-white dark:text-white ${lightMode}`}
>
<div className="fixed top-0 w-full sm:hidden">
<Navbar <Navbar
selectedConversation={selectedConversation} selectedConversation={selectedConversation}
onNewConversation={handleNewConversation} onNewConversation={handleNewConversation}
@ -513,18 +557,18 @@ const Home: React.FC<HomeProps> = ({ serverSideApiKeyIsSet }) => {
/> />
<IconArrowBarLeft <IconArrowBarLeft
className="z-50 fixed top-5 left-[270px] sm:top-0.5 sm:left-[270px] sm:text-neutral-700 dark:text-white cursor-pointer hover:text-gray-400 dark:hover:text-gray-300 h-7 w-7 sm:h-8 sm:w-8" className="fixed top-5 left-[270px] z-50 h-7 w-7 cursor-pointer hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:left-[270px] sm:h-8 sm:w-8 sm:text-neutral-700"
onClick={() => setShowSidebar(!showSidebar)} onClick={() => setShowSidebar(!showSidebar)}
/> />
<div <div
onClick={() => setShowSidebar(!showSidebar)} onClick={() => setShowSidebar(!showSidebar)}
className="sm:hidden bg-black opacity-70 z-10 absolute top-0 left-0 h-full w-full" className="absolute top-0 left-0 z-10 h-full w-full bg-black opacity-70 sm:hidden"
></div> ></div>
</div> </div>
) : ( ) : (
<IconArrowBarRight <IconArrowBarRight
className="fixed text-white z-50 top-2.5 left-4 sm:top-0.5 sm:left-4 sm:text-neutral-700 dark:text-white cursor-pointer hover:text-gray-400 dark:hover:text-gray-300 h-7 w-7 sm:h-8 sm:w-8" className="fixed top-2.5 left-4 z-50 h-7 w-7 cursor-pointer text-white hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:left-4 sm:h-8 sm:w-8 sm:text-neutral-700"
onClick={() => setShowSidebar(!showSidebar)} onClick={() => setShowSidebar(!showSidebar)}
/> />
)} )}
@ -562,6 +606,6 @@ export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
'sidebar', 'sidebar',
'markdown', 'markdown',
])), ])),
} },
}; };
}; };

View File

@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

5
prettier.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
trailingComma: 'all',
singleQuote: true,
plugins: [require('prettier-plugin-tailwindcss')]
};

View File

@ -1,9 +1,13 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ["./app/**/*.{js,ts,jsx,tsx}", "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"], content: [
darkMode: "class", './app/**/*.{js,ts,jsx,tsx}',
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
darkMode: 'class',
theme: { theme: {
extend: {} extend: {},
}, },
plugins: [require("@tailwindcss/typography")] plugins: [require('@tailwindcss/typography')],
}; };

View File

@ -4,19 +4,19 @@ export interface OpenAIModel {
} }
export enum OpenAIModelID { export enum OpenAIModelID {
GPT_3_5 = "gpt-3.5-turbo", GPT_3_5 = 'gpt-3.5-turbo',
GPT_4 = "gpt-4" GPT_4 = 'gpt-4',
} }
export const OpenAIModels: Record<OpenAIModelID, OpenAIModel> = { export const OpenAIModels: Record<OpenAIModelID, OpenAIModel> = {
[OpenAIModelID.GPT_3_5]: { [OpenAIModelID.GPT_3_5]: {
id: OpenAIModelID.GPT_3_5, id: OpenAIModelID.GPT_3_5,
name: "Default (GPT-3.5)" name: 'Default (GPT-3.5)',
}, },
[OpenAIModelID.GPT_4]: { [OpenAIModelID.GPT_4]: {
id: OpenAIModelID.GPT_4, id: OpenAIModelID.GPT_4,
name: "GPT-4" name: 'GPT-4',
} },
}; };
export interface Message { export interface Message {
@ -24,7 +24,7 @@ export interface Message {
content: string; content: string;
} }
export type Role = "assistant" | "user"; export type Role = 'assistant' | 'user';
export interface ChatFolder { export interface ChatFolder {
id: number; id: number;
@ -57,13 +57,13 @@ export interface LocalStorage {
apiKey: string; apiKey: string;
conversationHistory: Conversation[]; conversationHistory: Conversation[];
selectedConversation: Conversation; selectedConversation: Conversation;
theme: "light" | "dark"; theme: 'light' | 'dark';
// added folders (3/23/23) // added folders (3/23/23)
folders: ChatFolder[]; folders: ChatFolder[];
} }
export interface ErrorMessage { export interface ErrorMessage {
code: String | null, code: String | null;
title: String, title: String;
messageLines: String[] messageLines: String[];
} }

View File

@ -1,5 +1,5 @@
import { Conversation, OpenAIModelID, OpenAIModels } from "@/types"; import { Conversation, OpenAIModelID, OpenAIModels } from '@/types';
import { DEFAULT_SYSTEM_PROMPT } from "./const"; import { DEFAULT_SYSTEM_PROMPT } from './const';
export const cleanSelectedConversation = (conversation: Conversation) => { export const cleanSelectedConversation = (conversation: Conversation) => {
// added model for each conversation (3/20/23) // added model for each conversation (3/20/23)
@ -12,7 +12,7 @@ export const cleanSelectedConversation = (conversation: Conversation) => {
if (!updatedConversation.model) { if (!updatedConversation.model) {
updatedConversation = { updatedConversation = {
...updatedConversation, ...updatedConversation,
model: updatedConversation.model || OpenAIModels[OpenAIModelID.GPT_3_5] model: updatedConversation.model || OpenAIModels[OpenAIModelID.GPT_3_5],
}; };
} }
@ -20,14 +20,14 @@ export const cleanSelectedConversation = (conversation: Conversation) => {
if (!updatedConversation.prompt) { if (!updatedConversation.prompt) {
updatedConversation = { updatedConversation = {
...updatedConversation, ...updatedConversation,
prompt: updatedConversation.prompt || DEFAULT_SYSTEM_PROMPT prompt: updatedConversation.prompt || DEFAULT_SYSTEM_PROMPT,
}; };
} }
if (!updatedConversation.folderId) { if (!updatedConversation.folderId) {
updatedConversation = { updatedConversation = {
...updatedConversation, ...updatedConversation,
folderId: updatedConversation.folderId || 0 folderId: updatedConversation.folderId || 0,
}; };
} }
@ -56,7 +56,10 @@ export const cleanConversationHistory = (history: Conversation[]) => {
acc.push(conversation); acc.push(conversation);
return acc; return acc;
} catch (error) { } catch (error) {
console.warn(`error while cleaning conversations' history. Removing culprit`, error); console.warn(
`error while cleaning conversations' history. Removing culprit`,
error,
);
} }
return acc; return acc;
}, []); }, []);

View File

@ -3,35 +3,35 @@ interface languageMap {
} }
export const programmingLanguages: languageMap = { export const programmingLanguages: languageMap = {
javascript: ".js", javascript: '.js',
python: ".py", python: '.py',
java: ".java", java: '.java',
c: ".c", c: '.c',
cpp: ".cpp", cpp: '.cpp',
"c++": ".cpp", 'c++': '.cpp',
"c#": ".cs", 'c#': '.cs',
ruby: ".rb", ruby: '.rb',
php: ".php", php: '.php',
swift: ".swift", swift: '.swift',
"objective-c": ".m", 'objective-c': '.m',
kotlin: ".kt", kotlin: '.kt',
typescript: ".ts", typescript: '.ts',
go: ".go", go: '.go',
perl: ".pl", perl: '.pl',
rust: ".rs", rust: '.rs',
scala: ".scala", scala: '.scala',
haskell: ".hs", haskell: '.hs',
lua: ".lua", lua: '.lua',
shell: ".sh", shell: '.sh',
sql: ".sql", sql: '.sql',
html: ".html", html: '.html',
css: ".css" css: '.css',
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
}; };
export const generateRandomString = (length: Number, lowercase = false) => { export const generateRandomString = (length: Number, lowercase = false) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0 const chars = 'ABCDEFGHJKLMNPQRSTUVWXY3456789'; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = ""; let result = '';
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)); result += chars.charAt(Math.floor(Math.random() * chars.length));
} }

View File

@ -1,4 +1,5 @@
export const DEFAULT_SYSTEM_PROMPT = "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown."; export const DEFAULT_SYSTEM_PROMPT =
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.";
export const OPENAI_API_HOST =
export const OPENAI_API_HOST = process.env.OPENAI_API_HOST || 'https://api.openai.com' process.env.OPENAI_API_HOST || 'https://api.openai.com';

View File

@ -1,6 +1,9 @@
import { Conversation } from "@/types"; import { Conversation } from '@/types';
export const updateConversation = (updatedConversation: Conversation, allConversations: Conversation[]) => { export const updateConversation = (
updatedConversation: Conversation,
allConversations: Conversation[],
) => {
const updatedConversations = allConversations.map((c) => { const updatedConversations = allConversations.map((c) => {
if (c.id === updatedConversation.id) { if (c.id === updatedConversation.id) {
return updatedConversation; return updatedConversation;
@ -14,14 +17,14 @@ export const updateConversation = (updatedConversation: Conversation, allConvers
return { return {
single: updatedConversation, single: updatedConversation,
all: updatedConversations all: updatedConversations,
}; };
}; };
export const saveConversation = (conversation: Conversation) => { export const saveConversation = (conversation: Conversation) => {
localStorage.setItem("selectedConversation", JSON.stringify(conversation)); localStorage.setItem('selectedConversation', JSON.stringify(conversation));
}; };
export const saveConversations = (conversations: Conversation[]) => { export const saveConversations = (conversations: Conversation[]) => {
localStorage.setItem("conversationHistory", JSON.stringify(conversations)); localStorage.setItem('conversationHistory', JSON.stringify(conversations));
}; };

View File

@ -1,5 +1,5 @@
import { ChatFolder } from "@/types"; import { ChatFolder } from '@/types';
export const saveFolders = (folders: ChatFolder[]) => { export const saveFolders = (folders: ChatFolder[]) => {
localStorage.setItem("folders", JSON.stringify(folders)); localStorage.setItem('folders', JSON.stringify(folders));
}; };

View File

@ -1,8 +1,8 @@
import { ChatFolder, Conversation } from "@/types"; import { ChatFolder, Conversation } from '@/types';
export const exportData = () => { export const exportData = () => {
let history = localStorage.getItem("conversationHistory"); let history = localStorage.getItem('conversationHistory');
let folders = localStorage.getItem("folders"); let folders = localStorage.getItem('folders');
if (history) { if (history) {
history = JSON.parse(history); history = JSON.parse(history);
@ -14,23 +14,29 @@ export const exportData = () => {
const data = { const data = {
history, history,
folders folders,
}; };
const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement('a');
link.download = "chatbot_ui_history.json"; link.download = 'chatbot_ui_history.json';
link.href = url; link.href = url;
link.style.display = "none"; link.style.display = 'none';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
export const importData = (conversations: Conversation[], folders: ChatFolder[]) => { export const importData = (
localStorage.setItem("conversationHistory", JSON.stringify(conversations)); conversations: Conversation[],
localStorage.setItem("selectedConversation", JSON.stringify(conversations[conversations.length - 1])); folders: ChatFolder[],
localStorage.setItem("folders", JSON.stringify(folders)); ) => {
localStorage.setItem('conversationHistory', JSON.stringify(conversations));
localStorage.setItem(
'selectedConversation',
JSON.stringify(conversations[conversations.length - 1]),
);
localStorage.setItem('folders', JSON.stringify(folders));
}; };

View File

@ -1,27 +1,36 @@
import { Message, OpenAIModel } from "@/types"; import { Message, OpenAIModel } from '@/types';
import { createParser, ParsedEvent, ReconnectInterval } from "eventsource-parser"; import {
import { OPENAI_API_HOST } from "../app/const"; createParser,
ParsedEvent,
ReconnectInterval,
} from 'eventsource-parser';
import { OPENAI_API_HOST } from '../app/const';
export const OpenAIStream = async (model: OpenAIModel, systemPrompt: string, key: string, messages: Message[]) => { export const OpenAIStream = async (
model: OpenAIModel,
systemPrompt: string,
key: string,
messages: Message[],
) => {
const res = await fetch(`${OPENAI_API_HOST}/v1/chat/completions`, { const res = await fetch(`${OPENAI_API_HOST}/v1/chat/completions`, {
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}` Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`,
}, },
method: "POST", method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
model: model.id, model: model.id,
messages: [ messages: [
{ {
role: "system", role: 'system',
content: systemPrompt content: systemPrompt,
}, },
...messages ...messages,
], ],
max_tokens: 1000, max_tokens: 1000,
temperature: 1, temperature: 1,
stream: true stream: true,
}) }),
}); });
if (res.status !== 200) { if (res.status !== 200) {
@ -35,10 +44,10 @@ export const OpenAIStream = async (model: OpenAIModel, systemPrompt: string, key
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
const onParse = (event: ParsedEvent | ReconnectInterval) => { const onParse = (event: ParsedEvent | ReconnectInterval) => {
if (event.type === "event") { if (event.type === 'event') {
const data = event.data; const data = event.data;
if (data === "[DONE]") { if (data === '[DONE]') {
controller.close(); controller.close();
return; return;
} }
@ -59,7 +68,7 @@ export const OpenAIStream = async (model: OpenAIModel, systemPrompt: string, key
for await (const chunk of res.body as any) { for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk)); parser.feed(decoder.decode(chunk));
} }
} },
}); });
return stream; return stream;

3891
yarn.lock

File diff suppressed because it is too large Load Diff