MAJOR REFACTOR (#494)

* move index to home folder, create state and context files and barrell folder

* Sanity Check Commit:  reducer added to home.tsx manual QA all working

* WIP: promptBar

* fix missing json parse on folders and prompts

* split context and add promptbar context

* add context to nested prompt componets and componetize Folder componet

* remove log

* Create buttons folder and componetize sidebar action button

* tidy up prompt handlers

* componetized sidebar

* added back chatbar componet to left side sidebar

* monster commit: Componetized the common code between chatbar and promptbar into new componet Sidebar and added context to both bars

* add useFetch service

* added prettier import sort to keep imports ordered and easier to indentify

* added react query and useFetch to work with RQ

* added apiService, errorService and reactQuery

* add callback and tidy up error service

* refactor chat and child componets to useContext

* fix extra calls and bad calls to mel endpoint

* minor import cleanup

---------

Co-authored-by: jc.durbin <jc.durbin@ardanis.com>
This commit is contained in:
Mckay Wrigley 2023-04-10 21:10:18 -06:00 committed by GitHub
parent 68c9cd4bd8
commit 6500db9c1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
128 changed files with 3666 additions and 3053 deletions

View File

@ -7,7 +7,7 @@ name: Docker
on:
push:
branches: [ "main" ]
branches: ['main']
env:
# Use docker.io for Docker Hub if empty
@ -15,10 +15,8 @@ env:
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read

View File

@ -1,4 +1,5 @@
# Contributing Guidelines
**Welcome to Chatbot UI!**
We appreciate your interest in contributing to our project.
@ -6,6 +7,7 @@ We appreciate your interest in contributing to our project.
Before you get started, please read our guidelines for contributing.
## Types of Contributions
We welcome the following types of contributions:
- Bug fixes
@ -15,8 +17,8 @@ We welcome the following types of contributions:
- Translations
- Tests
## Getting Started
To get started, fork the project on GitHub and clone it locally on your machine. Then, create a new branch to work on your changes.
```
@ -29,6 +31,7 @@ git checkout -b my-branch-name
Before submitting your pull request, please make sure your changes pass our automated tests and adhere to our code style guidelines.
## Pull Request Process
1. Fork the project on GitHub.
2. Clone your forked repository locally on your machine.
3. Create a new branch from the main branch.
@ -38,4 +41,5 @@ Before submitting your pull request, please make sure your changes pass our auto
7. Submit a pull request to the main branch of the main repository.
## Contact
If you have any questions or need help getting started, feel free to reach out to me on [Twitter](https://twitter.com/mckaywrigley).

View File

@ -1,8 +1,4 @@
import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export';
import { OpenAIModels, OpenAIModelID } from '@/types/openai';
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { it, describe, expect } from 'vitest';
import {
cleanData,
isExportFormatV1,
@ -12,6 +8,11 @@ import {
isLatestExportFormat,
} from '@/utils/app/importExport';
import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export';
import { OpenAIModelID, OpenAIModels } from '@/types/openai';
import { describe, expect, it } from 'vitest';
describe('Export Format Functions', () => {
describe('isExportFormatV1', () => {
it('should return true for v1 format', () => {
@ -105,7 +106,7 @@ describe('cleanData Functions', () => {
},
],
folders: [],
prompts:[]
prompts: [],
});
});
});
@ -253,9 +254,7 @@ describe('cleanData Functions', () => {
folderId: null,
},
],
});
});
});
});

View File

@ -0,0 +1,17 @@
import { MouseEventHandler, ReactElement } from 'react';
interface Props {
handleClick: MouseEventHandler<HTMLButtonElement>;
children: ReactElement;
}
const SidebarActionButton = ({ handleClick, children }: Props) => (
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={handleClick}
>
{children}
</button>
);
export default SidebarActionButton;

View File

@ -0,0 +1 @@
export { default } from './SidebarActionButton';

View File

@ -1,22 +1,31 @@
import { Conversation, Message } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { ErrorMessage } from '@/types/error';
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
import { Plugin } from '@/types/plugin';
import { Prompt } from '@/types/prompt';
import { throttle } from '@/utils';
import { IconArrowDown, IconClearAll, IconSettings } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import {
FC,
MutableRefObject,
memo,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { Spinner } from '../Global/Spinner';
import toast from 'react-hot-toast';
import { useTranslation } from 'next-i18next';
import { getEndpoint } from '@/utils/app/api';
import {
saveConversation,
saveConversations,
updateConversation,
} from '@/utils/app/conversation';
import { throttle } from '@/utils/data/throttle';
import { ChatBody, Conversation, Message } from '@/types/chat';
import { Plugin } from '@/types/plugin';
import HomeContext from '@/pages/api/home/home.context';
import Spinner from '../Spinner';
import { ChatInput } from './ChatInput';
import { ChatLoader } from './ChatLoader';
import { ChatMessage } from './ChatMessage';
@ -25,45 +34,29 @@ import { ModelSelect } from './ModelSelect';
import { SystemPrompt } from './SystemPrompt';
interface Props {
conversation: Conversation;
models: OpenAIModel[];
apiKey: string;
serverSideApiKeyIsSet: boolean;
defaultModelId: OpenAIModelID;
messageIsStreaming: boolean;
modelError: ErrorMessage | null;
loading: boolean;
prompts: Prompt[];
onSend: (
message: Message,
deleteCount: number,
plugin: Plugin | null,
) => void;
onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
onEditMessage: (message: Message, messageIndex: number) => void;
stopConversationRef: MutableRefObject<boolean>;
}
export const Chat: FC<Props> = memo(
({
conversation,
export const Chat = memo(({ stopConversationRef }: Props) => {
const { t } = useTranslation('chat');
const {
state: {
selectedConversation,
conversations,
models,
apiKey,
pluginKeys,
serverSideApiKeyIsSet,
defaultModelId,
messageIsStreaming,
modelError,
loading,
prompts,
onSend,
onUpdateConversation,
onEditMessage,
stopConversationRef,
}) => {
const { t } = useTranslation('chat');
},
handleUpdateConversation,
dispatch: homeDispatch,
} = useContext(HomeContext);
const [currentMessage, setCurrentMessage] = useState<Message>();
const [autoScrollEnabled, setAutoScrollEnabled] = useState<boolean>(true);
const [showSettings, setShowSettings] = useState<boolean>(false);
@ -74,6 +67,191 @@ export const Chat: FC<Props> = memo(
const chatContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSend = useCallback(
async (message: Message, deleteCount = 0, plugin: Plugin | null = null) => {
if (selectedConversation) {
let updatedConversation: Conversation;
if (deleteCount) {
const updatedMessages = [...selectedConversation.messages];
for (let i = 0; i < deleteCount; i++) {
updatedMessages.pop();
}
updatedConversation = {
...selectedConversation,
messages: [...updatedMessages, message],
};
} else {
updatedConversation = {
...selectedConversation,
messages: [...selectedConversation.messages, message],
};
}
homeDispatch({
field: 'selectedConversation',
value: updatedConversation,
});
homeDispatch({ field: 'loading', value: true });
homeDispatch({ field: 'messageIsStreaming', value: true });
const chatBody: ChatBody = {
model: updatedConversation.model,
messages: updatedConversation.messages,
key: apiKey,
prompt: updatedConversation.prompt,
};
const endpoint = getEndpoint(plugin);
let body;
if (!plugin) {
body = JSON.stringify(chatBody);
} else {
body = JSON.stringify({
...chatBody,
googleAPIKey: pluginKeys
.find((key) => key.pluginId === 'google-search')
?.requiredKeys.find((key) => key.key === 'GOOGLE_API_KEY')?.value,
googleCSEId: pluginKeys
.find((key) => key.pluginId === 'google-search')
?.requiredKeys.find((key) => key.key === 'GOOGLE_CSE_ID')?.value,
});
}
const controller = new AbortController();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
body,
});
if (!response.ok) {
homeDispatch({ field: 'loading', value: false });
homeDispatch({ field: 'messageIsStreaming', value: false });
toast.error(response.statusText);
return;
}
const data = response.body;
if (!data) {
homeDispatch({ field: 'loading', value: false });
homeDispatch({ field: 'messageIsStreaming', value: false });
return;
}
if (!plugin) {
if (updatedConversation.messages.length === 1) {
const { content } = message;
const customName =
content.length > 30 ? content.substring(0, 30) + '...' : content;
updatedConversation = {
...updatedConversation,
name: customName,
};
}
homeDispatch({ field: 'loading', value: false });
const reader = data.getReader();
const decoder = new TextDecoder();
let done = false;
let isFirst = true;
let text = '';
while (!done) {
if (stopConversationRef.current === true) {
controller.abort();
done = true;
break;
}
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
text += chunkValue;
if (isFirst) {
isFirst = false;
const updatedMessages: Message[] = [
...updatedConversation.messages,
{ role: 'assistant', content: chunkValue },
];
updatedConversation = {
...updatedConversation,
messages: updatedMessages,
};
homeDispatch({
field: 'selectedConversation',
value: updatedConversation,
});
} else {
const updatedMessages: Message[] =
updatedConversation.messages.map((message, index) => {
if (index === updatedConversation.messages.length - 1) {
return {
...message,
content: text,
};
}
return message;
});
updatedConversation = {
...updatedConversation,
messages: updatedMessages,
};
homeDispatch({
field: 'selectedConversation',
value: updatedConversation,
});
}
}
saveConversation(updatedConversation);
const updatedConversations: Conversation[] = conversations.map(
(conversation) => {
if (conversation.id === selectedConversation.id) {
return updatedConversation;
}
return conversation;
},
);
if (updatedConversations.length === 0) {
updatedConversations.push(updatedConversation);
}
homeDispatch({ field: 'conversations', value: updatedConversations });
saveConversations(updatedConversations);
homeDispatch({ field: 'messageIsStreaming', value: false });
} else {
const { answer } = await response.json();
const updatedMessages: Message[] = [
...updatedConversation.messages,
{ role: 'assistant', content: answer },
];
updatedConversation = {
...updatedConversation,
messages: updatedMessages,
};
homeDispatch({
field: 'selectedConversation',
value: updateConversation,
});
saveConversation(updatedConversation);
const updatedConversations: Conversation[] = conversations.map(
(conversation) => {
if (conversation.id === selectedConversation.id) {
return updatedConversation;
}
return conversation;
},
);
if (updatedConversations.length === 0) {
updatedConversations.push(updatedConversation);
}
homeDispatch({ field: 'conversations', value: updatedConversations });
saveConversations(updatedConversations);
homeDispatch({ field: 'loading', value: false });
homeDispatch({ field: 'messageIsStreaming', value: false });
}
}
},
[
apiKey,
conversations,
pluginKeys,
selectedConversation,
stopConversationRef,
],
);
const scrollToBottom = useCallback(() => {
if (autoScrollEnabled) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -109,8 +287,14 @@ export const Chat: FC<Props> = memo(
};
const onClearAll = () => {
if (confirm(t<string>('Are you sure you want to clear all messages?'))) {
onUpdateConversation(conversation, { key: 'messages', value: [] });
if (
confirm(t<string>('Are you sure you want to clear all messages?')) &&
selectedConversation
) {
handleUpdateConversation(selectedConversation, {
key: 'messages',
value: [],
});
}
};
@ -121,12 +305,21 @@ export const Chat: FC<Props> = memo(
};
const throttledScrollDown = throttle(scrollDown, 250);
// useEffect(() => {
// console.log('currentMessage', currentMessage);
// if (currentMessage) {
// handleSend(currentMessage);
// homeDispatch({ field: 'currentMessage', value: undefined });
// }
// }, [currentMessage]);
useEffect(() => {
throttledScrollDown();
selectedConversation &&
setCurrentMessage(
conversation.messages[conversation.messages.length - 2],
selectedConversation.messages[selectedConversation.messages.length - 2],
);
}, [conversation.messages, throttledScrollDown]);
}, [selectedConversation, throttledScrollDown]);
useEffect(() => {
const observer = new IntersectionObserver(
@ -167,8 +360,8 @@ export const Chat: FC<Props> = memo(
</div>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="mb-2">
Chatbot UI allows you to plug in your API key to use this UI
with their API.
Chatbot UI allows you to plug in your API key to use this UI with
their API.
</div>
<div className="mb-2">
It is <span className="italic">only</span> used to communicate
@ -180,9 +373,7 @@ export const Chat: FC<Props> = memo(
)}
</div>
<div>
{t(
"If you don't have an OpenAI API key, you can get one here: ",
)}
{t("If you don't have an OpenAI API key, you can get one here: ")}
<a
href="https://platform.openai.com/account/api-keys"
target="_blank"
@ -203,7 +394,7 @@ export const Chat: FC<Props> = memo(
ref={chatContainerRef}
onScroll={handleScroll}
>
{conversation.messages.length === 0 ? (
{selectedConversation?.messages.length === 0 ? (
<>
<div className="mx-auto flex w-[350px] flex-col space-y-10 pt-12 sm:w-[600px]">
<div className="text-center text-3xl font-semibold text-gray-800 dark:text-gray-100">
@ -218,23 +409,13 @@ export const Chat: FC<Props> = memo(
{models.length > 0 && (
<div className="flex h-full flex-col space-y-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-600">
<ModelSelect
model={conversation.model}
models={models}
defaultModelId={defaultModelId}
onModelChange={(model) =>
onUpdateConversation(conversation, {
key: 'model',
value: model,
})
}
/>
<ModelSelect />
<SystemPrompt
conversation={conversation}
conversation={selectedConversation}
prompts={prompts}
onChangePrompt={(prompt) =>
onUpdateConversation(conversation, {
handleUpdateConversation(selectedConversation, {
key: 'prompt',
value: prompt,
})
@ -247,7 +428,7 @@ export const Chat: FC<Props> = memo(
) : (
<>
<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}
{t('Model')}: {selectedConversation?.model.name}
<button
className="ml-2 cursor-pointer hover:opacity-50"
onClick={handleSettings}
@ -264,27 +445,16 @@ export const Chat: FC<Props> = memo(
{showSettings && (
<div className="flex flex-col space-y-10 md:mx-auto md:max-w-xl md:gap-6 md:py-3 md:pt-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="flex h-full flex-col space-y-4 border-b border-neutral-200 p-4 dark:border-neutral-600 md:rounded-lg md:border">
<ModelSelect
model={conversation.model}
models={models}
defaultModelId={defaultModelId}
onModelChange={(model) =>
onUpdateConversation(conversation, {
key: 'model',
value: model,
})
}
/>
<ModelSelect />
</div>
</div>
)}
{conversation.messages.map((message, index) => (
{selectedConversation?.messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
messageIndex={index}
onEditMessage={onEditMessage}
/>
))}
@ -301,17 +471,13 @@ export const Chat: FC<Props> = memo(
<ChatInput
stopConversationRef={stopConversationRef}
textareaRef={textareaRef}
messageIsStreaming={messageIsStreaming}
conversationIsEmpty={conversation.messages.length === 0}
model={conversation.model}
prompts={prompts}
onSend={(message, plugin) => {
setCurrentMessage(message);
onSend(message, 0, plugin);
handleSend(message, 0, plugin);
}}
onRegenerate={() => {
if (currentMessage) {
onSend(currentMessage, 2, null);
handleSend(currentMessage, 2, null);
}
}}
/>
@ -329,6 +495,5 @@ export const Chat: FC<Props> = memo(
)}
</div>
);
},
);
});
Chat.displayName = 'Chat';

View File

@ -1,7 +1,3 @@
import { Message } from '@/types/chat';
import { OpenAIModel } from '@/types/openai';
import { Plugin } from '@/types/plugin';
import { Prompt } from '@/types/prompt';
import {
IconBolt,
IconBrandGoogle,
@ -9,43 +5,49 @@ import {
IconRepeat,
IconSend,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import {
FC,
KeyboardEvent,
MutableRefObject,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'next-i18next';
import { Message } from '@/types/chat';
import { Plugin } from '@/types/plugin';
import { Prompt } from '@/types/prompt';
import HomeContext from '@/pages/api/home/home.context';
import { PluginSelect } from './PluginSelect';
import { PromptList } from './PromptList';
import { VariableModal } from './VariableModal';
interface Props {
messageIsStreaming: boolean;
model: OpenAIModel;
conversationIsEmpty: boolean;
prompts: Prompt[];
onSend: (message: Message, plugin: Plugin | null) => void;
onRegenerate: () => void;
stopConversationRef: MutableRefObject<boolean>;
textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
}
export const ChatInput: FC<Props> = ({
messageIsStreaming,
model,
conversationIsEmpty,
prompts,
export const ChatInput = ({
onSend,
onRegenerate,
stopConversationRef,
textareaRef,
}) => {
}: Props) => {
const { t } = useTranslation('chat');
const {
state: { selectedConversation, messageIsStreaming, prompts },
dispatch: homeDispatch,
} = useContext(HomeContext);
const [content, setContent] = useState<string>();
const [isTyping, setIsTyping] = useState<boolean>(false);
const [showPromptList, setShowPromptList] = useState(false);
@ -64,9 +66,9 @@ export const ChatInput: FC<Props> = ({
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const maxLength = model.maxLength;
const maxLength = selectedConversation?.model.maxLength;
if (value.length > maxLength) {
if (maxLength && value.length > maxLength) {
alert(
t(
`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
@ -261,7 +263,9 @@ export const ChatInput: FC<Props> = ({
</button>
)}
{!messageIsStreaming && !conversationIsEmpty && (
{!messageIsStreaming &&
selectedConversation &&
selectedConversation.messages.length > 0 && (
<button
className="absolute top-0 left-0 right-0 mx-auto mb-3 flex w-fit items-center gap-3 rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:mb-0 md:mt-2"
onClick={onRegenerate}

View File

@ -1,28 +1,40 @@
import { Message } from '@/types/chat';
import {
IconCheck,
IconCopy,
IconEdit,
IconUser,
IconRobot,
IconUser,
} from '@tabler/icons-react';
import { FC, memo, useContext, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { FC, memo, useEffect, useRef, useState } from 'react';
import { updateConversation } from '@/utils/app/conversation';
import { Message } from '@/types/chat';
import HomeContext from '@/pages/api/home/home.context';
import { CodeBlock } from '../Markdown/CodeBlock';
import { MemoizedReactMarkdown } from '../Markdown/MemoizedReactMarkdown';
import rehypeMathjax from 'rehype-mathjax';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { CodeBlock } from '../Markdown/CodeBlock';
import { MemoizedReactMarkdown } from '../Markdown/MemoizedReactMarkdown';
interface Props {
message: Message;
messageIndex: number;
onEditMessage: (message: Message, messageIndex: number) => void;
}
export const ChatMessage: FC<Props> = memo(
({ message, messageIndex, onEditMessage }) => {
export const ChatMessage: FC<Props> = memo(({ message, messageIndex }) => {
const { t } = useTranslation('chat');
const {
state: { selectedConversation, conversations },
dispatch: homeDispatch,
} = useContext(HomeContext);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isTyping, setIsTyping] = useState<boolean>(false);
const [messageContent, setMessageContent] = useState(message.content);
@ -34,9 +46,7 @@ export const ChatMessage: FC<Props> = memo(
setIsEditing(!isEditing);
};
const handleInputChange = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessageContent(event.target.value);
if (textareaRef.current) {
textareaRef.current.style.height = 'inherit';
@ -46,7 +56,32 @@ export const ChatMessage: FC<Props> = memo(
const handleEditMessage = () => {
if (message.content != messageContent) {
onEditMessage({ ...message, content: messageContent }, messageIndex);
if (selectedConversation) {
const updatedMessages = selectedConversation.messages
.map((m, i) => {
if (i < messageIndex) {
return m;
}
})
.filter((m) => m) as Message[];
const updatedConversation = {
...selectedConversation,
messages: updatedMessages,
};
const { single, all } = updateConversation(
updatedConversation,
conversations,
);
homeDispatch({ field: 'selectedConversation', value: single });
homeDispatch({ field: 'conversations', value: all });
homeDispatch({
field: 'currentMessage',
value: { ...message, content: messageContent },
});
}
}
setIsEditing(false);
};
@ -232,6 +267,5 @@ export const ChatMessage: FC<Props> = memo(
</div>
</div>
);
},
);
});
ChatMessage.displayName = 'ChatMessage';

View File

@ -1,7 +1,8 @@
import { ErrorMessage } from '@/types/error';
import { IconCircleX } from '@tabler/icons-react';
import { FC } from 'react';
import { ErrorMessage } from '@/types/error';
interface Props {
error: ErrorMessage;
}

View File

@ -1,23 +1,31 @@
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
import { useTranslation } from 'next-i18next';
import { IconExternalLink } from '@tabler/icons-react';
import { FC } from 'react';
import { useContext } from 'react';
interface Props {
model: OpenAIModel;
models: OpenAIModel[];
defaultModelId: OpenAIModelID;
onModelChange: (model: OpenAIModel) => void;
}
import { useTranslation } from 'next-i18next';
export const ModelSelect: FC<Props> = ({
model,
models,
defaultModelId,
onModelChange,
}) => {
import { OpenAIModel } from '@/types/openai';
import HomeContext from '@/pages/api/home/home.context';
export const ModelSelect = () => {
const { t } = useTranslation('chat');
const {
state: { selectedConversation, models, defaultModelId },
handleUpdateConversation,
dispatch: homeDispatch,
} = useContext(HomeContext);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
selectedConversation &&
handleUpdateConversation(selectedConversation, {
key: 'model',
value: models.find(
(model) => model.id === e.target.value,
) as OpenAIModel,
});
};
return (
<div className="flex flex-col">
<label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
@ -27,14 +35,8 @@ export const ModelSelect: FC<Props> = ({
<select
className="w-full bg-transparent p-2"
placeholder={t('Select a model') || ''}
value={model?.id || defaultModelId}
onChange={(e) => {
onModelChange(
models.find(
(model) => model.id === e.target.value,
) as OpenAIModel,
);
}}
value={selectedConversation?.model?.id || defaultModelId}
onChange={handleChange}
>
{models.map((model) => (
<option
@ -50,8 +52,12 @@ export const ModelSelect: FC<Props> = ({
</select>
</div>
<div className="w-full mt-3 text-left text-neutral-700 dark:text-neutral-400 flex items-center">
<a href="https://platform.openai.com/account/usage" target="_blank" className="flex items-center">
<IconExternalLink size={18} className={"inline mr-1"} />
<a
href="https://platform.openai.com/account/usage"
target="_blank"
className="flex items-center"
>
<IconExternalLink size={18} className={'inline mr-1'} />
{t('View Account Usage')}
</a>
</div>

View File

@ -1,7 +1,9 @@
import { Plugin, PluginList } from '@/types/plugin';
import { useTranslation } from 'next-i18next';
import { FC, useEffect, useRef } from 'react';
import { useTranslation } from 'next-i18next';
import { Plugin, PluginList } from '@/types/plugin';
interface Props {
plugin: Plugin | null;
onPluginChange: (plugin: Plugin) => void;

View File

@ -1,6 +1,7 @@
import { Prompt } from '@/types/prompt';
import { FC, MutableRefObject } from 'react';
import { Prompt } from '@/types/prompt';
interface Props {
prompts: Prompt[];
activePromptIndex: number;

View File

@ -1,7 +1,8 @@
import { IconRefresh } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
import { useTranslation } from 'next-i18next';
interface Props {
onRegenerate: () => void;
}

View File

@ -1,8 +1,3 @@
import { Conversation } from '@/types/chat';
import { OpenAIModelID } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { useTranslation } from 'next-i18next';
import {
FC,
KeyboardEvent,
@ -11,6 +6,14 @@ import {
useRef,
useState,
} from 'react';
import { useTranslation } from 'next-i18next';
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { Conversation } from '@/types/chat';
import { Prompt } from '@/types/prompt';
import { PromptList } from './PromptList';
import { VariableModal } from './VariableModal';

View File

@ -1,6 +1,7 @@
import { Prompt } from '@/types/prompt';
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Prompt } from '@/types/prompt';
interface Props {
prompt: Prompt;
variables: string[];

View File

@ -0,0 +1,25 @@
import { Dispatch, createContext } from 'react';
import { ActionType } from '@/hooks/useCreateReducer';
import { Conversation } from '@/types/chat';
import { SupportedExportFormats } from '@/types/export';
import { PluginKey } from '@/types/plugin';
import { ChatbarInitialState } from './Chatbar.state';
export interface ChatbarContextProps {
state: ChatbarInitialState;
dispatch: Dispatch<ActionType<ChatbarInitialState>>;
handleDeleteConversation: (conversation: Conversation) => void;
handleClearConversations: () => void;
handleExportData: () => void;
handleImportConversations: (data: SupportedExportFormats) => void;
handlePluginKeyChange: (pluginKey: PluginKey) => void;
handleClearPluginKey: (pluginKey: PluginKey) => void;
handleApiKeyChange: (apiKey: string) => void;
}
const ChatbarContext = createContext<ChatbarContextProps>(undefined!);
export default ChatbarContext;

View File

@ -0,0 +1,11 @@
import { Conversation } from '@/types/chat';
export interface ChatbarInitialState {
searchTerm: string;
filteredConversations: Conversation[];
}
export const initialState: ChatbarInitialState = {
searchTerm: '',
filteredConversations: [],
};

View File

@ -1,219 +1,237 @@
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { SupportedExportFormats } from '@/types/export';
import { Folder } from '@/types/folder';
import { PluginKey } from '@/types/plugin';
import { IconFolderPlus, IconMessagesOff, IconPlus } from '@tabler/icons-react';
import { useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'next-i18next';
import { FC, useEffect, useState } from 'react';
import { ChatFolders } from '../Folders/Chat/ChatFolders';
import { Search } from '../Sidebar/Search';
import { ChatbarSettings } from './ChatbarSettings';
import { Conversations } from './Conversations';
interface Props {
loading: boolean;
conversations: Conversation[];
lightMode: 'light' | 'dark';
selectedConversation: Conversation;
apiKey: string;
serverSideApiKeyIsSet: boolean;
pluginKeys: PluginKey[];
serverSidePluginKeysSet: boolean;
folders: Folder[];
onCreateFolder: (name: string) => void;
onDeleteFolder: (folderId: string) => void;
onUpdateFolder: (folderId: string, name: string) => void;
onNewConversation: () => void;
onToggleLightMode: (mode: 'light' | 'dark') => void;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
onApiKeyChange: (apiKey: string) => void;
onClearConversations: () => void;
onExportConversations: () => void;
onImportConversations: (data: SupportedExportFormats) => void;
onPluginKeyChange: (pluginKey: PluginKey) => void;
onClearPluginKey: (pluginKey: PluginKey) => void;
}
import { useCreateReducer } from '@/hooks/useCreateReducer';
export const Chatbar: FC<Props> = ({
loading,
conversations,
lightMode,
selectedConversation,
apiKey,
serverSideApiKeyIsSet,
pluginKeys,
serverSidePluginKeysSet,
folders,
onCreateFolder,
onDeleteFolder,
onUpdateFolder,
onNewConversation,
onToggleLightMode,
onSelectConversation,
onDeleteConversation,
onUpdateConversation,
onApiKeyChange,
onClearConversations,
onExportConversations,
onImportConversations,
onPluginKeyChange,
onClearPluginKey,
}) => {
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { saveConversation, saveConversations } from '@/utils/app/conversation';
import { saveFolders } from '@/utils/app/folders';
import { exportData, importData } from '@/utils/app/importExport';
import { Conversation } from '@/types/chat';
import { LatestExportFormat, SupportedExportFormats } from '@/types/export';
import { OpenAIModels } from '@/types/openai';
import { PluginKey } from '@/types/plugin';
import HomeContext from '@/pages/api/home/home.context';
import { ChatFolders } from './components/ChatFolders';
import { ChatbarSettings } from './components/ChatbarSettings';
import { Conversations } from './components/Conversations';
import Sidebar from '../Sidebar';
import ChatbarContext from './Chatbar.context';
import { ChatbarInitialState, initialState } from './Chatbar.state';
import { v4 as uuidv4 } from 'uuid';
export const Chatbar = () => {
const { t } = useTranslation('sidebar');
const [searchTerm, setSearchTerm] = useState<string>('');
const [filteredConversations, setFilteredConversations] =
useState<Conversation[]>(conversations);
const handleUpdateConversation = (
conversation: Conversation,
data: KeyValuePair,
) => {
onUpdateConversation(conversation, data);
setSearchTerm('');
const chatBarContextValue = useCreateReducer<ChatbarInitialState>({
initialState,
});
const {
state: { conversations, showChatbar, defaultModelId, folders, pluginKeys },
dispatch: homeDispatch,
handleCreateFolder,
handleNewConversation,
handleUpdateConversation,
} = useContext(HomeContext);
const {
state: { searchTerm, filteredConversations },
dispatch: chatDispatch,
} = chatBarContextValue;
const handleApiKeyChange = useCallback(
(apiKey: string) => {
homeDispatch({ field: 'apiKey', value: apiKey });
localStorage.setItem('apiKey', apiKey);
},
[homeDispatch],
);
const handlePluginKeyChange = (pluginKey: PluginKey) => {
if (pluginKeys.some((key) => key.pluginId === pluginKey.pluginId)) {
const updatedPluginKeys = pluginKeys.map((key) => {
if (key.pluginId === pluginKey.pluginId) {
return pluginKey;
}
return key;
});
homeDispatch({ field: 'pluginKeys', value: updatedPluginKeys });
localStorage.setItem('pluginKeys', JSON.stringify(updatedPluginKeys));
} else {
homeDispatch({ field: 'pluginKeys', value: [...pluginKeys, pluginKey] });
localStorage.setItem(
'pluginKeys',
JSON.stringify([...pluginKeys, pluginKey]),
);
}
};
const handleClearPluginKey = (pluginKey: PluginKey) => {
const updatedPluginKeys = pluginKeys.filter(
(key) => key.pluginId !== pluginKey.pluginId,
);
if (updatedPluginKeys.length === 0) {
homeDispatch({ field: 'pluginKeys', value: [] });
localStorage.removeItem('pluginKeys');
return;
}
homeDispatch({ field: 'pluginKeys', value: updatedPluginKeys });
localStorage.setItem('pluginKeys', JSON.stringify(updatedPluginKeys));
};
const handleExportData = () => {
exportData();
};
const handleImportConversations = (data: SupportedExportFormats) => {
const { history, folders, prompts }: LatestExportFormat = importData(data);
homeDispatch({ field: 'conversations', value: history });
homeDispatch({
field: 'selectedConversation',
value: history[history.length - 1],
});
homeDispatch({ field: 'folders', value: folders });
homeDispatch({ field: 'prompts', value: prompts });
};
const handleClearConversations = () => {
defaultModelId &&
homeDispatch({
field: 'selectedConversation',
value: {
id: uuidv4(),
name: 'New conversation',
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
},
});
homeDispatch({ field: 'conversations', value: [] });
localStorage.removeItem('conversationHistory');
localStorage.removeItem('selectedConversation');
const updatedFolders = folders.filter((f) => f.type !== 'chat');
homeDispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
};
const handleDeleteConversation = (conversation: Conversation) => {
onDeleteConversation(conversation);
setSearchTerm('');
const updatedConversations = conversations.filter(
(c) => c.id !== conversation.id,
);
homeDispatch({ field: 'conversations', value: updatedConversations });
chatDispatch({ field: 'searchTerm', value: '' });
saveConversations(updatedConversations);
if (updatedConversations.length > 0) {
homeDispatch({
field: 'selectedConversation',
value: updatedConversations[updatedConversations.length - 1],
});
saveConversation(updatedConversations[updatedConversations.length - 1]);
} else {
defaultModelId &&
homeDispatch({
field: 'selectedConversation',
value: {
id: uuidv4(),
name: 'New conversation',
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
},
});
localStorage.removeItem('selectedConversation');
}
};
const handleToggleChatbar = () => {
homeDispatch({ field: 'showChatbar', value: !showChatbar });
localStorage.setItem('showChatbar', JSON.stringify(!showChatbar));
};
const handleDrop = (e: any) => {
if (e.dataTransfer) {
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
onUpdateConversation(conversation, { key: 'folderId', value: 0 });
handleUpdateConversation(conversation, { key: 'folderId', value: 0 });
chatDispatch({ field: 'searchTerm', value: '' });
e.target.style.background = 'none';
}
};
const allowDrop = (e: any) => {
e.preventDefault();
};
const highlightDrop = (e: any) => {
e.target.style.background = '#343541';
};
const removeHighlight = (e: any) => {
e.target.style.background = 'none';
};
useEffect(() => {
if (searchTerm) {
setFilteredConversations(
conversations.filter((conversation) => {
chatDispatch({
field: 'filteredConversations',
value: conversations.filter((conversation) => {
const searchable =
conversation.name.toLocaleLowerCase() +
' ' +
conversation.messages.map((message) => message.content).join(' ');
return searchable.toLowerCase().includes(searchTerm.toLowerCase());
}),
);
});
} else {
setFilteredConversations(conversations);
chatDispatch({
field: 'filteredConversations',
value: conversations,
});
}
}, [searchTerm, conversations]);
return (
<div
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`}
>
<div className="flex items-center">
<button
className="flex w-[190px] flex-shrink-0 cursor-pointer select-none items-center gap-3 rounded-md border border-white/20 p-3 text-[14px] leading-normal text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={() => {
onNewConversation();
setSearchTerm('');
<ChatbarContext.Provider
value={{
...chatBarContextValue,
handleDeleteConversation,
handleClearConversations,
handleImportConversations,
handleExportData,
handlePluginKeyChange,
handleClearPluginKey,
handleApiKeyChange,
}}
>
<IconPlus size={18} />
{t('New chat')}
</button>
<button
className="ml-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-[14px] leading-normal text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={() => onCreateFolder(t('New folder'))}
>
<IconFolderPlus size={18} />
</button>
</div>
{conversations.length > 1 && (
<Search
placeholder="Search conversations..."
<Sidebar<Conversation>
side={'left'}
isOpen={showChatbar}
addItemButtonTitle={t('New chat')}
itemComponent={<Conversations conversations={filteredConversations} />}
folderComponent={<ChatFolders searchTerm={searchTerm} />}
items={filteredConversations}
searchTerm={searchTerm}
onSearch={setSearchTerm}
handleSearchTerm={(searchTerm: string) =>
chatDispatch({ field: 'searchTerm', value: searchTerm })
}
toggleOpen={handleToggleChatbar}
handleCreateItem={handleNewConversation}
handleCreateFolder={() => handleCreateFolder(t('New folder'), 'chat')}
handleDrop={handleDrop}
footerComponent={<ChatbarSettings />}
/>
)}
<div className="flex-grow overflow-auto">
{folders.length > 0 && (
<div className="flex border-b border-white/20 pb-2">
<ChatFolders
searchTerm={searchTerm}
conversations={filteredConversations.filter(
(conversation) => conversation.folderId,
)}
folders={folders}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
selectedConversation={selectedConversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
/>
</div>
)}
{conversations.length > 0 ? (
<div
className="pt-2"
onDrop={(e) => handleDrop(e)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
<Conversations
loading={loading}
conversations={filteredConversations.filter(
(conversation) => !conversation.folderId,
)}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
/>
</div>
) : (
<div className="mt-8 flex flex-col items-center gap-3 text-sm leading-normal text-white opacity-50">
<IconMessagesOff />
{t('No conversations.')}
</div>
)}
</div>
<ChatbarSettings
lightMode={lightMode}
apiKey={apiKey}
serverSideApiKeyIsSet={serverSideApiKeyIsSet}
pluginKeys={pluginKeys}
serverSidePluginKeysSet={serverSidePluginKeysSet}
conversationsCount={conversations.length}
onToggleLightMode={onToggleLightMode}
onApiKeyChange={onApiKeyChange}
onClearConversations={onClearConversations}
onExportConversations={onExportConversations}
onImportConversations={onImportConversations}
onPluginKeyChange={onPluginKeyChange}
onClearPluginKey={onClearPluginKey}
/>
</div>
</ChatbarContext.Provider>
);
};

View File

@ -1,82 +0,0 @@
import { SupportedExportFormats } from '@/types/export';
import { PluginKey } from '@/types/plugin';
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
import { Import } from '../Settings/Import';
import { Key } from '../Settings/Key';
import { SidebarButton } from '../Sidebar/SidebarButton';
import { ClearConversations } from './ClearConversations';
import { PluginKeys } from './PluginKeys';
interface Props {
lightMode: 'light' | 'dark';
apiKey: string;
serverSideApiKeyIsSet: boolean;
pluginKeys: PluginKey[];
serverSidePluginKeysSet: boolean;
conversationsCount: number;
onToggleLightMode: (mode: 'light' | 'dark') => void;
onApiKeyChange: (apiKey: string) => void;
onClearConversations: () => void;
onExportConversations: () => void;
onImportConversations: (data: SupportedExportFormats) => void;
onPluginKeyChange: (pluginKey: PluginKey) => void;
onClearPluginKey: (pluginKey: PluginKey) => void;
}
export const ChatbarSettings: FC<Props> = ({
lightMode,
apiKey,
serverSideApiKeyIsSet,
pluginKeys,
serverSidePluginKeysSet,
conversationsCount,
onToggleLightMode,
onApiKeyChange,
onClearConversations,
onExportConversations,
onImportConversations,
onPluginKeyChange,
onClearPluginKey,
}) => {
const { t } = useTranslation('sidebar');
return (
<div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
{conversationsCount > 0 ? (
<ClearConversations onClearConversations={onClearConversations} />
) : null}
<Import onImport={onImportConversations} />
<SidebarButton
text={t('Export data')}
icon={<IconFileExport size={18} />}
onClick={() => onExportConversations()}
/>
<SidebarButton
text={lightMode === 'light' ? t('Dark mode') : t('Light mode')}
icon={
lightMode === 'light' ? <IconMoon size={18} /> : <IconSun size={18} />
}
onClick={() =>
onToggleLightMode(lightMode === 'light' ? 'dark' : 'light')
}
/>
{!(serverSideApiKeyIsSet) ? (
<Key apiKey={apiKey} onApiKeyChange={onApiKeyChange} />
) : null}
{!(serverSidePluginKeysSet) ? (
<PluginKeys
pluginKeys={pluginKeys}
onPluginKeyChange={onPluginKeyChange}
onClearPluginKey={onClearPluginKey}
/>
) : null}
</div>
);
};

View File

@ -1,163 +0,0 @@
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import {
IconCheck,
IconMessage,
IconPencil,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { DragEvent, FC, KeyboardEvent, useEffect, useState } from 'react';
interface Props {
selectedConversation: Conversation;
conversation: Conversation;
loading: boolean;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
}
export const ConversationComponent: FC<Props> = ({
selectedConversation,
conversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation,
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRename(selectedConversation);
}
};
const handleDragStart = (
e: DragEvent<HTMLButtonElement>,
conversation: Conversation,
) => {
if (e.dataTransfer) {
e.dataTransfer.setData('conversation', JSON.stringify(conversation));
}
};
const handleRename = (conversation: Conversation) => {
if (renameValue.trim().length > 0) {
onUpdateConversation(conversation, { key: 'name', value: renameValue });
setRenameValue('');
setIsRenaming(false);
}
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
return (
<div className="relative flex items-center">
{isRenaming && selectedConversation.id === conversation.id ? (
<div className="flex w-full items-center gap-3 bg-[#343541]/90 p-3 rounded-lg">
<IconMessage size={18} />
<input
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
</div>
) : (
<button
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)}
disabled={loading}
draggable="true"
onDragStart={(e) => handleDragStart(e, conversation)}
>
<IconMessage size={18} />
<div
className={`relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3 ${
selectedConversation.id === conversation.id ? 'pr-12' : 'pr-1'
}`}
>
{conversation.name}
</div>
</button>
)}
{(isDeleting || isRenaming) &&
selectedConversation.id === conversation.id && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteConversation(conversation);
} else if (isRenaming) {
handleRename(conversation);
}
setIsDeleting(false);
setIsRenaming(false);
}}
>
<IconCheck size={18} />
</button>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
>
<IconX size={18} />
</button>
</div>
)}
{selectedConversation.id === conversation.id &&
!isDeleting &&
!isRenaming && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(selectedConversation.name);
}}
>
<IconPencil size={18} />
</button>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
>
<IconTrash size={18} />
</button>
</div>
)}
</div>
);
};

View File

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

View File

@ -0,0 +1,63 @@
import { useContext } from 'react';
import { FolderInterface } from '@/types/folder';
import HomeContext from '@/pages/api/home/home.context';
import Folder from '@/components/Folder';
import { ConversationComponent } from './Conversation';
interface Props {
searchTerm: string;
}
export const ChatFolders = ({ searchTerm }: Props) => {
const {
state: { folders, conversations },
handleUpdateConversation,
} = useContext(HomeContext);
const handleDrop = (e: any, folder: FolderInterface) => {
if (e.dataTransfer) {
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
handleUpdateConversation(conversation, {
key: 'folderId',
value: folder.id,
});
}
};
const ChatFolders = (currentFolder: FolderInterface) => {
return (
conversations &&
conversations
.filter((conversation) => conversation.folderId)
.map((conversation, index) => {
if (conversation.folderId === currentFolder.id) {
return (
<div key={index} className="ml-5 gap-2 border-l pl-2">
<ConversationComponent conversation={conversation} />
</div>
);
}
})
);
};
return (
<div className="flex w-full flex-col pt-2">
{folders
.filter((folder) => folder.type === 'chat')
.map((folder, index) => (
<Folder
key={index}
searchTerm={searchTerm}
currentFolder={folder}
handleDrop={handleDrop}
folderComponent={ChatFolders(folder)}
/>
))}
</div>
);
};

View File

@ -0,0 +1,71 @@
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
import { useContext } from 'react';
import { useTranslation } from 'next-i18next';
import HomeContext from '@/pages/api/home/home.context';
import { Import } from '../../Settings/Import';
import { Key } from '../../Settings/Key';
import { SidebarButton } from '../../Sidebar/SidebarButton';
import ChatbarContext from '../Chatbar.context';
import { ClearConversations } from './ClearConversations';
import { PluginKeys } from './PluginKeys';
export const ChatbarSettings = () => {
const { t } = useTranslation('sidebar');
const {
state: {
apiKey,
lightMode,
serverSideApiKeyIsSet,
serverSidePluginKeysSet,
conversations,
},
dispatch: homeDispatch,
} = useContext(HomeContext);
const {
handleClearConversations,
handleImportConversations,
handleExportData,
handleApiKeyChange,
} = useContext(ChatbarContext);
return (
<div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
{conversations.length > 0 ? (
<ClearConversations onClearConversations={handleClearConversations} />
) : null}
<Import onImport={handleImportConversations} />
<SidebarButton
text={t('Export data')}
icon={<IconFileExport size={18} />}
onClick={() => handleExportData()}
/>
<SidebarButton
text={lightMode === 'light' ? t('Dark mode') : t('Light mode')}
icon={
lightMode === 'light' ? <IconMoon size={18} /> : <IconSun size={18} />
}
onClick={() =>
homeDispatch({
field: 'lightMode',
value: lightMode === 'light' ? 'dark' : 'light',
})
}
/>
{!serverSideApiKeyIsSet ? (
<Key apiKey={apiKey} onApiKeyChange={handleApiKeyChange} />
) : null}
{!serverSidePluginKeysSet ? <PluginKeys /> : null}
</div>
);
};

View File

@ -1,7 +1,9 @@
import { IconCheck, IconTrash, IconX } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, useState } from 'react';
import { SidebarButton } from '../Sidebar/SidebarButton';
import { useTranslation } from 'next-i18next';
import { SidebarButton } from '@/components/Sidebar/SidebarButton';
interface Props {
onClearConversations: () => void;
@ -27,7 +29,7 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
<div className="flex w-[40px]">
<IconCheck
className="ml-auto min-w-[20px] mr-1 text-neutral-400 hover:text-neutral-100"
className="ml-auto mr-1 min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();

View File

@ -0,0 +1,168 @@
import {
IconCheck,
IconMessage,
IconPencil,
IconTrash,
IconX,
} from '@tabler/icons-react';
import {
DragEvent,
KeyboardEvent,
MouseEventHandler,
useContext,
useEffect,
useState,
} from 'react';
import { Conversation } from '@/types/chat';
import HomeContext from '@/pages/api/home/home.context';
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
import ChatbarContext from '@/components/Chatbar/Chatbar.context';
interface Props {
conversation: Conversation;
}
export const ConversationComponent = ({ conversation }: Props) => {
const {
state: { selectedConversation, messageIsStreaming },
handleSelectConversation,
handleUpdateConversation,
} = useContext(HomeContext);
const { handleDeleteConversation } = useContext(ChatbarContext);
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
selectedConversation && handleRename(selectedConversation);
}
};
const handleDragStart = (
e: DragEvent<HTMLButtonElement>,
conversation: Conversation,
) => {
if (e.dataTransfer) {
e.dataTransfer.setData('conversation', JSON.stringify(conversation));
}
};
const handleRename = (conversation: Conversation) => {
if (renameValue.trim().length > 0) {
handleUpdateConversation(conversation, {
key: 'name',
value: renameValue,
});
setRenameValue('');
setIsRenaming(false);
}
};
const handleConfirm: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (isDeleting) {
handleDeleteConversation(conversation);
} else if (isRenaming) {
handleRename(conversation);
}
setIsDeleting(false);
setIsRenaming(false);
};
const handleCancel: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
};
const handleOpenRenameModal: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setIsRenaming(true);
selectedConversation && setRenameValue(selectedConversation.name);
};
const handleOpenDeleteModal: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setIsDeleting(true);
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
return (
<div className="relative flex items-center">
{isRenaming && selectedConversation?.id === conversation.id ? (
<div className="flex w-full items-center gap-3 rounded-lg bg-[#343541]/90 p-3">
<IconMessage size={18} />
<input
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
</div>
) : (
<button
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90 ${
messageIsStreaming ? 'disabled:cursor-not-allowed' : ''
} ${
selectedConversation?.id === conversation.id
? 'bg-[#343541]/90'
: ''
}`}
onClick={() => handleSelectConversation(conversation)}
disabled={messageIsStreaming}
draggable="true"
onDragStart={(e) => handleDragStart(e, conversation)}
>
<IconMessage size={18} />
<div
className={`relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3 ${
selectedConversation?.id === conversation.id ? 'pr-12' : 'pr-1'
}`}
>
{conversation.name}
</div>
</button>
)}
{(isDeleting || isRenaming) &&
selectedConversation?.id === conversation.id && (
<div className="absolute right-1 z-10 flex text-gray-300">
<SidebarActionButton handleClick={handleConfirm}>
<IconCheck size={18} />
</SidebarActionButton>
<SidebarActionButton handleClick={handleCancel}>
<IconX size={18} />
</SidebarActionButton>
</div>
)}
{selectedConversation?.id === conversation.id &&
!isDeleting &&
!isRenaming && (
<div className="absolute right-1 z-10 flex text-gray-300">
<SidebarActionButton handleClick={handleOpenRenameModal}>
<IconPencil size={18} />
</SidebarActionButton>
<SidebarActionButton handleClick={handleOpenDeleteModal}>
<IconTrash size={18} />
</SidebarActionButton>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,21 @@
import { Conversation } from '@/types/chat';
import { ConversationComponent } from './Conversation';
interface Props {
conversations: Conversation[];
}
export const Conversations = ({ conversations }: Props) => {
return (
<div className="flex w-full flex-col gap-1">
{conversations
.filter((conversation) => !conversation.folderId)
.slice()
.reverse()
.map((conversation, index) => (
<ConversationComponent key={index} conversation={conversation} />
))}
</div>
);
};

View File

@ -1,22 +1,25 @@
import { PluginID, PluginKey } from '@/types/plugin';
import { IconKey } from '@tabler/icons-react';
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { KeyboardEvent, useContext, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SidebarButton } from '../Sidebar/SidebarButton';
interface Props {
pluginKeys: PluginKey[];
onPluginKeyChange: (pluginKey: PluginKey) => void;
onClearPluginKey: (pluginKey: PluginKey) => void;
}
import { PluginID, PluginKey } from '@/types/plugin';
export const PluginKeys: FC<Props> = ({
pluginKeys,
onPluginKeyChange,
onClearPluginKey,
}) => {
import HomeContext from '@/pages/api/home/home.context';
import { SidebarButton } from '@/components/Sidebar/SidebarButton';
import ChatbarContext from '../Chatbar.context';
export const PluginKeys = () => {
const { t } = useTranslation('sidebar');
const {
state: { pluginKeys },
} = useContext(HomeContext);
const { handlePluginKeyChange, handleClearPluginKey } =
useContext(ChatbarContext);
const [isChanging, setIsChanging] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
@ -118,7 +121,7 @@ export const PluginKeys: FC<Props> = ({
}),
};
onPluginKeyChange(updatedPluginKey);
handlePluginKeyChange(updatedPluginKey);
}
} else {
const newPluginKey: PluginKey = {
@ -135,7 +138,7 @@ export const PluginKeys: FC<Props> = ({
],
};
onPluginKeyChange(newPluginKey);
handlePluginKeyChange(newPluginKey);
}
}}
/>
@ -177,7 +180,7 @@ export const PluginKeys: FC<Props> = ({
}),
};
onPluginKeyChange(updatedPluginKey);
handlePluginKeyChange(updatedPluginKey);
}
} else {
const newPluginKey: PluginKey = {
@ -194,7 +197,7 @@ export const PluginKeys: FC<Props> = ({
],
};
onPluginKeyChange(newPluginKey);
handlePluginKeyChange(newPluginKey);
}
}}
/>
@ -207,7 +210,7 @@ export const PluginKeys: FC<Props> = ({
);
if (pluginKey) {
onClearPluginKey(pluginKey);
handleClearPluginKey(pluginKey);
}
}}
>

View File

@ -1,6 +1,3 @@
import { PromptComponent } from '@/components/Promptbar/Prompt';
import { Folder } from '@/types/folder';
import { Prompt } from '@/types/prompt';
import {
IconCaretDown,
IconCaretRight,
@ -9,29 +6,35 @@ import {
IconTrash,
IconX,
} from '@tabler/icons-react';
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import {
KeyboardEvent,
ReactElement,
useContext,
useEffect,
useState,
} from 'react';
import { FolderInterface } from '@/types/folder';
import HomeContext from '@/pages/api/home/home.context';
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
interface Props {
currentFolder: FolderInterface;
searchTerm: string;
prompts: Prompt[];
currentFolder: Folder;
onDeleteFolder: (folder: string) => void;
onUpdateFolder: (folder: string, name: string) => void;
// prompt props
onDeletePrompt: (prompt: Prompt) => void;
onUpdatePrompt: (prompt: Prompt) => void;
handleDrop: (e: any, folder: FolderInterface) => void;
folderComponent: (ReactElement | undefined)[];
}
export const PromptFolder: FC<Props> = ({
searchTerm,
prompts,
const Folder = ({
currentFolder,
onDeleteFolder,
onUpdateFolder,
// prompt props
onDeletePrompt,
onUpdatePrompt,
}) => {
searchTerm,
handleDrop,
folderComponent,
}: Props) => {
const { handleDeleteFolder, handleUpdateFolder } = useContext(HomeContext);
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
@ -45,23 +48,16 @@ export const PromptFolder: FC<Props> = ({
};
const handleRename = () => {
onUpdateFolder(currentFolder.id, renameValue);
handleUpdateFolder(currentFolder.id, renameValue);
setRenameValue('');
setIsRenaming(false);
};
const handleDrop = (e: any, folder: Folder) => {
const dropHandler = (e: any) => {
if (e.dataTransfer) {
setIsOpen(true);
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
const updatedPrompt = {
...prompt,
folderId: folder.id,
};
onUpdatePrompt(updatedPrompt);
handleDrop(e, currentFolder);
e.target.style.background = 'none';
}
@ -118,7 +114,7 @@ export const PromptFolder: FC<Props> = ({
<button
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90`}
onClick={() => setIsOpen(!isOpen)}
onDrop={(e) => handleDrop(e, currentFolder)}
onDrop={(e) => dropHandler(e)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
@ -137,13 +133,12 @@ export const PromptFolder: FC<Props> = ({
{(isDeleting || isRenaming) && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
<SidebarActionButton
handleClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteFolder(currentFolder.id);
handleDeleteFolder(currentFolder.id);
} else if (isRenaming) {
handleRename();
}
@ -153,60 +148,45 @@ export const PromptFolder: FC<Props> = ({
}}
>
<IconCheck size={18} />
</button>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
</SidebarActionButton>
<SidebarActionButton
handleClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
>
<IconX size={18} />
</button>
</SidebarActionButton>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
<SidebarActionButton
handleClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(currentFolder.name);
}}
>
<IconPencil size={18} />
</button>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
</SidebarActionButton>
<SidebarActionButton
handleClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
>
<IconTrash size={18} />
</button>
</SidebarActionButton>
</div>
)}
</div>
{isOpen
? prompts.map((prompt, index) => {
if (prompt.folderId === currentFolder.id) {
return (
<div key={index} className="ml-5 gap-2 border-l pl-2">
<PromptComponent
prompt={prompt}
onDeletePrompt={onDeletePrompt}
onUpdatePrompt={onUpdatePrompt}
/>
</div>
);
}
})
: null}
{isOpen ? folderComponent : null}
</>
);
};
export default Folder;

View File

@ -0,0 +1 @@
export { default } from './Folder';

View File

@ -1,220 +0,0 @@
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { Folder } from '@/types/folder';
import {
IconCaretDown,
IconCaretRight,
IconCheck,
IconPencil,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { ConversationComponent } from '../../Chatbar/Conversation';
interface Props {
searchTerm: string;
conversations: Conversation[];
currentFolder: Folder;
onDeleteFolder: (folder: string) => void;
onUpdateFolder: (folder: string, name: string) => void;
// conversation props
selectedConversation: Conversation;
loading: boolean;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
}
export const ChatFolder: FC<Props> = ({
searchTerm,
conversations,
currentFolder,
onDeleteFolder,
onUpdateFolder,
// conversation props
selectedConversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation,
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRename();
}
};
const handleRename = () => {
onUpdateFolder(currentFolder.id, renameValue);
setRenameValue('');
setIsRenaming(false);
};
const handleDrop = (e: any, folder: Folder) => {
if (e.dataTransfer) {
setIsOpen(true);
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
onUpdateConversation(conversation, { key: 'folderId', value: folder.id });
e.target.style.background = 'none';
}
};
const allowDrop = (e: any) => {
e.preventDefault();
};
const highlightDrop = (e: any) => {
e.target.style.background = '#343541';
};
const removeHighlight = (e: any) => {
e.target.style.background = 'none';
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
useEffect(() => {
if (searchTerm) {
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [searchTerm]);
return (
<>
<div className="relative flex items-center">
{isRenaming ? (
<div className="flex w-full items-center gap-3 bg-[#343541]/90 p-3 rounded-lg">
{isOpen ? (
<IconCaretDown size={18} />
) : (
<IconCaretRight size={18} />
)}
<input
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
</div>
) : (
<button
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90`}
onClick={() => setIsOpen(!isOpen)}
onDrop={(e) => handleDrop(e, currentFolder)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
{isOpen ? (
<IconCaretDown size={18} />
) : (
<IconCaretRight size={18} />
)}
<div className="relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3">
{currentFolder.name}
</div>
</button>
)}
{(isDeleting || isRenaming) && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteFolder(currentFolder.id);
} else if (isRenaming) {
handleRename();
}
setIsDeleting(false);
setIsRenaming(false);
}}
>
<IconCheck size={18} />
</button>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
>
<IconX size={18} />
</button>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(currentFolder.name);
}}
>
<IconPencil size={18} />
</button>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
>
<IconTrash size={18} />
</button>
</div>
)}
</div>
{isOpen
? conversations.map((conversation, index) => {
if (conversation.folderId === currentFolder.id) {
return (
<div key={index} className="ml-5 gap-2 border-l pl-2">
<ConversationComponent
selectedConversation={selectedConversation}
conversation={conversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
</div>
);
}
})
: null}
</>
);
};

View File

@ -1,57 +0,0 @@
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { Folder } from '@/types/folder';
import { FC } from 'react';
import { ChatFolder } from './ChatFolder';
interface Props {
searchTerm: string;
conversations: Conversation[];
folders: Folder[];
onDeleteFolder: (folder: string) => void;
onUpdateFolder: (folder: string, name: string) => void;
// conversation props
selectedConversation: Conversation;
loading: boolean;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
}
export const ChatFolders: FC<Props> = ({
searchTerm,
conversations,
folders,
onDeleteFolder,
onUpdateFolder,
// conversation props
selectedConversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation,
}) => {
return (
<div className="flex w-full flex-col pt-2">
{folders.map((folder, index) => (
<ChatFolder
key={index}
searchTerm={searchTerm}
conversations={conversations.filter((c) => c.folderId)}
currentFolder={folder}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
// conversation props
selectedConversation={selectedConversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
))}
</div>
);
};

View File

@ -1,44 +0,0 @@
import { Folder } from '@/types/folder';
import { Prompt } from '@/types/prompt';
import { FC } from 'react';
import { PromptFolder } from './PromptFolder';
interface Props {
searchTerm: string;
prompts: Prompt[];
folders: Folder[];
onDeleteFolder: (folder: string) => void;
onUpdateFolder: (folder: string, name: string) => void;
// prompt props
onDeletePrompt: (prompt: Prompt) => void;
onUpdatePrompt: (prompt: Prompt) => void;
}
export const PromptFolders: FC<Props> = ({
searchTerm,
prompts,
folders,
onDeleteFolder,
onUpdateFolder,
// prompt props
onDeletePrompt,
onUpdatePrompt,
}) => {
return (
<div className="flex w-full flex-col pt-2">
{folders.map((folder, index) => (
<PromptFolder
key={index}
searchTerm={searchTerm}
prompts={prompts.filter((p) => p.folderId)}
currentFolder={folder}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
// prompt props
onDeletePrompt={onDeletePrompt}
onUpdatePrompt={onUpdatePrompt}
/>
))}
</div>
);
};

View File

@ -1,12 +1,14 @@
import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
import { FC, memo, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useTranslation } from 'next-i18next';
import {
generateRandomString,
programmingLanguages,
} from '@/utils/app/codeblock';
import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, memo, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
interface Props {
language: string;
@ -67,11 +69,7 @@ export const CodeBlock: FC<Props> = memo(({ language, value }) => {
className="flex gap-1.5 items-center rounded bg-none p-1 text-xs text-white"
onClick={copyToClipboard}
>
{isCopied ? (
<IconCheck size={18} />
) : (
<IconClipboard size={18} />
)}
{isCopied ? <IconCheck size={18} /> : <IconClipboard size={18} />}
{isCopied ? t('Copied!') : t('Copy code')}
</button>
<button

View File

@ -1,7 +1,8 @@
import { Conversation } from '@/types/chat';
import { IconPlus } from '@tabler/icons-react';
import { FC } from 'react';
import { Conversation } from '@/types/chat';
interface Props {
selectedConversation: Conversation;
onNewConversation: () => void;

View File

@ -0,0 +1,19 @@
import { Dispatch, createContext } from 'react';
import { ActionType } from '@/hooks/useCreateReducer';
import { Prompt } from '@/types/prompt';
import { PromptbarInitialState } from './Promptbar.state';
export interface PromptbarContextProps {
state: PromptbarInitialState;
dispatch: Dispatch<ActionType<PromptbarInitialState>>;
handleCreatePrompt: () => void;
handleDeletePrompt: (prompt: Prompt) => void;
handleUpdatePrompt: (prompt: Prompt) => void;
}
const PromptbarContext = createContext<PromptbarContextProps>(undefined!);
export default PromptbarContext;

View File

@ -0,0 +1,11 @@
import { Prompt } from '@/types/prompt';
export interface PromptbarInitialState {
searchTerm: string;
filteredPrompts: Prompt[];
}
export const initialState: PromptbarInitialState = {
searchTerm: '',
filteredPrompts: [],
};

View File

@ -1,50 +1,85 @@
import { Folder } from '@/types/folder';
import { Prompt } from '@/types/prompt';
import {
IconFolderPlus,
IconMistOff,
IconPlus,
} from '@tabler/icons-react';
import { FC, useEffect, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PromptFolders } from '../Folders/Prompt/PromptFolders';
import { Search } from '../Sidebar/Search';
import { PromptbarSettings } from './PromptbarSettings';
import { Prompts } from './Prompts';
interface Props {
prompts: Prompt[];
folders: Folder[];
onCreateFolder: (name: string) => void;
onDeleteFolder: (folderId: string) => void;
onUpdateFolder: (folderId: string, name: string) => void;
onCreatePrompt: () => void;
onUpdatePrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
}
import { useCreateReducer } from '@/hooks/useCreateReducer';
export const Promptbar: FC<Props> = ({
folders,
prompts,
onCreateFolder,
onDeleteFolder,
onUpdateFolder,
onCreatePrompt,
onUpdatePrompt,
onDeletePrompt,
}) => {
import { savePrompts } from '@/utils/app/prompts';
import { OpenAIModels } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import HomeContext from '@/pages/api/home/home.context';
import { PromptFolders } from './components/PromptFolders';
import { PromptbarSettings } from './components/PromptbarSettings';
import { Prompts } from './components/Prompts';
import Sidebar from '../Sidebar';
import PromptbarContext from './PromptBar.context';
import { PromptbarInitialState, initialState } from './Promptbar.state';
import { v4 as uuidv4 } from 'uuid';
const Promptbar = () => {
const { t } = useTranslation('promptbar');
const [searchTerm, setSearchTerm] = useState<string>('');
const [filteredPrompts, setFilteredPrompts] = useState<Prompt[]>(prompts);
const handleUpdatePrompt = (prompt: Prompt) => {
onUpdatePrompt(prompt);
setSearchTerm('');
const promptBarContextValue = useCreateReducer<PromptbarInitialState>({
initialState,
});
const {
state: { prompts, defaultModelId, showPromptbar },
dispatch: homeDispatch,
handleCreateFolder,
} = useContext(HomeContext);
const {
state: { searchTerm, filteredPrompts },
dispatch: promptDispatch,
} = promptBarContextValue;
const handleTogglePromptbar = () => {
homeDispatch({ field: 'showPromptbar', value: !showPromptbar });
localStorage.setItem('showPromptbar', JSON.stringify(!showPromptbar));
};
const handleCreatePrompt = () => {
if (defaultModelId) {
const newPrompt: Prompt = {
id: uuidv4(),
name: `Prompt ${prompts.length + 1}`,
description: '',
content: '',
model: OpenAIModels[defaultModelId],
folderId: null,
};
const updatedPrompts = [...prompts, newPrompt];
homeDispatch({ field: 'prompts', value: updatedPrompts });
savePrompts(updatedPrompts);
}
};
const handleDeletePrompt = (prompt: Prompt) => {
onDeletePrompt(prompt);
setSearchTerm('');
const updatedPrompts = prompts.filter((p) => p.id !== prompt.id);
homeDispatch({ field: 'prompts', value: updatedPrompts });
savePrompts(updatedPrompts);
};
const handleUpdatePrompt = (prompt: Prompt) => {
const updatedPrompts = prompts.map((p) => {
if (p.id === prompt.id) {
return prompt;
}
return p;
});
homeDispatch({ field: 'prompts', value: updatedPrompts });
savePrompts(updatedPrompts);
};
const handleDrop = (e: any) => {
@ -56,28 +91,17 @@ export const Promptbar: FC<Props> = ({
folderId: e.target.dataset.folderId,
};
onUpdatePrompt(updatedPrompt);
handleUpdatePrompt(updatedPrompt);
e.target.style.background = 'none';
}
};
const allowDrop = (e: any) => {
e.preventDefault();
};
const highlightDrop = (e: any) => {
e.target.style.background = '#343541';
};
const removeHighlight = (e: any) => {
e.target.style.background = 'none';
};
useEffect(() => {
if (searchTerm) {
setFilteredPrompts(
prompts.filter((prompt) => {
promptDispatch({
field: 'filteredPrompts',
value: prompts.filter((prompt) => {
const searchable =
prompt.name.toLowerCase() +
' ' +
@ -86,85 +110,43 @@ export const Promptbar: FC<Props> = ({
prompt.content.toLowerCase();
return searchable.includes(searchTerm.toLowerCase());
}),
);
});
} else {
setFilteredPrompts(prompts);
promptDispatch({ field: 'filteredPrompts', value: prompts });
}
}, [searchTerm, prompts]);
return (
<div
className={`fixed top-0 right-0 z-50 flex h-full w-[260px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}
>
<div className="flex items-center">
<button
className="text-sidebar flex w-[190px] flex-shrink-0 cursor-pointer select-none items-center gap-3 rounded-md border border-white/20 p-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={() => {
onCreatePrompt();
setSearchTerm('');
<PromptbarContext.Provider
value={{
...promptBarContextValue,
handleCreatePrompt,
handleDeletePrompt,
handleUpdatePrompt,
}}
>
<IconPlus size={16} />
{t('New prompt')}
</button>
<button
className="flex items-center flex-shrink-0 gap-3 p-3 ml-2 text-sm text-white transition-colors duration-200 border rounded-md cursor-pointer border-white/20 hover:bg-gray-500/10"
onClick={() => onCreateFolder(t('New folder'))}
>
<IconFolderPlus size={16} />
</button>
</div>
{prompts.length > 1 && (
<Search
placeholder={t('Search prompts...') || ''}
searchTerm={searchTerm}
onSearch={setSearchTerm}
/>
)}
<div className="flex-grow overflow-auto">
{folders.length > 0 && (
<div className="flex pb-2 border-b border-white/20">
<PromptFolders
searchTerm={searchTerm}
prompts={filteredPrompts}
folders={folders}
onUpdateFolder={onUpdateFolder}
onDeleteFolder={onDeleteFolder}
// prompt props
onDeletePrompt={handleDeletePrompt}
onUpdatePrompt={handleUpdatePrompt}
/>
</div>
)}
{prompts.length > 0 ? (
<div
className="pt-2"
onDrop={(e) => handleDrop(e)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
<Sidebar<Prompt>
side={'right'}
isOpen={showPromptbar}
addItemButtonTitle={t('New prompt')}
itemComponent={
<Prompts
prompts={filteredPrompts.filter((prompt) => !prompt.folderId)}
onUpdatePrompt={handleUpdatePrompt}
onDeletePrompt={handleDeletePrompt}
/>
</div>
) : (
<div className="mt-8 text-center text-white opacity-50 select-none">
<IconMistOff className="mx-auto mb-3" />
<span className="text-[14px] leading-normal">
{t('No prompts.')}
</span>
</div>
)}
</div>
<PromptbarSettings />
</div>
}
folderComponent={<PromptFolders />}
items={filteredPrompts}
searchTerm={searchTerm}
handleSearchTerm={(searchTerm: string) =>
promptDispatch({ field: 'searchTerm', value: searchTerm })
}
toggleOpen={handleTogglePromptbar}
handleCreateItem={handleCreatePrompt}
handleCreateFolder={() => handleCreateFolder(t('New folder'), 'prompt')}
handleDrop={handleDrop}
/>
</PromptbarContext.Provider>
);
};
export default Promptbar;

View File

@ -1,31 +0,0 @@
import { Prompt } from '@/types/prompt';
import { FC } from 'react';
import { PromptComponent } from './Prompt';
interface Props {
prompts: Prompt[];
onUpdatePrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
}
export const Prompts: FC<Props> = ({
prompts,
onUpdatePrompt,
onDeletePrompt,
}) => {
return (
<div className="flex w-full flex-col gap-1">
{prompts
.slice()
.reverse()
.map((prompt, index) => (
<PromptComponent
key={index}
prompt={prompt}
onUpdatePrompt={onUpdatePrompt}
onDeletePrompt={onDeletePrompt}
/>
))}
</div>
);
};

View File

@ -1,29 +1,66 @@
import { Prompt } from '@/types/prompt';
import {
IconBulbFilled,
IconCheck,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { DragEvent, FC, useEffect, useState } from 'react';
import {
DragEvent,
MouseEventHandler,
useContext,
useEffect,
useState,
} from 'react';
import { Prompt } from '@/types/prompt';
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
import PromptbarContext from '../PromptBar.context';
import { PromptModal } from './PromptModal';
interface Props {
prompt: Prompt;
onUpdatePrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
}
export const PromptComponent: FC<Props> = ({
prompt,
onUpdatePrompt,
onDeletePrompt,
}) => {
export const PromptComponent = ({ prompt }: Props) => {
const {
dispatch: promptDispatch,
handleUpdatePrompt,
handleDeletePrompt,
} = useContext(PromptbarContext);
const [showModal, setShowModal] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const handleUpdate = (prompt: Prompt) => {
handleUpdatePrompt(prompt);
promptDispatch({ field: 'searchTerm', value: '' });
};
const handleDelete: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (isDeleting) {
handleDeletePrompt(prompt);
promptDispatch({ field: 'searchTerm', value: '' });
}
setIsDeleting(false);
};
const handleCancelDelete: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setIsDeleting(false);
};
const handleOpenDeleteModal: MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setIsDeleting(true);
};
const handleDragStart = (e: DragEvent<HTMLButtonElement>, prompt: Prompt) => {
if (e.dataTransfer) {
e.dataTransfer.setData('prompt', JSON.stringify(prompt));
@ -63,44 +100,21 @@ export const PromptComponent: FC<Props> = ({
{(isDeleting || isRenaming) && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeletePrompt(prompt);
}
setIsDeleting(false);
}}
>
<SidebarActionButton handleClick={handleDelete}>
<IconCheck size={18} />
</button>
</SidebarActionButton>
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
}}
>
<SidebarActionButton handleClick={handleCancelDelete}>
<IconX size={18} />
</button>
</SidebarActionButton>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="absolute right-1 z-10 flex text-gray-300">
<button
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
>
<SidebarActionButton handleClick={handleOpenDeleteModal}>
<IconTrash size={18} />
</button>
</SidebarActionButton>
</div>
)}
@ -108,7 +122,7 @@ export const PromptComponent: FC<Props> = ({
<PromptModal
prompt={prompt}
onClose={() => setShowModal(false)}
onUpdatePrompt={onUpdatePrompt}
onUpdatePrompt={handleUpdate}
/>
)}
</div>

View File

@ -0,0 +1,63 @@
import { useContext } from 'react';
import { FolderInterface } from '@/types/folder';
import HomeContext from '@/pages/api/home/home.context';
import Folder from '@/components/Folder';
import { PromptComponent } from '@/components/Promptbar/components/Prompt';
import PromptbarContext from '../PromptBar.context';
export const PromptFolders = () => {
const {
state: { folders },
} = useContext(HomeContext);
const {
state: { searchTerm, filteredPrompts },
handleUpdatePrompt,
} = useContext(PromptbarContext);
const handleDrop = (e: any, folder: FolderInterface) => {
if (e.dataTransfer) {
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
const updatedPrompt = {
...prompt,
folderId: folder.id,
};
handleUpdatePrompt(updatedPrompt);
}
};
const PromptFolders = (currentFolder: FolderInterface) =>
filteredPrompts
.filter((p) => p.folderId)
.map((prompt, index) => {
if (prompt.folderId === currentFolder.id) {
return (
<div key={index} className="ml-5 gap-2 border-l pl-2">
<PromptComponent prompt={prompt} />
</div>
);
}
});
return (
<div className="flex w-full flex-col pt-2">
{folders
.filter((folder) => folder.type === 'prompt')
.map((folder, index) => (
<Folder
key={index}
searchTerm={searchTerm}
currentFolder={folder}
handleDrop={handleDrop}
folderComponent={PromptFolders(folder)}
/>
))}
</div>
);
};

View File

@ -1,7 +1,9 @@
import { Prompt } from '@/types/prompt';
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { Prompt } from '@/types/prompt';
interface Props {
prompt: Prompt;
onClose: () => void;

View File

@ -1,4 +1,4 @@
import { FC } from "react";
import { FC } from 'react';
interface Props {}

View File

@ -0,0 +1,22 @@
import { FC } from 'react';
import { Prompt } from '@/types/prompt';
import { PromptComponent } from './Prompt';
interface Props {
prompts: Prompt[];
}
export const Prompts: FC<Props> = ({ prompts }) => {
return (
<div className="flex w-full flex-col gap-1">
{prompts
.slice()
.reverse()
.map((prompt, index) => (
<PromptComponent key={index} prompt={prompt} />
))}
</div>
);
};

View File

@ -0,0 +1 @@
export { default } from './Promptbar';

View File

@ -1,14 +1,14 @@
import { IconX } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
import { useTranslation } from 'next-i18next';
interface Props {
placeholder: string;
searchTerm: string;
onSearch: (searchTerm: string) => void;
}
export const Search: FC<Props> = ({ placeholder, searchTerm, onSearch }) => {
const Search: FC<Props> = ({ placeholder, searchTerm, onSearch }) => {
const { t } = useTranslation('sidebar');
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -39,3 +39,5 @@ export const Search: FC<Props> = ({ placeholder, searchTerm, onSearch }) => {
</div>
);
};
export default Search;

View File

@ -0,0 +1 @@
export { default } from './Search';

View File

@ -1,7 +1,10 @@
import { SupportedExportFormats } from '@/types/export';
import { IconFileImport } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
import { useTranslation } from 'next-i18next';
import { SupportedExportFormats } from '@/types/export';
import { SidebarButton } from '../Sidebar/SidebarButton';
interface Props {

View File

@ -1,6 +1,8 @@
import { IconCheck, IconKey, IconX } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { SidebarButton } from '../Sidebar/SidebarButton';
interface Props {

View File

@ -0,0 +1,125 @@
import { IconFolderPlus, IconMistOff, IconPlus } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
CloseSidebarButton,
OpenSidebarButton,
} from './components/OpenCloseButton';
import Search from '../Search';
interface Props<T> {
isOpen: boolean;
addItemButtonTitle: string;
side: 'left' | 'right';
items: T[];
itemComponent: ReactNode;
folderComponent: ReactNode;
footerComponent?: ReactNode;
searchTerm: string;
handleSearchTerm: (searchTerm: string) => void;
toggleOpen: () => void;
handleCreateItem: () => void;
handleCreateFolder: () => void;
handleDrop: (e: any) => void;
}
const Sidebar = <T,>({
isOpen,
addItemButtonTitle,
side,
items,
itemComponent,
folderComponent,
footerComponent,
searchTerm,
handleSearchTerm,
toggleOpen,
handleCreateItem,
handleCreateFolder,
handleDrop,
}: Props<T>) => {
const { t } = useTranslation('promptbar');
const allowDrop = (e: any) => {
e.preventDefault();
};
const highlightDrop = (e: any) => {
e.target.style.background = '#343541';
};
const removeHighlight = (e: any) => {
e.target.style.background = 'none';
};
return isOpen ? (
<div>
<div
className={`fixed top-0 z-50 flex h-full w-[260px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}
>
<div className="flex items-center">
<button
className="text-sidebar flex w-[190px] flex-shrink-0 cursor-pointer select-none items-center gap-3 rounded-md border border-white/20 p-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={() => {
handleCreateItem();
handleSearchTerm('');
}}
>
<IconPlus size={16} />
{addItemButtonTitle}
</button>
<button
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={handleCreateFolder}
>
<IconFolderPlus size={16} />
</button>
</div>
{items?.length > 0 && (
<Search
placeholder={t('Search prompts...') || ''}
searchTerm={searchTerm}
onSearch={handleSearchTerm}
/>
)}
<div className="flex-grow overflow-auto">
{items?.length > 0 && (
<div className="flex border-b border-white/20 pb-2">
{folderComponent}
</div>
)}
{items?.length > 0 ? (
<div
className="pt-2"
onDrop={handleDrop}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
{itemComponent}
</div>
) : (
<div className="mt-8 select-none text-center text-white opacity-50">
<IconMistOff className="mx-auto mb-3" />
<span className="text-[14px] leading-normal">
{t('No prompts.')}
</span>
</div>
)}
</div>
{footerComponent}
</div>
<CloseSidebarButton onClick={toggleOpen} side={side} />
</div>
) : (
<OpenSidebarButton onClick={toggleOpen} side={side} />
);
};
export default Sidebar;

View File

@ -0,0 +1,42 @@
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
interface Props {
onClick: any;
side: 'left' | 'right';
}
export const CloseSidebarButton = ({ onClick, side }: Props) => {
return (
<>
<button
className={`fixed top-5 ${
side === 'right' ? 'right-[270px]' : 'left-[270px]'
} z-50 h-7 w-7 hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:${
side === 'right' ? 'right-[270px]' : 'left-[270px]'
} sm:h-8 sm:w-8 sm:text-neutral-700`}
onClick={onClick}
>
{side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
</button>
<div
onClick={onClick}
className="absolute top-0 left-0 z-10 h-full w-full bg-black opacity-70 sm:hidden"
></div>
</>
);
};
export const OpenSidebarButton = ({ onClick, side }: Props) => {
return (
<button
className={`fixed top-2.5 ${
side === 'right' ? 'right-2' : 'left-2'
} z-50 h-7 w-7 text-white hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:${
side === 'right' ? 'right-2' : 'left-2'
} sm:h-8 sm:w-8 sm:text-neutral-700`}
onClick={onClick}
>
{side === 'right' ? <IconArrowBarLeft /> : <IconArrowBarRight />}
</button>
);
};

View File

@ -0,0 +1 @@
export { default } from './Sidebar';

View File

@ -5,7 +5,7 @@ interface Props {
className?: string;
}
export const Spinner: FC<Props> = ({ size = '1em', className="" }) => {
const Spinner = ({ size = '1em', className = '' }: Props) => {
return (
<svg
stroke="currentColor"
@ -30,3 +30,5 @@ export const Spinner: FC<Props> = ({ size = '1em', className="" }) => {
</svg>
);
};
export default Spinner;

View File

@ -0,0 +1 @@
export { default } from './Spinner';

30
hooks/useCreateReducer.ts Normal file
View File

@ -0,0 +1,30 @@
import { useMemo, useReducer } from 'react';
// Extracts property names from initial state of reducer to allow typesafe dispatch objects
export type FieldNames<T> = {
[K in keyof T]: T[K] extends string ? K : K;
}[keyof T];
// Returns the Action Type for the dispatch object to be used for typing in things like context
export type ActionType<T> =
| { type: 'reset' }
| { type?: 'change'; field: FieldNames<T>; value: any };
// Returns a typed dispatch and state
export const useCreateReducer = <T>({ initialState }: { initialState: T }) => {
type Action =
| { type: 'reset' }
| { type?: 'change'; field: FieldNames<T>; value: any };
const reducer = (state: T, action: Action) => {
if (!action.type) return { ...state, [action.field]: action.value };
if (action.type === 'reset') return initialState;
throw new Error();
};
const [state, dispatch] = useReducer(reducer, initialState);
return useMemo(() => ({ state, dispatch }), [state, dispatch]);
};

88
hooks/useFetch.ts Normal file
View File

@ -0,0 +1,88 @@
export type RequestModel = {
params?: object;
headers?: object;
signal?: AbortSignal;
};
export type RequestWithBodyModel = RequestModel & {
body?: object | FormData;
};
export const useFetch = () => {
const handleFetch = async (
url: string,
request: any,
signal?: AbortSignal,
) => {
const requestUrl = request?.params ? `${url}${request.params}` : url;
const requestBody = request?.body
? request.body instanceof FormData
? { ...request, body: request.body }
: { ...request, body: JSON.stringify(request.body) }
: request;
const headers = {
...(request?.headers
? request.headers
: request?.body && request.body instanceof FormData
? {}
: { 'Content-type': 'application/json' }),
};
return fetch(requestUrl, { ...requestBody, headers, signal })
.then((response) => {
if (!response.ok) throw response;
const contentType = response.headers.get('content-type');
const contentDisposition = response.headers.get('content-disposition');
const headers = response.headers;
const result =
contentType &&
(contentType?.indexOf('application/json') !== -1 ||
contentType?.indexOf('text/plain') !== -1)
? response.json()
: contentDisposition?.indexOf('attachment') !== -1
? response.blob()
: response;
return result;
})
.catch(async (err) => {
const contentType = err.headers.get('content-type');
const errResult =
contentType && contentType?.indexOf('application/problem+json') !== -1
? await err.json()
: err;
throw errResult;
});
};
return {
get: async <T>(url: string, request?: RequestModel): Promise<T> => {
return handleFetch(url, { ...request, method: 'get' });
},
post: async <T>(
url: string,
request?: RequestWithBodyModel,
): Promise<T> => {
return handleFetch(url, { ...request, method: 'post' });
},
put: async <T>(url: string, request?: RequestWithBodyModel): Promise<T> => {
return handleFetch(url, { ...request, method: 'put' });
},
patch: async <T>(
url: string,
request?: RequestWithBodyModel,
): Promise<T> => {
return handleFetch(url, { ...request, method: 'patch' });
},
delete: async <T>(url: string, request?: RequestModel): Promise<T> => {
return handleFetch(url, { ...request, method: 'delete' });
},
};
};

View File

@ -1,24 +1,24 @@
module.exports = {
i18n: {
defaultLocale: "en",
defaultLocale: 'en',
locales: [
"bn",
"de",
"en",
"es",
"fr",
"he",
"id",
"it",
"ja",
"ko",
"pt",
"ru",
"sv",
"te",
"vi",
"zh",
"ar",
'bn',
'de',
'en',
'es',
'fr',
'he',
'id',
'it',
'ja',
'ko',
'pt',
'ru',
'sv',
'te',
'vi',
'zh',
'ar',
],
},
localePath:

1173
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@
"react-hot-toast": "^2.4.0",
"react-i18next": "^12.2.0",
"react-markdown": "^8.0.5",
"react-query": "^3.39.3",
"react-syntax-highlighter": "^15.5.0",
"rehype-mathjax": "^4.0.2",
"remark-gfm": "^3.0.1",
@ -33,6 +34,7 @@
"devDependencies": {
"@mozilla/readability": "^0.4.4",
"@tailwindcss/typography": "^0.5.9",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/jsdom": "^21.1.1",
"@types/node": "18.15.0",
"@types/react": "18.0.28",

View File

@ -1,16 +1,23 @@
import '@/styles/globals.css';
import { Toaster } from 'react-hot-toast';
import { QueryClient, QueryClientProvider } from 'react-query';
import { appWithTranslation } from 'next-i18next';
import type { AppProps } from 'next/app';
import { Inter } from 'next/font/google';
import { Toaster } from 'react-hot-toast';
import '@/styles/globals.css';
const inter = Inter({ subsets: ['latin'] });
function App({ Component, pageProps }: AppProps<{}>) {
const queryClient = new QueryClient();
return (
<div className={inter.className}>
<Toaster />
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { Html, Head, Main, NextScript, DocumentProps } from 'next/document';
import { DocumentProps, Head, Html, Main, NextScript } from 'next/document';
import i18nextConfig from '../next-i18next.config';
type Props = DocumentProps & {

View File

@ -1,11 +1,14 @@
import { ChatBody, Message } from '@/types/chat';
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { OpenAIError, OpenAIStream } from '@/utils/server';
import tiktokenModel from '@dqbd/tiktoken/encoders/cl100k_base.json';
import { Tiktoken, init } from '@dqbd/tiktoken/lite/init';
import { ChatBody, Message } from '@/types/chat';
// @ts-expect-error
import wasm from '../../node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm?module';
import tiktokenModel from '@dqbd/tiktoken/encoders/cl100k_base.json';
import { Tiktoken, init } from '@dqbd/tiktoken/lite/init';
export const config = {
runtime: 'edge',
};

View File

@ -1,11 +1,14 @@
import { Message } from '@/types/chat';
import { GoogleBody, GoogleSource } from '@/types/google';
import { NextApiRequest, NextApiResponse } from 'next';
import { OPENAI_API_HOST } from '@/utils/app/const';
import { cleanSourceText } from '@/utils/server/google';
import { Message } from '@/types/chat';
import { GoogleBody, GoogleSource } from '@/types/google';
import { Readability } from '@mozilla/readability';
import endent from 'endent';
import jsdom, { JSDOM } from 'jsdom';
import { NextApiRequest, NextApiResponse } from 'next';
const handler = async (req: NextApiRequest, res: NextApiResponse<any>) => {
try {

View File

@ -0,0 +1,27 @@
import { Dispatch, createContext } from 'react';
import { ActionType } from '@/hooks/useCreateReducer';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { FolderType } from '@/types/folder';
import { HomeInitialState } from './home.state';
export interface HomeContextProps {
state: HomeInitialState;
dispatch: Dispatch<ActionType<HomeInitialState>>;
handleNewConversation: () => void;
handleCreateFolder: (name: string, type: FolderType) => void;
handleDeleteFolder: (folderId: string) => void;
handleUpdateFolder: (folderId: string, name: string) => void;
handleSelectConversation: (conversation: Conversation) => void;
handleUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
) => void;
}
const HomeContext = createContext<HomeContextProps>(undefined!);
export default HomeContext;

View File

@ -0,0 +1,52 @@
import { Conversation, Message } from '@/types/chat';
import { ErrorMessage } from '@/types/error';
import { FolderInterface } from '@/types/folder';
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
import { PluginKey } from '@/types/plugin';
import { Prompt } from '@/types/prompt';
export interface HomeInitialState {
apiKey: string;
pluginKeys: PluginKey[];
loading: boolean;
lightMode: 'light' | 'dark';
messageIsStreaming: boolean;
modelError: ErrorMessage | null;
models: OpenAIModel[];
folders: FolderInterface[];
conversations: Conversation[];
selectedConversation: Conversation | undefined;
currentMessage: Message | undefined;
prompts: Prompt[];
showChatbar: boolean;
showPromptbar: boolean;
currentFolder: FolderInterface | undefined;
messageError: boolean;
searchTerm: string;
defaultModelId: OpenAIModelID | undefined;
serverSideApiKeyIsSet: boolean;
serverSidePluginKeysSet: boolean;
}
export const initialState: HomeInitialState = {
apiKey: '',
loading: false,
pluginKeys: [],
lightMode: 'dark',
messageIsStreaming: false,
modelError: null,
models: [],
folders: [],
conversations: [],
selectedConversation: undefined,
currentMessage: undefined,
prompts: [],
showPromptbar: true,
showChatbar: true,
currentFolder: undefined,
messageError: false,
searchTerm: '',
defaultModelId: undefined,
serverSideApiKeyIsSet: false,
serverSidePluginKeysSet: false,
};

424
pages/api/home/home.tsx Normal file
View File

@ -0,0 +1,424 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Head from 'next/head';
import { useCreateReducer } from '@/hooks/useCreateReducer';
import useErrorService from '@/services/errorService';
import useApiService from '@/services/useApiService';
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 { savePrompts } from '@/utils/app/prompts';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { FolderInterface, FolderType } from '@/types/folder';
import { OpenAIModelID, OpenAIModels, fallbackModelID } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { Chat } from '@/components/Chat/Chat';
import { Chatbar } from '@/components/Chatbar/Chatbar';
import { Navbar } from '@/components/Mobile/Navbar';
import Promptbar from '@/components/Promptbar';
import HomeContext from './home.context';
import { HomeInitialState, initialState } from './home.state';
import { v4 as uuidv4 } from 'uuid';
interface Props {
serverSideApiKeyIsSet: boolean;
serverSidePluginKeysSet: boolean;
defaultModelId: OpenAIModelID;
}
const Home = ({
serverSideApiKeyIsSet,
serverSidePluginKeysSet,
defaultModelId,
}: Props) => {
const { t } = useTranslation('chat');
const { getModels } = useApiService();
const { getModelsError } = useErrorService();
const [initialRender, setInitialRender] = useState<boolean>(true);
const contextValue = useCreateReducer<HomeInitialState>({
initialState,
});
const {
state: {
apiKey,
lightMode,
folders,
conversations,
selectedConversation,
prompts,
},
dispatch,
} = contextValue;
const stopConversationRef = useRef<boolean>(false);
const { data, error, refetch } = useQuery(
['GetModels', apiKey, serverSideApiKeyIsSet],
({ signal }) => {
if (!apiKey && !serverSideApiKeyIsSet) return null;
return getModels(
{
key: apiKey,
},
signal,
);
},
{ enabled: true, refetchOnMount: false },
);
useEffect(() => {
if (data) dispatch({ field: 'models', value: data });
}, [data, dispatch]);
useEffect(() => {
dispatch({ field: 'modelError', value: getModelsError(error) });
}, [dispatch, error, getModelsError]);
// FETCH MODELS ----------------------------------------------
const handleSelectConversation = (conversation: Conversation) => {
dispatch({
field: 'selectedConversation',
value: conversation,
});
saveConversation(conversation);
};
// FOLDER OPERATIONS --------------------------------------------
const handleCreateFolder = (name: string, type: FolderType) => {
const newFolder: FolderInterface = {
id: uuidv4(),
name,
type,
};
const updatedFolders = [...folders, newFolder];
dispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
};
const handleDeleteFolder = (folderId: string) => {
const updatedFolders = folders.filter((f) => f.id !== folderId);
dispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
const updatedConversations: Conversation[] = conversations.map((c) => {
if (c.folderId === folderId) {
return {
...c,
folderId: null,
};
}
return c;
});
dispatch({ field: 'conversations', value: updatedConversations });
saveConversations(updatedConversations);
const updatedPrompts: Prompt[] = prompts.map((p) => {
if (p.folderId === folderId) {
return {
...p,
folderId: null,
};
}
return p;
});
dispatch({ field: 'prompts', value: updatedPrompts });
savePrompts(updatedPrompts);
};
const handleUpdateFolder = (folderId: string, name: string) => {
const updatedFolders = folders.map((f) => {
if (f.id === folderId) {
return {
...f,
name,
};
}
return f;
});
dispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
};
// CONVERSATION OPERATIONS --------------------------------------------
const handleNewConversation = () => {
const lastConversation = conversations[conversations.length - 1];
const newConversation: Conversation = {
id: uuidv4(),
name: `${t('New Conversation')}`,
messages: [],
model: lastConversation?.model || {
id: OpenAIModels[defaultModelId].id,
name: OpenAIModels[defaultModelId].name,
maxLength: OpenAIModels[defaultModelId].maxLength,
tokenLimit: OpenAIModels[defaultModelId].tokenLimit,
},
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
};
const updatedConversations = [...conversations, newConversation];
dispatch({ field: 'selectedConversation', value: newConversation });
dispatch({ field: 'conversations', value: updatedConversations });
saveConversation(newConversation);
saveConversations(updatedConversations);
dispatch({ field: 'loading', value: false });
};
const handleUpdateConversation = (
conversation: Conversation,
data: KeyValuePair,
) => {
const updatedConversation = {
...conversation,
[data.key]: data.value,
};
const { single, all } = updateConversation(
updatedConversation,
conversations,
);
dispatch({ field: 'selectedConversation', value: single });
dispatch({ field: 'conversations', value: all });
};
// EFFECTS --------------------------------------------
useEffect(() => {
if (window.innerWidth < 640) {
dispatch({ field: 'showChatbar', value: false });
}
}, [selectedConversation]);
useEffect(() => {
defaultModelId &&
dispatch({ field: 'defaultModelId', value: defaultModelId });
serverSideApiKeyIsSet &&
dispatch({
field: 'serverSideApiKeyIsSet',
value: serverSideApiKeyIsSet,
});
serverSidePluginKeysSet &&
dispatch({
field: 'serverSidePluginKeysSet',
value: serverSidePluginKeysSet,
});
}, [defaultModelId, serverSideApiKeyIsSet, serverSidePluginKeysSet]);
// ON LOAD --------------------------------------------
useEffect(() => {
console.log('initialize', serverSideApiKeyIsSet);
const theme = localStorage.getItem('theme');
if (theme) {
dispatch({ field: 'lightMode', value: theme as 'dark' | 'light' });
}
const apiKey = localStorage.getItem('apiKey');
if (serverSideApiKeyIsSet) {
console.log('trigger key', apiKey);
dispatch({ field: 'apiKey', value: '' });
localStorage.removeItem('apiKey');
} else if (apiKey) {
dispatch({ field: 'apiKey', value: apiKey });
}
const pluginKeys = localStorage.getItem('pluginKeys');
if (serverSidePluginKeysSet) {
dispatch({ field: 'pluginKeys', value: [] });
localStorage.removeItem('pluginKeys');
} else if (pluginKeys) {
dispatch({ field: 'pluginKeys', value: pluginKeys });
}
if (window.innerWidth < 640) {
dispatch({ field: 'showChatbar', value: false });
}
const showChatbar = localStorage.getItem('showChatbar');
if (showChatbar) {
dispatch({ field: 'showChatbar', value: showChatbar === 'true' });
}
const showPromptbar = localStorage.getItem('showPromptbar');
if (showPromptbar) {
dispatch({ field: 'showPromptbar', value: showPromptbar === 'true' });
}
const folders = localStorage.getItem('folders');
if (folders) {
dispatch({ field: 'folders', value: JSON.parse(folders) });
}
const prompts = localStorage.getItem('prompts');
if (prompts) {
dispatch({ field: 'prompts', value: JSON.parse(prompts) });
}
const conversationHistory = localStorage.getItem('conversationHistory');
if (conversationHistory) {
const parsedConversationHistory: Conversation[] =
JSON.parse(conversationHistory);
const cleanedConversationHistory = cleanConversationHistory(
parsedConversationHistory,
);
dispatch({ field: 'conversations', value: cleanedConversationHistory });
}
const selectedConversation = localStorage.getItem('selectedConversation');
if (selectedConversation) {
const parsedSelectedConversation: Conversation =
JSON.parse(selectedConversation);
const cleanedSelectedConversation = cleanSelectedConversation(
parsedSelectedConversation,
);
dispatch({
field: 'selectedConversation',
value: cleanedSelectedConversation,
});
} else {
dispatch({
field: 'selectedConversation',
value: {
id: uuidv4(),
name: 'New conversation',
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
},
});
}
}, [
defaultModelId,
dispatch,
serverSideApiKeyIsSet,
serverSidePluginKeysSet,
]);
return (
<HomeContext.Provider
value={{
...contextValue,
handleNewConversation,
handleCreateFolder,
handleDeleteFolder,
handleUpdateFolder,
handleSelectConversation,
handleUpdateConversation,
}}
>
<Head>
<title>Chatbot UI</title>
<meta name="description" content="ChatGPT but better." />
<meta
name="viewport"
content="height=device-height ,width=device-width, initial-scale=1, user-scalable=no"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
{selectedConversation && (
<main
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
selectedConversation={selectedConversation}
onNewConversation={handleNewConversation}
/>
</div>
<div className="flex h-full w-full pt-[48px] sm:pt-0">
<Chatbar />
<div className="flex flex-1">
<Chat stopConversationRef={stopConversationRef} />
</div>
<Promptbar />
</div>
</main>
)}
</HomeContext.Provider>
);
};
export default Home;
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
const defaultModelId =
(process.env.DEFAULT_MODEL &&
Object.values(OpenAIModelID).includes(
process.env.DEFAULT_MODEL as OpenAIModelID,
) &&
process.env.DEFAULT_MODEL) ||
fallbackModelID;
let serverSidePluginKeysSet = false;
const googleApiKey = process.env.GOOGLE_API_KEY;
const googleCSEId = process.env.GOOGLE_CSE_ID;
if (googleApiKey && googleCSEId) {
serverSidePluginKeysSet = true;
}
return {
props: {
serverSideApiKeyIsSet: !!process.env.OPENAI_API_KEY,
defaultModelId,
serverSidePluginKeysSet,
...(await serverSideTranslations(locale ?? 'en', [
'common',
'chat',
'sidebar',
'markdown',
'promptbar',
])),
},
};
};

1
pages/api/home/index.ts Normal file
View File

@ -0,0 +1 @@
export { default, getServerSideProps } from './home';

View File

@ -1,6 +1,7 @@
import { OpenAIModel, OpenAIModelID, OpenAIModels } from '@/types/openai';
import { OPENAI_API_HOST } from '@/utils/app/const';
import { OpenAIModel, OpenAIModelID, OpenAIModels } from '@/types/openai';
export const config = {
runtime: 'edge',
};
@ -17,7 +18,7 @@ const handler = async (req: Request): Promise<Response> => {
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`,
...(process.env.OPENAI_ORGANIZATION && {
'OpenAI-Organization': process.env.OPENAI_ORGANIZATION,
})
}),
},
});

View File

@ -1,895 +1 @@
import { Chat } from '@/components/Chat/Chat';
import { Chatbar } from '@/components/Chatbar/Chatbar';
import { Navbar } from '@/components/Mobile/Navbar';
import { Promptbar } from '@/components/Promptbar/Promptbar';
import { ChatBody, Conversation, Message } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { ErrorMessage } from '@/types/error';
import { LatestExportFormat, SupportedExportFormats } from '@/types/export';
import { Folder, FolderType } from '@/types/folder';
import {
OpenAIModel,
OpenAIModelID,
OpenAIModels,
fallbackModelID,
} from '@/types/openai';
import { Plugin, PluginKey } from '@/types/plugin';
import { Prompt } from '@/types/prompt';
import { getEndpoint } from '@/utils/app/api';
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 { savePrompts } from '@/utils/app/prompts';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Head from 'next/head';
import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { v4 as uuidv4 } from 'uuid';
interface HomeProps {
serverSideApiKeyIsSet: boolean;
serverSidePluginKeysSet: boolean;
defaultModelId: OpenAIModelID;
}
const Home: React.FC<HomeProps> = ({
serverSideApiKeyIsSet,
serverSidePluginKeysSet,
defaultModelId,
}) => {
const { t } = useTranslation('chat');
// STATE ----------------------------------------------
const [apiKey, setApiKey] = useState<string>('');
const [pluginKeys, setPluginKeys] = useState<PluginKey[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [lightMode, setLightMode] = useState<'dark' | 'light'>('dark');
const [messageIsStreaming, setMessageIsStreaming] = useState<boolean>(false);
const [modelError, setModelError] = useState<ErrorMessage | null>(null);
const [models, setModels] = useState<OpenAIModel[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] =
useState<Conversation>();
const [currentMessage, setCurrentMessage] = useState<Message>();
const [showSidebar, setShowSidebar] = useState<boolean>(true);
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [showPromptbar, setShowPromptbar] = useState<boolean>(true);
// REFS ----------------------------------------------
const stopConversationRef = useRef<boolean>(false);
// FETCH RESPONSE ----------------------------------------------
const handleSend = async (
message: Message,
deleteCount = 0,
plugin: Plugin | null = null,
) => {
if (selectedConversation) {
let updatedConversation: Conversation;
if (deleteCount) {
const updatedMessages = [...selectedConversation.messages];
for (let i = 0; i < deleteCount; i++) {
updatedMessages.pop();
}
updatedConversation = {
...selectedConversation,
messages: [...updatedMessages, message],
};
} else {
updatedConversation = {
...selectedConversation,
messages: [...selectedConversation.messages, message],
};
}
setSelectedConversation(updatedConversation);
setLoading(true);
setMessageIsStreaming(true);
const chatBody: ChatBody = {
model: updatedConversation.model,
messages: updatedConversation.messages,
key: apiKey,
prompt: updatedConversation.prompt,
};
const endpoint = getEndpoint(plugin);
let body;
if (!plugin) {
body = JSON.stringify(chatBody);
} else {
body = JSON.stringify({
...chatBody,
googleAPIKey: pluginKeys
.find((key) => key.pluginId === 'google-search')
?.requiredKeys.find((key) => key.key === 'GOOGLE_API_KEY')?.value,
googleCSEId: pluginKeys
.find((key) => key.pluginId === 'google-search')
?.requiredKeys.find((key) => key.key === 'GOOGLE_CSE_ID')?.value,
});
}
const controller = new AbortController();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
body,
});
if (!response.ok) {
setLoading(false);
setMessageIsStreaming(false);
toast.error(response.statusText);
return;
}
const data = response.body;
if (!data) {
setLoading(false);
setMessageIsStreaming(false);
return;
}
if (!plugin) {
if (updatedConversation.messages.length === 1) {
const { content } = message;
const customName =
content.length > 30 ? content.substring(0, 30) + '...' : content;
updatedConversation = {
...updatedConversation,
name: customName,
};
}
setLoading(false);
const reader = data.getReader();
const decoder = new TextDecoder();
let done = false;
let isFirst = true;
let text = '';
while (!done) {
if (stopConversationRef.current === true) {
controller.abort();
done = true;
break;
}
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
text += chunkValue;
if (isFirst) {
isFirst = false;
const updatedMessages: Message[] = [
...updatedConversation.messages,
{ role: 'assistant', content: chunkValue },
];
updatedConversation = {
...updatedConversation,
messages: updatedMessages,
};
setSelectedConversation(updatedConversation);
} else {
const updatedMessages: Message[] = updatedConversation.messages.map(
(message, index) => {
if (index === updatedConversation.messages.length - 1) {
return {
...message,
content: text,
};
}
return message;
},
);
updatedConversation = {
...updatedConversation,
messages: updatedMessages,
};
setSelectedConversation(updatedConversation);
}
}
saveConversation(updatedConversation);
const updatedConversations: Conversation[] = conversations.map(
(conversation) => {
if (conversation.id === selectedConversation.id) {
return updatedConversation;
}
return conversation;
},
);
if (updatedConversations.length === 0) {
updatedConversations.push(updatedConversation);
}
setConversations(updatedConversations);
saveConversations(updatedConversations);
setMessageIsStreaming(false);
} else {
const { answer } = await response.json();
const updatedMessages: Message[] = [
...updatedConversation.messages,
{ role: 'assistant', content: answer },
];
updatedConversation = {
...updatedConversation,
messages: updatedMessages,
};
setSelectedConversation(updatedConversation);
saveConversation(updatedConversation);
const updatedConversations: Conversation[] = conversations.map(
(conversation) => {
if (conversation.id === selectedConversation.id) {
return updatedConversation;
}
return conversation;
},
);
if (updatedConversations.length === 0) {
updatedConversations.push(updatedConversation);
}
setConversations(updatedConversations);
saveConversations(updatedConversations);
setLoading(false);
setMessageIsStreaming(false);
}
}
};
// FETCH MODELS ----------------------------------------------
const fetchModels = async (key: string) => {
const error = {
title: t('Error fetching models.'),
code: null,
messageLines: [
t(
'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;
const response = await fetch('/api/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key,
}),
});
if (!response.ok) {
try {
const data = await response.json();
Object.assign(error, {
code: data.error?.code,
messageLines: [data.error?.message],
});
} catch (e) {}
setModelError(error);
return;
}
const data = await response.json();
if (!data) {
setModelError(error);
return;
}
setModels(data);
setModelError(null);
};
// BASIC HANDLERS --------------------------------------------
const handleLightMode = (mode: 'dark' | 'light') => {
setLightMode(mode);
localStorage.setItem('theme', mode);
};
const handleApiKeyChange = (apiKey: string) => {
setApiKey(apiKey);
localStorage.setItem('apiKey', apiKey);
};
const handlePluginKeyChange = (pluginKey: PluginKey) => {
if (pluginKeys.some((key) => key.pluginId === pluginKey.pluginId)) {
const updatedPluginKeys = pluginKeys.map((key) => {
if (key.pluginId === pluginKey.pluginId) {
return pluginKey;
}
return key;
});
setPluginKeys(updatedPluginKeys);
localStorage.setItem('pluginKeys', JSON.stringify(updatedPluginKeys));
} else {
setPluginKeys([...pluginKeys, pluginKey]);
localStorage.setItem(
'pluginKeys',
JSON.stringify([...pluginKeys, pluginKey]),
);
}
};
const handleClearPluginKey = (pluginKey: PluginKey) => {
const updatedPluginKeys = pluginKeys.filter(
(key) => key.pluginId !== pluginKey.pluginId,
);
if (updatedPluginKeys.length === 0) {
setPluginKeys([]);
localStorage.removeItem('pluginKeys');
return;
}
setPluginKeys(updatedPluginKeys);
localStorage.setItem('pluginKeys', JSON.stringify(updatedPluginKeys));
};
const handleToggleChatbar = () => {
setShowSidebar(!showSidebar);
localStorage.setItem('showChatbar', JSON.stringify(!showSidebar));
};
const handleTogglePromptbar = () => {
setShowPromptbar(!showPromptbar);
localStorage.setItem('showPromptbar', JSON.stringify(!showPromptbar));
};
const handleExportData = () => {
exportData();
};
const handleImportConversations = (data: SupportedExportFormats) => {
const { history, folders, prompts }: LatestExportFormat = importData(data);
setConversations(history);
setSelectedConversation(history[history.length - 1]);
setFolders(folders);
setPrompts(prompts);
};
const handleSelectConversation = (conversation: Conversation) => {
setSelectedConversation(conversation);
saveConversation(conversation);
};
// FOLDER OPERATIONS --------------------------------------------
const handleCreateFolder = (name: string, type: FolderType) => {
const newFolder: Folder = {
id: uuidv4(),
name,
type,
};
const updatedFolders = [...folders, newFolder];
setFolders(updatedFolders);
saveFolders(updatedFolders);
};
const handleDeleteFolder = (folderId: string) => {
const updatedFolders = folders.filter((f) => f.id !== folderId);
setFolders(updatedFolders);
saveFolders(updatedFolders);
const updatedConversations: Conversation[] = conversations.map((c) => {
if (c.folderId === folderId) {
return {
...c,
folderId: null,
};
}
return c;
});
setConversations(updatedConversations);
saveConversations(updatedConversations);
const updatedPrompts: Prompt[] = prompts.map((p) => {
if (p.folderId === folderId) {
return {
...p,
folderId: null,
};
}
return p;
});
setPrompts(updatedPrompts);
savePrompts(updatedPrompts);
};
const handleUpdateFolder = (folderId: string, name: string) => {
const updatedFolders = folders.map((f) => {
if (f.id === folderId) {
return {
...f,
name,
};
}
return f;
});
setFolders(updatedFolders);
saveFolders(updatedFolders);
};
// CONVERSATION OPERATIONS --------------------------------------------
const handleNewConversation = () => {
const lastConversation = conversations[conversations.length - 1];
const newConversation: Conversation = {
id: uuidv4(),
name: `${t('New Conversation')}`,
messages: [],
model: lastConversation?.model || {
id: OpenAIModels[defaultModelId].id,
name: OpenAIModels[defaultModelId].name,
maxLength: OpenAIModels[defaultModelId].maxLength,
tokenLimit: OpenAIModels[defaultModelId].tokenLimit,
},
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
};
const updatedConversations = [...conversations, newConversation];
setSelectedConversation(newConversation);
setConversations(updatedConversations);
saveConversation(newConversation);
saveConversations(updatedConversations);
setLoading(false);
};
const handleDeleteConversation = (conversation: Conversation) => {
const updatedConversations = conversations.filter(
(c) => c.id !== conversation.id,
);
setConversations(updatedConversations);
saveConversations(updatedConversations);
if (updatedConversations.length > 0) {
setSelectedConversation(
updatedConversations[updatedConversations.length - 1],
);
saveConversation(updatedConversations[updatedConversations.length - 1]);
} else {
setSelectedConversation({
id: uuidv4(),
name: 'New conversation',
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
});
localStorage.removeItem('selectedConversation');
}
};
const handleUpdateConversation = (
conversation: Conversation,
data: KeyValuePair,
) => {
const updatedConversation = {
...conversation,
[data.key]: data.value,
};
const { single, all } = updateConversation(
updatedConversation,
conversations,
);
setSelectedConversation(single);
setConversations(all);
};
const handleClearConversations = () => {
setConversations([]);
localStorage.removeItem('conversationHistory');
setSelectedConversation({
id: uuidv4(),
name: 'New conversation',
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
});
localStorage.removeItem('selectedConversation');
const updatedFolders = folders.filter((f) => f.type !== 'chat');
setFolders(updatedFolders);
saveFolders(updatedFolders);
};
const handleEditMessage = (message: Message, messageIndex: number) => {
if (selectedConversation) {
const updatedMessages = selectedConversation.messages
.map((m, i) => {
if (i < messageIndex) {
return m;
}
})
.filter((m) => m) as Message[];
const updatedConversation = {
...selectedConversation,
messages: updatedMessages,
};
const { single, all } = updateConversation(
updatedConversation,
conversations,
);
setSelectedConversation(single);
setConversations(all);
setCurrentMessage(message);
}
};
// PROMPT OPERATIONS --------------------------------------------
const handleCreatePrompt = () => {
const newPrompt: Prompt = {
id: uuidv4(),
name: `Prompt ${prompts.length + 1}`,
description: '',
content: '',
model: OpenAIModels[defaultModelId],
folderId: null,
};
const updatedPrompts = [...prompts, newPrompt];
setPrompts(updatedPrompts);
savePrompts(updatedPrompts);
};
const handleUpdatePrompt = (prompt: Prompt) => {
const updatedPrompts = prompts.map((p) => {
if (p.id === prompt.id) {
return prompt;
}
return p;
});
setPrompts(updatedPrompts);
savePrompts(updatedPrompts);
};
const handleDeletePrompt = (prompt: Prompt) => {
const updatedPrompts = prompts.filter((p) => p.id !== prompt.id);
setPrompts(updatedPrompts);
savePrompts(updatedPrompts);
};
// EFFECTS --------------------------------------------
useEffect(() => {
if (currentMessage) {
handleSend(currentMessage);
setCurrentMessage(undefined);
}
}, [currentMessage]);
useEffect(() => {
if (window.innerWidth < 640) {
setShowSidebar(false);
}
}, [selectedConversation]);
useEffect(() => {
if (apiKey) {
fetchModels(apiKey);
}
}, [apiKey]);
// ON LOAD --------------------------------------------
useEffect(() => {
const theme = localStorage.getItem('theme');
if (theme) {
setLightMode(theme as 'dark' | 'light');
}
const apiKey = localStorage.getItem('apiKey');
if (serverSideApiKeyIsSet) {
fetchModels('');
setApiKey('');
localStorage.removeItem('apiKey');
} else if (apiKey) {
setApiKey(apiKey);
fetchModels(apiKey);
}
const pluginKeys = localStorage.getItem('pluginKeys');
if (serverSidePluginKeysSet) {
setPluginKeys([]);
localStorage.removeItem('pluginKeys');
} else if (pluginKeys) {
setPluginKeys(JSON.parse(pluginKeys));
}
if (window.innerWidth < 640) {
setShowSidebar(false);
}
const showChatbar = localStorage.getItem('showChatbar');
if (showChatbar) {
setShowSidebar(showChatbar === 'true');
}
const showPromptbar = localStorage.getItem('showPromptbar');
if (showPromptbar) {
setShowPromptbar(showPromptbar === 'true');
}
const folders = localStorage.getItem('folders');
if (folders) {
setFolders(JSON.parse(folders));
}
const prompts = localStorage.getItem('prompts');
if (prompts) {
setPrompts(JSON.parse(prompts));
}
const conversationHistory = localStorage.getItem('conversationHistory');
if (conversationHistory) {
const parsedConversationHistory: Conversation[] =
JSON.parse(conversationHistory);
const cleanedConversationHistory = cleanConversationHistory(
parsedConversationHistory,
);
setConversations(cleanedConversationHistory);
}
const selectedConversation = localStorage.getItem('selectedConversation');
if (selectedConversation) {
const parsedSelectedConversation: Conversation =
JSON.parse(selectedConversation);
const cleanedSelectedConversation = cleanSelectedConversation(
parsedSelectedConversation,
);
setSelectedConversation(cleanedSelectedConversation);
} else {
setSelectedConversation({
id: uuidv4(),
name: 'New conversation',
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: null,
});
}
}, [serverSideApiKeyIsSet]);
return (
<>
<Head>
<title>Chatbot UI</title>
<meta name="description" content="ChatGPT but better." />
<meta
name="viewport"
content="height=device-height ,width=device-width, initial-scale=1, user-scalable=no"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
{selectedConversation && (
<main
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
selectedConversation={selectedConversation}
onNewConversation={handleNewConversation}
/>
</div>
<div className="flex h-full w-full pt-[48px] sm:pt-0">
{showSidebar ? (
<div>
<Chatbar
loading={messageIsStreaming}
conversations={conversations}
lightMode={lightMode}
selectedConversation={selectedConversation}
apiKey={apiKey}
serverSideApiKeyIsSet={serverSideApiKeyIsSet}
pluginKeys={pluginKeys}
serverSidePluginKeysSet={serverSidePluginKeysSet}
folders={folders.filter((folder) => folder.type === 'chat')}
onToggleLightMode={handleLightMode}
onCreateFolder={(name) => handleCreateFolder(name, 'chat')}
onDeleteFolder={handleDeleteFolder}
onUpdateFolder={handleUpdateFolder}
onNewConversation={handleNewConversation}
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
onApiKeyChange={handleApiKeyChange}
onClearConversations={handleClearConversations}
onExportConversations={handleExportData}
onImportConversations={handleImportConversations}
onPluginKeyChange={handlePluginKeyChange}
onClearPluginKey={handleClearPluginKey}
/>
<button
className="fixed top-5 left-[270px] z-50 h-7 w-7 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={handleToggleChatbar}
>
<IconArrowBarLeft />
</button>
<div
onClick={handleToggleChatbar}
className="absolute top-0 left-0 z-10 h-full w-full bg-black opacity-70 sm:hidden"
></div>
</div>
) : (
<button
className="fixed top-2.5 left-4 z-50 h-7 w-7 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={handleToggleChatbar}
>
<IconArrowBarRight />
</button>
)}
<div className="flex flex-1">
<Chat
conversation={selectedConversation}
messageIsStreaming={messageIsStreaming}
apiKey={apiKey}
serverSideApiKeyIsSet={serverSideApiKeyIsSet}
defaultModelId={defaultModelId}
modelError={modelError}
models={models}
loading={loading}
prompts={prompts}
onSend={handleSend}
onUpdateConversation={handleUpdateConversation}
onEditMessage={handleEditMessage}
stopConversationRef={stopConversationRef}
/>
</div>
{showPromptbar ? (
<div>
<Promptbar
prompts={prompts}
folders={folders.filter((folder) => folder.type === 'prompt')}
onCreatePrompt={handleCreatePrompt}
onUpdatePrompt={handleUpdatePrompt}
onDeletePrompt={handleDeletePrompt}
onCreateFolder={(name) => handleCreateFolder(name, 'prompt')}
onDeleteFolder={handleDeleteFolder}
onUpdateFolder={handleUpdateFolder}
/>
<button
className="fixed top-5 right-[270px] z-50 h-7 w-7 hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:right-[270px] sm:h-8 sm:w-8 sm:text-neutral-700"
onClick={handleTogglePromptbar}
>
<IconArrowBarRight />
</button>
<div
onClick={handleTogglePromptbar}
className="absolute top-0 left-0 z-10 h-full w-full bg-black opacity-70 sm:hidden"
></div>
</div>
) : (
<button
className="fixed top-2.5 right-4 z-50 h-7 w-7 text-white hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:right-4 sm:h-8 sm:w-8 sm:text-neutral-700"
onClick={handleTogglePromptbar}
>
<IconArrowBarLeft />
</button>
)}
</div>
</main>
)}
</>
);
};
export default Home;
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
const defaultModelId =
(process.env.DEFAULT_MODEL &&
Object.values(OpenAIModelID).includes(
process.env.DEFAULT_MODEL as OpenAIModelID,
) &&
process.env.DEFAULT_MODEL) ||
fallbackModelID;
let serverSidePluginKeysSet = false;
const googleApiKey = process.env.GOOGLE_API_KEY;
const googleCSEId = process.env.GOOGLE_CSE_ID;
if (googleApiKey && googleCSEId) {
serverSidePluginKeysSet = true;
}
return {
props: {
serverSideApiKeyIsSet: !!process.env.OPENAI_API_KEY,
defaultModelId,
serverSidePluginKeysSet,
...(await serverSideTranslations(locale ?? 'en', [
'common',
'chat',
'sidebar',
'markdown',
'promptbar',
])),
},
};
};
export { default, getServerSideProps } from './api/home';

View File

@ -1,5 +1,25 @@
module.exports = {
trailingComma: 'all',
singleQuote: true,
plugins: [require('prettier-plugin-tailwindcss')]
plugins: [
'prettier-plugin-tailwindcss',
'@trivago/prettier-plugin-sort-imports',
],
importOrder: [
'react', // React
'^react-.*$', // React-related imports
'^next', // Next-related imports
'^next-.*$', // Next-related imports
'^next/.*$', // Next-related imports
'^.*/hooks/.*$', // Hooks
'^.*/services/.*$', // Services
'^.*/utils/.*$', // Utils
'^.*/types/.*$', // Types
'^.*/pages/.*$', // Components
'^.*/components/.*$', // Components
'^[./]', // Other imports
'.*', // Any uncaught imports
],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
};

View File

@ -23,14 +23,8 @@
"click if using a .env.local file": ".env.local انقر إذا كنت تستخدم ملف",
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "حرفًا {{maxLength}} حد الرسالة هو {{valueLength}} لقد أدخلت ",
"Please enter a message": "يرجى إدخال رسالة",
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI هي مجموعة متقدمة للدردشة تستخدم",
"Are you sure you want to clear all messages?": "هل أنت متأكد أنك تريد مسح كافة الرسائل؟"

View File

@ -1,5 +1,5 @@
{
"Copy code": "نسخ الكود",
"Copied!": "تم النسخ!",
"Enter file name": "أدخل اسم الملف"
"Copy code": "نسخ الكود",
"Copied!": "تم النسخ!",
"Enter file name": "أدخل اسم الملف"
}

View File

@ -9,4 +9,4 @@
"Prompt": "مطلب",
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "محتوى المطلب. استخدم {{}} للإشارة إلى متغير. مثال: {{الاسم}} هي {{صفة}} {{اسم}}",
"Save": "حفظ"
}
}

35
services/errorService.ts Normal file
View File

@ -0,0 +1,35 @@
import { useMemo } from 'react';
import { useTranslation } from 'next-i18next';
import { ErrorMessage } from '@/types/error';
const useErrorService = () => {
const { t } = useTranslation('chat');
return {
getModelsError: useMemo(
() => (error: any) => {
return !error
? null
: ({
title: t('Error fetching models.'),
code: error.status || 'unknown',
messageLines: error.statusText
? [error.statusText]
: [
t(
'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);
},
[t],
),
};
};
export default useErrorService;

46
services/useApiService.ts Normal file
View File

@ -0,0 +1,46 @@
import { useCallback } from 'react';
import { useFetch } from '@/hooks/useFetch';
export interface GetModelsRequestProps {
key: string;
}
const useApiService = () => {
const fetchService = useFetch();
// const getModels = useCallback(
// (
// params: GetManagementRoutineInstanceDetailedParams,
// signal?: AbortSignal
// ) => {
// return fetchService.get<GetManagementRoutineInstanceDetailed>(
// `/v1/ManagementRoutines/${params.managementRoutineId}/instances/${params.instanceId
// }?sensorGroupIds=${params.sensorGroupId ?? ''}`,
// {
// signal,
// }
// );
// },
// [fetchService]
// );
const getModels = useCallback(
(params: GetModelsRequestProps, signal?: AbortSignal) => {
return fetchService.post<GetModelsRequestProps>(`/api/models`, {
body: { key: params.key },
headers: {
'Content-Type': 'application/json',
},
signal,
});
},
[fetchService],
);
return {
getModels,
};
};
export default useApiService;

View File

@ -11,7 +11,7 @@ module.exports = {
},
variants: {
extend: {
visibility: ["group-hover"],
visibility: ['group-hover'],
},
},
plugins: [require('@tailwindcss/typography')],

View File

@ -1,5 +1,5 @@
import { Conversation, Message } from './chat';
import { Folder } from './folder';
import { FolderInterface } from './folder';
import { OpenAIModel } from './openai';
import { Prompt } from './prompt';
@ -34,12 +34,12 @@ export interface ExportFormatV2 {
export interface ExportFormatV3 {
version: 3;
history: Conversation[];
folders: Folder[];
folders: FolderInterface[];
}
export interface ExportFormatV4 {
version: 4;
history: Conversation[];
folders: Folder[];
prompts: Prompt[]
folders: FolderInterface[];
prompts: Prompt[];
}

View File

@ -1,4 +1,4 @@
export interface Folder {
export interface FolderInterface {
id: string;
name: string;
type: FolderType;

View File

@ -1,5 +1,5 @@
import { Conversation } from './chat';
import { Folder } from './folder';
import { FolderInterface } from './folder';
import { PluginKey } from './plugin';
import { Prompt } from './prompt';
@ -10,7 +10,7 @@ export interface LocalStorage {
selectedConversation: Conversation;
theme: 'light' | 'dark';
// added folders (3/23/23)
folders: Folder[];
folders: FolderInterface[];
// added prompts (3/26/23)
prompts: Prompt[];
// added showChatbar and showPromptbar (3/26/23)

View File

@ -1,5 +1,6 @@
import { Conversation } from '@/types/chat';
import { OpenAIModelID, OpenAIModels } from '@/types/openai';
import { DEFAULT_SYSTEM_PROMPT } from './const';
export const cleanSelectedConversation = (conversation: Conversation) => {

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import {
LatestExportFormat,
SupportedExportFormats,
} from '@/types/export';
import { cleanConversationHistory } from './clean';
export function isExportFormatV1(obj: any): obj is ExportFormatV1 {
@ -50,14 +51,13 @@ export function cleanData(data: SupportedExportFormats): LatestExportFormat {
}
if (isExportFormatV3(data)) {
return {...data, version: 4, prompts: []};
return { ...data, version: 4, prompts: [] };
}
if(isExportFormatV4(data)){
if (isExportFormatV4(data)) {
return data;
}
throw new Error('Unsupported data format');
}
@ -81,7 +81,7 @@ export const exportData = () => {
folders = JSON.parse(folders);
}
if(prompts){
if (prompts) {
prompts = JSON.parse(prompts);
}
@ -110,7 +110,7 @@ export const importData = (
data: SupportedExportFormats,
): LatestExportFormat => {
const cleanedData = cleanData(data);
const { history,folders, prompts } = cleanedData;
const { history, folders, prompts } = cleanedData;
const conversations = history;
localStorage.setItem('conversationHistory', JSON.stringify(conversations));

22
utils/data/throttle.ts Normal file
View File

@ -0,0 +1,22 @@
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number,
): T {
let lastFunc: ReturnType<typeof setTimeout>;
let lastRan: number;
return ((...args) => {
if (!lastRan) {
func(...args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(() => {
if (Date.now() - lastRan >= limit) {
func(...args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
}) as T;
}

View File

@ -1,19 +0,0 @@
export function throttle<T extends (...args: any[]) => any>(func: T, limit: number): T {
let lastFunc: ReturnType<typeof setTimeout>;
let lastRan: number;
return ((...args) => {
if (!lastRan) {
func(...args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(() => {
if (Date.now() - lastRan >= limit) {
func(...args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
}) as T;
}

View File

@ -1,11 +1,13 @@
import { Message } from '@/types/chat';
import { OpenAIModel } from '@/types/openai';
import { OPENAI_API_HOST } from '../app/const';
import {
createParser,
ParsedEvent,
ReconnectInterval,
createParser,
} from 'eventsource-parser';
import { OPENAI_API_HOST } from '../app/const';
export class OpenAIError extends Error {
type: string;

View File

@ -1,5 +1,5 @@
import { defineConfig } from 'vite';
import path from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {