This commit is contained in:
Mckay Wrigley
2023-03-27 09:38:56 -06:00
committed by GitHub
parent 2269403806
commit 34c79c0d66
51 changed files with 1744 additions and 295 deletions
+42 -12
View File
@@ -1,14 +1,20 @@
import {
Conversation,
ErrorMessage,
KeyValuePair,
Message,
OpenAIModel,
} from '@/types';
import { Conversation, Message } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { ErrorMessage } from '@/types/error';
import { OpenAIModel } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { throttle } from '@/utils';
import { IconClearAll, IconKey, IconSettings } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, memo, MutableRefObject, useEffect, useRef, useState } from 'react';
import {
FC,
memo,
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Spinner } from '../Global/Spinner';
import { ChatInput } from './ChatInput';
import { ChatLoader } from './ChatLoader';
@@ -24,8 +30,8 @@ interface Props {
serverSideApiKeyIsSet: boolean;
messageIsStreaming: boolean;
modelError: ErrorMessage | null;
messageError: boolean;
loading: boolean;
prompts: Prompt[];
onSend: (message: Message, deleteCount?: number) => void;
onUpdateConversation: (
conversation: Conversation,
@@ -43,8 +49,8 @@ export const Chat: FC<Props> = memo(
serverSideApiKeyIsSet,
messageIsStreaming,
modelError,
messageError,
loading,
prompts,
onSend,
onUpdateConversation,
onEditMessage,
@@ -59,6 +65,27 @@ export const Chat: FC<Props> = memo(
const chatContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = useCallback(() => {
if (autoScrollEnabled) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
textareaRef.current?.focus();
}
}, [autoScrollEnabled]);
const handleScroll = () => {
if (chatContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } =
chatContainerRef.current;
const bottomTolerance = 30;
if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
setAutoScrollEnabled(false);
} else {
setAutoScrollEnabled(true);
}
}
};
const handleSettings = () => {
setShowSettings(!showSettings);
};
@@ -174,6 +201,7 @@ export const Chat: FC<Props> = memo(
<SystemPrompt
conversation={conversation}
prompts={prompts}
onChangePrompt={(prompt) =>
onUpdateConversation(conversation, {
key: 'prompt',
@@ -201,8 +229,8 @@ export const Chat: FC<Props> = memo(
/>
</div>
{showSettings && (
<div className="flex flex-col space-y-10 md:max-w-xl md:gap-6 md:py-3 md:pt-6 md:mx-auto lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="flex h-full flex-col space-y-4 border-b md:rounded-lg md:border border-neutral-200 p-4 dark:border-neutral-600">
<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}
@@ -241,7 +269,9 @@ export const Chat: FC<Props> = memo(
textareaRef={textareaRef}
messageIsStreaming={messageIsStreaming}
conversationIsEmpty={conversation.messages.length === 0}
messages={conversation.messages}
model={conversation.model}
prompts={prompts}
onSend={(message) => {
setCurrentMessage(message);
onSend(message);
+170 -16
View File
@@ -1,18 +1,26 @@
import { Message, OpenAIModel, OpenAIModelID } from '@/types';
import { Message } from '@/types/chat';
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { IconPlayerStop, IconRepeat, IconSend } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import {
FC,
KeyboardEvent,
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { PromptList } from './PromptList';
import { VariableModal } from './VariableModal';
interface Props {
messageIsStreaming: boolean;
model: OpenAIModel;
conversationIsEmpty: boolean;
messages: Message[];
prompts: Prompt[];
onSend: (message: Message) => void;
onRegenerate: () => void;
stopConversationRef: MutableRefObject<boolean>;
@@ -23,14 +31,28 @@ export const ChatInput: FC<Props> = ({
messageIsStreaming,
model,
conversationIsEmpty,
messages,
prompts,
onSend,
onRegenerate,
stopConversationRef,
textareaRef,
}) => {
const { t } = useTranslation('chat');
const [content, setContent] = useState<string>();
const [isTyping, setIsTyping] = useState<boolean>(false);
const [showPromptList, setShowPromptList] = useState(false);
const [activePromptIndex, setActivePromptIndex] = useState(0);
const [promptInputValue, setPromptInputValue] = useState('');
const [variables, setVariables] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const promptListRef = useRef<HTMLUListElement | null>(null);
const filteredPrompts = prompts.filter((prompt) =>
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
@@ -47,6 +69,7 @@ export const ChatInput: FC<Props> = ({
}
setContent(value);
updatePromptListVisibility(value);
};
const handleSend = () => {
@@ -67,6 +90,13 @@ export const ChatInput: FC<Props> = ({
}
};
const handleStopConversation = () => {
stopConversationRef.current = true;
setTimeout(() => {
stopConversationRef.current = false;
}, 1000);
};
const isMobile = () => {
const userAgent =
typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
@@ -75,15 +105,106 @@ export const ChatInput: FC<Props> = ({
return mobileRegex.test(userAgent);
};
const handleInitModal = () => {
const selectedPrompt = filteredPrompts[activePromptIndex];
setContent((prevContent) => {
const newContent = prevContent?.replace(/\/\w*$/, selectedPrompt.content);
return newContent;
});
handlePromptSelect(selectedPrompt);
setShowPromptList(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!isTyping) {
if (e.key === 'Enter' && !e.shiftKey && !isMobile()) {
if (showPromptList) {
if (e.key === 'ArrowDown') {
e.preventDefault();
handleSend();
setActivePromptIndex((prevIndex) =>
prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex,
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActivePromptIndex((prevIndex) =>
prevIndex > 0 ? prevIndex - 1 : prevIndex,
);
} else if (e.key === 'Tab') {
e.preventDefault();
setActivePromptIndex((prevIndex) =>
prevIndex < prompts.length - 1 ? prevIndex + 1 : 0,
);
} else if (e.key === 'Enter') {
e.preventDefault();
handleInitModal();
} else if (e.key === 'Escape') {
e.preventDefault();
setShowPromptList(false);
} else {
setActivePromptIndex(0);
}
} else if (e.key === 'Enter' && !isMobile() && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const parseVariables = (content: string) => {
const regex = /{{(.*?)}}/g;
const foundVariables = [];
let match;
while ((match = regex.exec(content)) !== null) {
foundVariables.push(match[1]);
}
return foundVariables;
};
const updatePromptListVisibility = useCallback((text: string) => {
const match = text.match(/\/\w*$/);
if (match) {
setShowPromptList(true);
setPromptInputValue(match[0].slice(1));
} else {
setShowPromptList(false);
setPromptInputValue('');
}
}, []);
const handlePromptSelect = (prompt: Prompt) => {
const parsedVariables = parseVariables(prompt.content);
setVariables(parsedVariables);
if (parsedVariables.length > 0) {
setIsModalVisible(true);
} else {
setContent((prevContent) => {
const updatedContent = prevContent?.replace(/\/\w*$/, prompt.content);
return updatedContent;
});
updatePromptListVisibility(prompt.content);
}
};
const handleSubmit = (updatedVariables: string[]) => {
const newContent = content?.replace(/{{(.*?)}}/g, (match, variable) => {
const index = variables.indexOf(variable);
return updatedVariables[index];
});
setContent(newContent);
if (textareaRef && textareaRef.current) {
textareaRef.current.focus();
}
};
useEffect(() => {
if (promptListRef.current) {
promptListRef.current.scrollTop = activePromptIndex * 30;
}
}, [activePromptIndex]);
useEffect(() => {
if (textareaRef && textareaRef.current) {
textareaRef.current.style.height = 'inherit';
@@ -94,19 +215,29 @@ export const ChatInput: FC<Props> = ({
}
}, [content]);
function handleStopConversation() {
stopConversationRef.current = true;
setTimeout(() => {
stopConversationRef.current = false;
}, 1000);
}
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (
promptListRef.current &&
!promptListRef.current.contains(e.target as Node)
) {
setShowPromptList(false);
}
};
window.addEventListener('click', handleOutsideClick);
return () => {
window.removeEventListener('click', handleOutsideClick);
};
}, []);
return (
<div className="absolute bottom-0 left-0 w-full border-transparent bg-gradient-to-b from-transparent via-white to-white pt-6 dark:border-white/20 dark:via-[#343541] dark:to-[#343541] md:pt-2">
<div className="stretch mx-2 mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:mt-[52px] md:last:mb-6 lg:mx-auto lg:max-w-3xl">
{messageIsStreaming && (
<button
className="absolute -top-2 left-0 right-0 mx-auto w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
className="absolute top-2 left-0 right-0 mx-auto mt-2 w-fit 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:top-0"
onClick={handleStopConversation}
>
<IconPlayerStop size={16} className="mb-[2px] inline-block" />{' '}
@@ -116,7 +247,7 @@ export const ChatInput: FC<Props> = ({
{!messageIsStreaming && !conversationIsEmpty && (
<button
className="absolute -top-2 left-0 right-0 mx-auto w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
className="absolute left-0 right-0 mx-auto mt-2 w-fit 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:top-0"
onClick={onRegenerate}
>
<IconRepeat size={16} className="mb-[2px] inline-block" />{' '}
@@ -124,10 +255,10 @@ export const ChatInput: FC<Props> = ({
</button>
)}
<div className="relative flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4">
<div className="relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] sm:mx-4 md:py-3 md:pl-4">
<textarea
ref={textareaRef}
className="m-0 w-full resize-none border-0 bg-transparent p-0 pr-7 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
className="m-0 w-full resize-none border-0 bg-transparent p-0 pr-8 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
style={{
resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`,
@@ -138,7 +269,9 @@ export const ChatInput: FC<Props> = ({
: 'hidden'
}`,
}}
placeholder={t('Type a message...') || ''}
placeholder={
t('Type a message or type "/" to select a prompt...') || ''
}
value={content}
rows={1}
onCompositionStart={() => setIsTyping(true)}
@@ -153,9 +286,30 @@ export const ChatInput: FC<Props> = ({
>
<IconSend size={16} className="opacity-60" />
</button>
{showPromptList && prompts.length > 0 && (
<div className="absolute bottom-12 w-full">
<PromptList
activePromptIndex={activePromptIndex}
prompts={filteredPrompts}
onSelect={handleInitModal}
onMouseOver={setActivePromptIndex}
promptListRef={promptListRef}
/>
</div>
)}
{isModalVisible && (
<VariableModal
prompt={prompts[activePromptIndex]}
variables={variables}
onSubmit={handleSubmit}
onClose={() => setIsModalVisible(false)}
/>
)}
</div>
</div>
<div className="px-3 pt-2 pb-3 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
<div className="px-3 pt-2 pb-3 text-center text-[12px] text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
<a
href="https://github.com/mckaywrigley/chatbot-ui"
target="_blank"
+4 -4
View File
@@ -1,7 +1,7 @@
import { Message } from '@/types';
import { Message } from '@/types/chat';
import { IconEdit } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, useEffect, useRef, useState, memo } from 'react';
import { FC, memo, useEffect, useRef, useState } from 'react';
import rehypeMathjax from 'rehype-mathjax';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -73,7 +73,7 @@ export const ChatMessage: FC<Props> = memo(
return (
<div
className={`group ${
className={`group px-4 ${
message.role === 'assistant'
? 'border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100'
: 'border-b border-black/10 bg-white text-gray-800 dark:border-gray-900/50 dark:bg-[#343541] dark:text-gray-100'
@@ -138,7 +138,7 @@ export const ChatMessage: FC<Props> = memo(
className={`absolute ${
window.innerWidth < 640
? 'right-3 bottom-1'
: 'right-[-20px] top-[26px]'
: 'right-0 top-[26px]'
}`}
>
<IconEdit
+9 -8
View File
@@ -1,5 +1,5 @@
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { FC } from "react";
import { IconCheck, IconCopy } from '@tabler/icons-react';
import { FC } from 'react';
type Props = {
messagedCopied: boolean;
@@ -7,16 +7,17 @@ type Props = {
};
export const CopyButton: FC<Props> = ({ messagedCopied, copyOnClick }) => (
<button className={`absolute ${window.innerWidth < 640 ? "right-3 bottom-1" : "right-[-20px] top-[26px] m-0"}`}>
<button
className={`absolute ${
window.innerWidth < 640 ? 'right-3 bottom-1' : 'right-0 top-[26px] m-0'
}`}
>
{messagedCopied ? (
<IconCheck
size={20}
className="text-green-500 dark:text-green-400"
/>
<IconCheck size={20} className="text-green-500 dark:text-green-400" />
) : (
<IconCopy
size={20}
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
onClick={copyOnClick}
/>
)}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ErrorMessage } from '@/types';
import { ErrorMessage } from '@/types/error';
import { IconCircleX } from '@tabler/icons-react';
import { FC } from 'react';
@@ -19,7 +19,7 @@ export const ErrorMessageDiv: FC<Props> = ({ error }) => {
{line}{' '}
</div>
))}
<div className="text-xs dark:text-red-400 opacity-50 mt-4">
<div className="mt-4 text-xs opacity-50 dark:text-red-400">
{error.code ? <i>Code: {error.code}</i> : ''}
</div>
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
import { OpenAIModel } from '@/types';
import { FC } from 'react';
import { OpenAIModel } from '@/types/openai';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
interface Props {
model: OpenAIModel;
+50
View File
@@ -0,0 +1,50 @@
import { Prompt } from '@/types/prompt';
import { FC, MutableRefObject } from 'react';
interface Props {
prompts: Prompt[];
activePromptIndex: number;
onSelect: () => void;
onMouseOver: (index: number) => void;
promptListRef: MutableRefObject<HTMLUListElement | null>;
}
export const PromptList: FC<Props> = ({
prompts,
activePromptIndex,
onSelect,
onMouseOver,
promptListRef,
}) => {
return (
<ul
ref={promptListRef}
className="z-10 w-full rounded border border-black/10 bg-white shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-neutral-500 dark:bg-[#343541] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]"
style={{
width: 'calc(100% - 48px)',
bottom: '100%',
marginBottom: '4px',
maxHeight: '200px',
}}
>
{prompts.map((prompt, index) => (
<li
key={prompt.id}
className={`${
index === activePromptIndex
? 'bg-gray-200 dark:bg-[#202123] dark:text-black'
: ''
} cursor-pointer px-3 py-2 text-sm text-black dark:text-white`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect();
}}
onMouseEnter={() => onMouseOver(index)}
>
{prompt.name}
</li>
))}
</ul>
);
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { IconRefresh } from '@tabler/icons-react';
import { FC } from 'react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
interface Props {
onRegenerate: () => void;
+175 -7
View File
@@ -1,35 +1,162 @@
import { Conversation } from '@/types';
import { Conversation } from '@/types/chat';
import { OpenAIModelID } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
import { FC, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import {
FC,
KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { PromptList } from './PromptList';
import { VariableModal } from './VariableModal';
interface Props {
conversation: Conversation;
prompts: Prompt[];
onChangePrompt: (prompt: string) => void;
}
export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
export const SystemPrompt: FC<Props> = ({
conversation,
prompts,
onChangePrompt,
}) => {
const { t } = useTranslation('chat');
const [value, setValue] = useState<string>('');
const [activePromptIndex, setActivePromptIndex] = useState(0);
const [showPromptList, setShowPromptList] = useState(false);
const [promptInputValue, setPromptInputValue] = useState('');
const [variables, setVariables] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptListRef = useRef<HTMLUListElement | null>(null);
const filteredPrompts = prompts.filter((prompt) =>
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const maxLength = 4000;
const maxLength =
conversation.model.id === OpenAIModelID.GPT_3_5 ? 12000 : 24000;
if (value.length > maxLength) {
alert(t(`Prompt limit is {{maxLength}} characters`, { maxLength }));
alert(
t(
`Prompt limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
{ maxLength, valueLength: value.length },
),
);
return;
}
setValue(value);
updatePromptListVisibility(value);
if (value.length > 0) {
onChangePrompt(value);
}
};
const handleInitModal = () => {
const selectedPrompt = filteredPrompts[activePromptIndex];
setValue((prevVal) => {
const newContent = prevVal?.replace(/\/\w*$/, selectedPrompt.content);
return newContent;
});
handlePromptSelect(selectedPrompt);
setShowPromptList(false);
};
const parseVariables = (content: string) => {
const regex = /{{(.*?)}}/g;
const foundVariables = [];
let match;
while ((match = regex.exec(content)) !== null) {
foundVariables.push(match[1]);
}
return foundVariables;
};
const updatePromptListVisibility = useCallback((text: string) => {
const match = text.match(/\/\w*$/);
if (match) {
setShowPromptList(true);
setPromptInputValue(match[0].slice(1));
} else {
setShowPromptList(false);
setPromptInputValue('');
}
}, []);
const handlePromptSelect = (prompt: Prompt) => {
const parsedVariables = parseVariables(prompt.content);
setVariables(parsedVariables);
if (parsedVariables.length > 0) {
setIsModalVisible(true);
} else {
const updatedContent = value?.replace(/\/\w*$/, prompt.content);
setValue(updatedContent);
onChangePrompt(updatedContent);
updatePromptListVisibility(prompt.content);
}
};
const handleSubmit = (updatedVariables: string[]) => {
const newContent = value?.replace(/{{(.*?)}}/g, (match, variable) => {
const index = variables.indexOf(variable);
return updatedVariables[index];
});
setValue(newContent);
onChangePrompt(newContent);
if (textareaRef && textareaRef.current) {
textareaRef.current.focus();
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showPromptList) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActivePromptIndex((prevIndex) =>
prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex,
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActivePromptIndex((prevIndex) =>
prevIndex > 0 ? prevIndex - 1 : prevIndex,
);
} else if (e.key === 'Tab') {
e.preventDefault();
setActivePromptIndex((prevIndex) =>
prevIndex < prompts.length - 1 ? prevIndex + 1 : 0,
);
} else if (e.key === 'Enter') {
e.preventDefault();
handleInitModal();
} else if (e.key === 'Escape') {
e.preventDefault();
setShowPromptList(false);
} else {
setActivePromptIndex(0);
}
}
};
useEffect(() => {
if (textareaRef && textareaRef.current) {
textareaRef.current.style.height = 'inherit';
@@ -45,6 +172,23 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
}
}, [conversation]);
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (
promptListRef.current &&
!promptListRef.current.contains(e.target as Node)
) {
setShowPromptList(false);
}
};
window.addEventListener('click', handleOutsideClick);
return () => {
window.removeEventListener('click', handleOutsideClick);
};
}, []);
return (
<div className="flex flex-col">
<label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
@@ -52,7 +196,7 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
</label>
<textarea
ref={textareaRef}
className="w-full rounded-lg border border-neutral-200 px-4 py-3 text-neutral-900 focus:outline-none bg-transparent dark:border-neutral-600 dark:text-neutral-100"
className="w-full rounded-lg border border-neutral-200 bg-transparent px-4 py-3 text-neutral-900 focus:outline-none dark:border-neutral-600 dark:text-neutral-100"
style={{
resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`,
@@ -63,11 +207,35 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
: 'hidden'
}`,
}}
placeholder={t('Enter a prompt') || ''}
placeholder={
t(`Enter a prompt or type "/" to select a prompt...`) || ''
}
value={t(value) || ''}
rows={1}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
{showPromptList && prompts.length > 0 && (
<div>
<PromptList
activePromptIndex={activePromptIndex}
prompts={filteredPrompts}
onSelect={handleInitModal}
onMouseOver={setActivePromptIndex}
promptListRef={promptListRef}
/>
</div>
)}
{isModalVisible && (
<VariableModal
prompt={prompts[activePromptIndex]}
variables={variables}
onSubmit={handleSubmit}
onClose={() => setIsModalVisible(false)}
/>
)}
</div>
);
};
+123
View File
@@ -0,0 +1,123 @@
import { Prompt } from '@/types/prompt';
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
interface Props {
prompt: Prompt;
variables: string[];
onSubmit: (updatedVariables: string[]) => void;
onClose: () => void;
}
export const VariableModal: FC<Props> = ({
prompt,
variables,
onSubmit,
onClose,
}) => {
const [updatedVariables, setUpdatedVariables] = useState<
{ key: string; value: string }[]
>(
variables
.map((variable) => ({ key: variable, value: '' }))
.filter(
(item, index, array) =>
array.findIndex((t) => t.key === item.key) === index,
),
);
const modalRef = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<HTMLTextAreaElement>(null);
const handleChange = (index: number, value: string) => {
setUpdatedVariables((prev) => {
const updated = [...prev];
updated[index].value = value;
return updated;
});
};
const handleSubmit = () => {
if (updatedVariables.some((variable) => variable.value === '')) {
alert('Please fill out all variables');
return;
}
onSubmit(updatedVariables.map((variable) => variable.value));
onClose();
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
onClose();
}
};
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
window.addEventListener('click', handleOutsideClick);
return () => {
window.removeEventListener('click', handleOutsideClick);
};
}, [onClose]);
useEffect(() => {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
onKeyDown={handleKeyDown}
>
<div
ref={modalRef}
className="dark:border-netural-400 inline-block max-h-[400px] transform overflow-hidden overflow-y-auto rounded-lg border border-gray-300 bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all dark:bg-[#202123] sm:my-8 sm:max-h-[600px] sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
role="dialog"
>
<div className="mb-4 text-xl font-bold text-black dark:text-neutral-200">
{prompt.name}
</div>
<div className="mb-4 text-sm italic text-black dark:text-neutral-200">
{prompt.description}
</div>
{updatedVariables.map((variable, index) => (
<div className="mb-4" key={index}>
<div className="mb-2 text-sm font-bold text-neutral-200">
{variable.key}
</div>
<textarea
ref={index === 0 ? nameInputRef : undefined}
className="mt-1 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
style={{ resize: 'none' }}
placeholder={`Enter a value for ${variable.key}...`}
value={variable.value}
onChange={(e) => handleChange(index, e.target.value)}
rows={3}
/>
</div>
))}
<button
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
onClick={handleSubmit}
>
Submit
</button>
</div>
</div>
);
};
@@ -1,16 +1,18 @@
import { ChatFolder, Conversation, KeyValuePair } from '@/types';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { Folder } from '@/types/folder';
import {
IconArrowBarLeft,
IconFolderPlus,
IconMessagesOff,
IconPlus,
} from '@tabler/icons-react';
import { FC, useEffect, useState } 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';
import { Folders } from './Folders';
import { Search } from './Search';
import { SidebarSettings } from './SidebarSettings';
interface Props {
loading: boolean;
@@ -18,10 +20,10 @@ interface Props {
lightMode: 'light' | 'dark';
selectedConversation: Conversation;
apiKey: string;
folders: ChatFolder[];
folders: Folder[];
onCreateFolder: (name: string) => void;
onDeleteFolder: (folderId: number) => void;
onUpdateFolder: (folderId: number, name: string) => void;
onDeleteFolder: (folderId: string) => void;
onUpdateFolder: (folderId: string, name: string) => void;
onNewConversation: () => void;
onToggleLightMode: (mode: 'light' | 'dark') => void;
onSelectConversation: (conversation: Conversation) => void;
@@ -36,11 +38,11 @@ interface Props {
onExportConversations: () => void;
onImportConversations: (data: {
conversations: Conversation[];
folders: ChatFolder[];
folders: Folder[];
}) => void;
}
export const Sidebar: FC<Props> = ({
export const Chatbar: FC<Props> = ({
loading,
conversations,
lightMode,
@@ -117,12 +119,12 @@ export const Sidebar: FC<Props> = ({
}, [searchTerm, conversations]);
return (
<aside
<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`}
>
<header className="flex items-center">
<div className="flex items-center">
<button
className="flex w-[190px] flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-[12.5px] leading-3 text-white transition-colors duration-200 select-none hover:bg-gray-500/10"
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('');
@@ -133,7 +135,7 @@ export const Sidebar: FC<Props> = ({
</button>
<button
className="ml-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-[12.5px] leading-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
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} />
@@ -144,21 +146,25 @@ export const Sidebar: FC<Props> = ({
size={32}
onClick={onToggleSidebar}
/>
</header>
</div>
{conversations.length > 1 && (
<Search searchTerm={searchTerm} onSearch={setSearchTerm} />
<Search
placeholder="Search conversations..."
searchTerm={searchTerm}
onSearch={setSearchTerm}
/>
)}
<div className="flex-grow overflow-y-auto overflow-x-clip">
<div className="flex-grow overflow-auto">
{folders.length > 0 && (
<div className="flex border-b border-white/20 pb-2">
<Folders
<ChatFolders
searchTerm={searchTerm}
conversations={filteredConversations.filter(
(conversation) => conversation.folderId !== 0,
(conversation) => conversation.folderId,
)}
folders={folders}
folders={folders.filter((folder) => folder.type === 'chat')}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
selectedConversation={selectedConversation}
@@ -181,9 +187,7 @@ export const Sidebar: FC<Props> = ({
<Conversations
loading={loading}
conversations={filteredConversations.filter(
(conversation) =>
conversation.folderId === 0 ||
!folders[conversation.folderId - 1],
(conversation) => !conversation.folderId,
)}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
@@ -192,14 +196,16 @@ export const Sidebar: FC<Props> = ({
/>
</div>
) : (
<div className="mt-8 text-white text-center opacity-50 select-none">
<IconMessagesOff className='mx-auto mb-3'/>
<span className='text-[12.5px] leading-3'>{t('No conversations.')}</span>
<div className="mt-8 select-none text-center text-white opacity-50">
<IconMessagesOff className="mx-auto mb-3" />
<span className="text-[14px] leading-normal">
{t('No conversations.')}
</span>
</div>
)}
</div>
<SidebarSettings
<ChatbarSettings
lightMode={lightMode}
apiKey={apiKey}
conversationsCount={conversations.length}
@@ -209,6 +215,6 @@ export const Sidebar: FC<Props> = ({
onExportConversations={onExportConversations}
onImportConversations={onImportConversations}
/>
</aside>
</div>
);
};
@@ -1,11 +1,12 @@
import { ChatFolder, Conversation } from '@/types';
import { Conversation } from '@/types/chat';
import { Folder } from '@/types/folder';
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
import { FC } from '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 { Import } from './Import';
import { Key } from './Key';
import { SidebarButton } from './SidebarButton';
interface Props {
lightMode: 'light' | 'dark';
@@ -17,11 +18,11 @@ interface Props {
onExportConversations: () => void;
onImportConversations: (data: {
conversations: Conversation[];
folders: ChatFolder[];
folders: Folder[];
}) => void;
}
export const SidebarSettings: FC<Props> = ({
export const ChatbarSettings: FC<Props> = ({
lightMode,
apiKey,
conversationsCount,
@@ -1,7 +1,7 @@
import { IconCheck, IconTrash, IconX } from '@tabler/icons-react';
import { FC, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { SidebarButton } from './SidebarButton';
import { FC, useState } from 'react';
import { SidebarButton } from '../Sidebar/SidebarButton';
interface Props {
onClearConversations: () => void;
@@ -1,4 +1,5 @@
import { Conversation, KeyValuePair } from '@/types';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import {
IconCheck,
IconMessage,
@@ -67,10 +68,10 @@ export const ConversationComponent: FC<Props> = ({
return (
<div className="relative flex items-center">
{isRenaming && selectedConversation.id === conversation.id ? (
<div className="flex w-full items-center gap-3 rounded-lg bg-gray-500/10 p-3">
<div className="flex w-full items-center gap-3 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-4 text-white outline-none focus:border-neutral-100"
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)}
@@ -80,10 +81,10 @@ export const ConversationComponent: FC<Props> = ({
</div>
) : (
<button
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-gray-500/10 ${
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-gray-500/10' : ''
selectedConversation.id === conversation.id ? 'bg-[#343541]/90' : ''
}`}
onClick={() => onSelectConversation(conversation)}
disabled={loading}
@@ -1,4 +1,5 @@
import { Conversation, KeyValuePair } from '@/types';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { FC } from 'react';
import { ConversationComponent } from './Conversation';
@@ -23,18 +24,21 @@ export const Conversations: FC<Props> = ({
onUpdateConversation,
}) => {
return (
<div className="flex w-full flex-col gap-1 pt-2">
{conversations.slice().reverse().map((conversation, index) => (
<ConversationComponent
key={index}
selectedConversation={selectedConversation}
conversation={conversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
))}
<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>
);
};
@@ -1,4 +1,6 @@
import { ChatFolder, Conversation, KeyValuePair } from '@/types';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { Folder } from '@/types/folder';
import {
IconCaretDown,
IconCaretRight,
@@ -8,14 +10,14 @@ import {
IconX,
} from '@tabler/icons-react';
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { ConversationComponent } from './Conversation';
import { ConversationComponent } from '../../Chatbar/Conversation';
interface Props {
searchTerm: string;
conversations: Conversation[];
currentFolder: ChatFolder;
onDeleteFolder: (folder: number) => void;
onUpdateFolder: (folder: number, name: string) => void;
currentFolder: Folder;
onDeleteFolder: (folder: string) => void;
onUpdateFolder: (folder: string, name: string) => void;
// conversation props
selectedConversation: Conversation;
loading: boolean;
@@ -27,7 +29,7 @@ interface Props {
) => void;
}
export const Folder: FC<Props> = ({
export const ChatFolder: FC<Props> = ({
searchTerm,
conversations,
currentFolder,
@@ -58,7 +60,7 @@ export const Folder: FC<Props> = ({
setIsRenaming(false);
};
const handleDrop = (e: any, folder: ChatFolder) => {
const handleDrop = (e: any, folder: Folder) => {
if (e.dataTransfer) {
setIsOpen(true);
@@ -100,7 +102,7 @@ export const Folder: FC<Props> = ({
return (
<div>
<div
className={`mb-1 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-[12px] leading-normal transition-colors duration-200 hover:bg-[#343541]/90`}
className={`mb-1 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-[14px] leading-normal transition-colors duration-200 hover:bg-[#343541]/90`}
onClick={() => setIsOpen(!isOpen)}
onDrop={(e) => handleDrop(e, currentFolder)}
onDragOver={allowDrop}
@@ -1,13 +1,15 @@
import { ChatFolder, Conversation, KeyValuePair } from '@/types';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { Folder } from '@/types/folder';
import { FC } from 'react';
import { Folder } from './Folder';
import { ChatFolder } from './ChatFolder';
interface Props {
searchTerm: string;
conversations: Conversation[];
folders: ChatFolder[];
onDeleteFolder: (folder: number) => void;
onUpdateFolder: (folder: number, name: string) => void;
folders: Folder[];
onDeleteFolder: (folder: string) => void;
onUpdateFolder: (folder: string, name: string) => void;
// conversation props
selectedConversation: Conversation;
loading: boolean;
@@ -19,7 +21,7 @@ interface Props {
) => void;
}
export const Folders: FC<Props> = ({
export const ChatFolders: FC<Props> = ({
searchTerm,
conversations,
folders,
@@ -35,7 +37,7 @@ export const Folders: FC<Props> = ({
return (
<div className="flex w-full flex-col gap-1 pt-2">
{folders.map((folder, index) => (
<Folder
<ChatFolder
key={index}
searchTerm={searchTerm}
conversations={conversations.filter((c) => c.folderId)}
+197
View File
@@ -0,0 +1,197 @@
import { PromptComponent } from '@/components/Promptbar/Prompt';
import { Folder } from '@/types/folder';
import { Prompt } from '@/types/prompt';
import {
IconCaretDown,
IconCaretRight,
IconCheck,
IconPencil,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { FC, KeyboardEvent, useEffect, useState } from 'react';
interface Props {
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;
}
export const PromptFolder: FC<Props> = ({
searchTerm,
prompts,
currentFolder,
onDeleteFolder,
onUpdateFolder,
// prompt props
onDeletePrompt,
onUpdatePrompt,
}) => {
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 prompt = JSON.parse(e.dataTransfer.getData('prompt'));
const updatedPrompt = {
...prompt,
folderId: folder.id,
};
onUpdatePrompt(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 (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
useEffect(() => {
if (searchTerm) {
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [searchTerm]);
return (
<div>
<div
className={`mb-1 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-[14px] leading-normal 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={16} /> : <IconCaretRight size={16} />}
{isRenaming ? (
<input
className="flex-1 overflow-hidden overflow-ellipsis border-b border-neutral-400 bg-transparent pr-1 text-left text-white outline-none focus:border-neutral-100"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap pr-1 text-left">
{currentFolder.name}
</div>
)}
{(isDeleting || isRenaming) && (
<div className="-ml-2 flex gap-1">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteFolder(currentFolder.id);
} else if (isRenaming) {
handleRename();
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="ml-2 flex gap-1">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(currentFolder.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</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 pt-2">
<PromptComponent
prompt={prompt}
onDeletePrompt={onDeletePrompt}
onUpdatePrompt={onUpdatePrompt}
/>
</div>
);
}
})
: null}
</div>
);
};
@@ -0,0 +1,44 @@
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 gap-1 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>
);
};
+1 -1
View File
@@ -3,8 +3,8 @@ import {
programmingLanguages,
} from '@/utils/app/codeblock';
import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
import { FC, useState, memo } from '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';
+1 -1
View File
@@ -1,4 +1,4 @@
import { Conversation } from '@/types';
import { Conversation } from '@/types/chat';
import { IconPlus } from '@tabler/icons-react';
import { FC } from 'react';
+123
View File
@@ -0,0 +1,123 @@
import { Prompt } from '@/types/prompt';
import {
IconBulbFilled,
IconCheck,
IconTrash,
IconX,
} from '@tabler/icons-react';
import { DragEvent, FC, useEffect, useState } from 'react';
import { PromptModal } from './PromptModal';
interface Props {
prompt: Prompt;
onUpdatePrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
}
export const PromptComponent: FC<Props> = ({
prompt,
onUpdatePrompt,
onDeletePrompt,
}) => {
const [showModal, setShowModal] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const handleDragStart = (e: DragEvent<HTMLButtonElement>, prompt: Prompt) => {
if (e.dataTransfer) {
e.dataTransfer.setData('prompt', JSON.stringify(prompt));
}
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
return (
<>
<button
className="text-sidebar flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-[14px] transition-colors duration-200 hover:bg-[#343541]/90"
draggable="true"
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
onDragStart={(e) => handleDragStart(e, prompt)}
onMouseLeave={() => {
setIsDeleting(false);
setIsRenaming(false);
setRenameValue('');
}}
>
<IconBulbFilled size={16} />
{isRenaming ? (
<input
className="flex-1 overflow-hidden overflow-ellipsis border-b border-neutral-400 bg-transparent pr-1 text-left text-white outline-none focus:border-neutral-100"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
autoFocus
/>
) : (
<div className="flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap pr-1 text-left">
{prompt.name}
</div>
)}
{(isDeleting || isRenaming) && (
<div className="-ml-2 flex gap-1">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeletePrompt(prompt);
}
setIsDeleting(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
}}
/>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="-ml-2 flex gap-1">
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</button>
{showModal && (
<PromptModal
prompt={prompt}
onClose={() => setShowModal(false)}
onUpdatePrompt={onUpdatePrompt}
/>
)}
</>
);
};
+117
View File
@@ -0,0 +1,117 @@
import { Prompt } from '@/types/prompt';
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
interface Props {
prompt: Prompt;
onClose: () => void;
onUpdatePrompt: (prompt: Prompt) => void;
}
export const PromptModal: FC<Props> = ({ prompt, onClose, onUpdatePrompt }) => {
const [name, setName] = useState(prompt.name);
const [description, setDescription] = useState(prompt.description);
const [content, setContent] = useState(prompt.content);
const modalRef = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const handleEnter = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
onUpdatePrompt({ ...prompt, name, description, content: content.trim() });
onClose();
}
};
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
window.addEventListener('click', handleOutsideClick);
return () => {
window.removeEventListener('click', handleOutsideClick);
};
}, [onClose]);
useEffect(() => {
nameInputRef.current?.focus();
}, []);
return (
<div
className="z-100 fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
onKeyDown={handleEnter}
>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
/>
<div
ref={modalRef}
className="dark:border-netural-400 inline-block max-h-[400px] transform overflow-hidden rounded-lg border border-gray-300 bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all dark:bg-[#202123] sm:my-8 sm:max-h-[600px] sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
role="dialog"
>
<div className="text-sm font-bold text-black dark:text-neutral-200">
Name
</div>
<input
ref={nameInputRef}
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
placeholder="A name for your prompt."
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
Description
</div>
<textarea
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
style={{ resize: 'none' }}
placeholder="A description for your prompt."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
Prompt
</div>
<textarea
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
style={{ resize: 'none' }}
placeholder="Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
/>
<button
type="button"
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
onClick={() => {
const updatedPrompt = {
...prompt,
name,
description,
content: content.trim(),
};
onUpdatePrompt(updatedPrompt);
onClose();
}}
>
Save
</button>
</div>
</div>
</div>
</div>
);
};
+175
View File
@@ -0,0 +1,175 @@
import { Folder } from '@/types/folder';
import { Prompt } from '@/types/prompt';
import {
IconArrowBarRight,
IconFolderPlus,
IconPlus,
} from '@tabler/icons-react';
import { FC, 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;
onToggleSidebar: () => void;
onCreatePrompt: () => void;
onUpdatePrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
}
export const Promptbar: FC<Props> = ({
folders,
prompts,
onCreateFolder,
onDeleteFolder,
onUpdateFolder,
onCreatePrompt,
onUpdatePrompt,
onDeletePrompt,
onToggleSidebar,
}) => {
const { t } = useTranslation('promptbar');
const [searchTerm, setSearchTerm] = useState<string>('');
const [filteredPrompts, setFilteredPrompts] = useState<Prompt[]>(prompts);
const handleUpdatePrompt = (prompt: Prompt) => {
onUpdatePrompt(prompt);
setSearchTerm('');
};
const handleDeletePrompt = (prompt: Prompt) => {
onDeletePrompt(prompt);
setSearchTerm('');
};
const handleDrop = (e: any) => {
if (e.dataTransfer) {
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
const updatedPrompt = {
...prompt,
folderId: e.target.dataset.folderId,
};
onUpdatePrompt(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) => {
const searchable =
prompt.name.toLowerCase() +
' ' +
prompt.description.toLowerCase() +
' ' +
prompt.content.toLowerCase();
return searchable.includes(searchTerm.toLowerCase());
}),
);
} else {
setFilteredPrompts(prompts);
}
}, [searchTerm, prompts]);
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 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('');
}}
>
<IconPlus size={16} />
{t('New prompt')}
</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={() => onCreateFolder(t('New folder'))}
>
<IconFolderPlus size={16} />
</button>
<IconArrowBarRight
className="ml-1 hidden cursor-pointer p-1 text-neutral-300 hover:text-neutral-400 sm:flex"
size={32}
onClick={onToggleSidebar}
/>
</div>
{prompts.length > 1 && (
<Search
placeholder="Search prompts..."
searchTerm={searchTerm}
onSearch={setSearchTerm}
/>
)}
<div className="flex-grow overflow-auto">
{folders.length > 0 && (
<div className="flex border-b border-white/20 pb-2">
<PromptFolders
searchTerm={searchTerm}
prompts={filteredPrompts}
folders={folders.filter((folder) => folder.type === 'prompt')}
onUpdateFolder={onUpdateFolder}
onDeleteFolder={onDeleteFolder}
// prompt props
onDeletePrompt={handleDeletePrompt}
onUpdatePrompt={handleUpdatePrompt}
/>
</div>
)}
{prompts.length > 0 ? (
<div
className="h-full pt-2"
onDrop={(e) => handleDrop(e)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
<Prompts
prompts={filteredPrompts.filter((prompt) => !prompt.folderId)}
onUpdatePrompt={handleUpdatePrompt}
onDeletePrompt={handleDeletePrompt}
/>
</div>
) : (
<div className="mt-4 text-center text-white">
<div>{t('No prompts.')}</div>
</div>
)}
</div>
<PromptbarSettings />
</div>
);
};
@@ -0,0 +1,7 @@
import { FC } from "react";
interface Props {}
export const PromptbarSettings: FC<Props> = () => {
return <div></div>;
};
+31
View File
@@ -0,0 +1,31 @@
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>
);
};
@@ -1,14 +1,15 @@
import { ChatFolder, Conversation } from '@/types';
import { Conversation } from '@/types/chat';
import { Folder } from '@/types/folder';
import { cleanConversationHistory } from '@/utils/app/clean';
import { IconFileImport } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
import { SidebarButton } from './SidebarButton';
import { SidebarButton } from '../Sidebar/SidebarButton';
interface Props {
onImport: (data: {
conversations: Conversation[];
folders: ChatFolder[];
folders: Folder[];
}) => void;
}
@@ -1,7 +1,7 @@
import { IconCheck, IconKey, IconX } from '@tabler/icons-react';
import { FC, KeyboardEvent, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { SidebarButton } from './SidebarButton';
import { FC, KeyboardEvent, useState } from 'react';
import { SidebarButton } from '../Sidebar/SidebarButton';
interface Props {
apiKey: string;
+5 -4
View File
@@ -1,13 +1,14 @@
import { IconX } from '@tabler/icons-react';
import { FC } from 'react';
import { useTranslation } from 'next-i18next';
import { FC } from 'react';
interface Props {
placeholder: string;
searchTerm: string;
onSearch: (searchTerm: string) => void;
}
export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
export const Search: FC<Props> = ({ placeholder, searchTerm, onSearch }) => {
const { t } = useTranslation('sidebar');
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -21,9 +22,9 @@ export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
return (
<div className="relative flex items-center">
<input
className="w-full flex-1 rounded-md border border-neutral-600 bg-[#202123] px-4 py-3 pr-10 text-[12px] leading-3 text-white"
className="w-full flex-1 rounded-md border border-neutral-600 bg-[#202123] px-4 py-3 pr-10 text-[14px] leading-3 text-white"
type="text"
placeholder={t('Search conversations...') || ''}
placeholder={t(placeholder) || ''}
value={searchTerm}
onChange={handleSearchChange}
/>
+1 -1
View File
@@ -9,7 +9,7 @@ interface Props {
export const SidebarButton: FC<Props> = ({ text, icon, onClick }) => {
return (
<button
className="flex w-full cursor-pointer select-none items-center gap-3 rounded-md py-3 px-3 text-[12.5px] leading-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
className="flex w-full cursor-pointer select-none items-center gap-3 rounded-md py-3 px-3 text-[14px] leading-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={onClick}
>
<div>{icon}</div>