This commit is contained in:
Mckay Wrigley
2023-04-04 09:41:24 -06:00
committed by GitHub
parent e8150e7195
commit e1f286efb8
19 changed files with 1685 additions and 267 deletions
+10 -8
View File
@@ -2,14 +2,15 @@ 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,
memo,
MutableRefObject,
memo,
useCallback,
useEffect,
useRef,
@@ -33,7 +34,11 @@ interface Props {
modelError: ErrorMessage | null;
loading: boolean;
prompts: Prompt[];
onSend: (message: Message, deleteCount?: number) => void;
onSend: (
message: Message,
deleteCount: number,
plugin: Plugin | null,
) => void;
onUpdateConversation: (
conversation: Conversation,
data: KeyValuePair,
@@ -116,8 +121,6 @@ export const Chat: FC<Props> = memo(
};
const throttledScrollDown = throttle(scrollDown, 250);
// appear scroll down button only when user scrolls up
useEffect(() => {
throttledScrollDown();
setCurrentMessage(
@@ -300,16 +303,15 @@ 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) => {
onSend={(message, plugin) => {
setCurrentMessage(message);
onSend(message);
onSend(message, 0, plugin);
}}
onRegenerate={() => {
if (currentMessage) {
onSend(currentMessage, 2);
onSend(currentMessage, 2, null);
}
}}
/>
+53 -13
View File
@@ -1,7 +1,14 @@
import { Message } from '@/types/chat';
import { OpenAIModel } from '@/types/openai';
import { Plugin } from '@/types/plugin';
import { Prompt } from '@/types/prompt';
import { IconPlayerStop, IconRepeat, IconSend } from '@tabler/icons-react';
import {
IconBolt,
IconBrandGoogle,
IconPlayerStop,
IconRepeat,
IconSend,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import {
FC,
@@ -12,6 +19,7 @@ import {
useRef,
useState,
} from 'react';
import { PluginSelect } from './PluginSelect';
import { PromptList } from './PromptList';
import { VariableModal } from './VariableModal';
@@ -19,9 +27,8 @@ interface Props {
messageIsStreaming: boolean;
model: OpenAIModel;
conversationIsEmpty: boolean;
messages: Message[];
prompts: Prompt[];
onSend: (message: Message) => void;
onSend: (message: Message, plugin: Plugin | null) => void;
onRegenerate: () => void;
stopConversationRef: MutableRefObject<boolean>;
textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
@@ -31,7 +38,6 @@ export const ChatInput: FC<Props> = ({
messageIsStreaming,
model,
conversationIsEmpty,
messages,
prompts,
onSend,
onRegenerate,
@@ -47,6 +53,8 @@ export const ChatInput: FC<Props> = ({
const [promptInputValue, setPromptInputValue] = useState('');
const [variables, setVariables] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const [showPluginSelect, setShowPluginSelect] = useState(false);
const [plugin, setPlugin] = useState<Plugin | null>(null);
const promptListRef = useRef<HTMLUListElement | null>(null);
@@ -82,8 +90,9 @@ export const ChatInput: FC<Props> = ({
return;
}
onSend({ role: 'user', content });
onSend({ role: 'user', content }, plugin);
setContent('');
setPlugin(null);
if (window.innerWidth < 640 && textareaRef && textareaRef.current) {
textareaRef.current.blur();
@@ -149,6 +158,9 @@ export const ChatInput: FC<Props> = ({
} else if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) {
e.preventDefault();
handleSend();
} else if (e.key === '/' && e.metaKey) {
e.preventDefault();
setShowPluginSelect(!showPluginSelect);
}
};
@@ -214,8 +226,9 @@ export const ChatInput: FC<Props> = ({
if (textareaRef && textareaRef.current) {
textareaRef.current.style.height = 'inherit';
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
textareaRef.current.style.overflow = `${textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
}`;
textareaRef.current.style.overflow = `${
textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
}`;
}
}, [content]);
@@ -241,7 +254,7 @@ export const ChatInput: FC<Props> = ({
<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-0 left-0 right-0 mb-3 md:mb-0 md:mt-2 mx-auto 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"
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={handleStopConversation}
>
<IconPlayerStop size={16} /> {t('Stop Generating')}
@@ -250,7 +263,7 @@ export const ChatInput: FC<Props> = ({
{!messageIsStreaming && !conversationIsEmpty && (
<button
className="absolute top-0 left-0 right-0 mb-3 md:mb-0 md:mt-2 mx-auto 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"
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}
>
<IconRepeat size={16} /> {t('Regenerate response')}
@@ -258,16 +271,42 @@ export const ChatInput: FC<Props> = ({
)}
<div className="relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white 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">
<button
className="absolute left-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
onClick={() => setShowPluginSelect(!showPluginSelect)}
onKeyDown={(e) => {}}
>
{plugin ? <IconBrandGoogle size={20} /> : <IconBolt size={20} />}
</button>
{showPluginSelect && (
<div className="absolute left-0 bottom-14 bg-white dark:bg-[#343541]">
<PluginSelect
plugin={plugin}
onPluginChange={(plugin: Plugin) => {
setPlugin(plugin);
setShowPluginSelect(false);
if (textareaRef && textareaRef.current) {
textareaRef.current.focus();
}
}}
/>
</div>
)}
<textarea
ref={textareaRef}
className="m-0 w-full resize-none border-0 bg-transparent p-0 py-2 pr-8 pl-2 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-4"
className="m-0 w-full resize-none border-0 bg-transparent p-0 py-2 pr-8 pl-10 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-10"
style={{
resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`,
maxHeight: '400px',
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400
? 'auto' : 'hidden'
}`,
overflow: `${
textareaRef.current && textareaRef.current.scrollHeight > 400
? 'auto'
: 'hidden'
}`,
}}
placeholder={
t('Type a message or type "/" to select a prompt...') || ''
@@ -279,6 +318,7 @@ export const ChatInput: FC<Props> = ({
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<button
className="absolute right-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
onClick={handleSend}
+1 -1
View File
@@ -9,7 +9,7 @@ export const ChatLoader: FC<Props> = () => {
className="group border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100"
style={{ overflowWrap: 'anywhere' }}
>
<div className="flex gap-4 p-4 m-auto text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="min-w-[40px] text-right font-bold">AI:</div>
<IconDots className="animate-pulse" />
</div>
+58
View File
@@ -0,0 +1,58 @@
import { Plugin, PluginList } from '@/types/plugin';
import { useTranslation } from 'next-i18next';
import { FC, useEffect, useRef } from 'react';
interface Props {
plugin: Plugin | null;
onPluginChange: (plugin: Plugin) => void;
}
export const PluginSelect: FC<Props> = ({ plugin, onPluginChange }) => {
const { t } = useTranslation('chat');
const selectRef = useRef<HTMLSelectElement>(null);
useEffect(() => {
if (selectRef.current) {
selectRef.current.focus();
}
}, []);
return (
<div className="flex flex-col">
<div className="w-full rounded-lg border border-neutral-200 bg-transparent pr-2 text-neutral-900 dark:border-neutral-600 dark:text-white">
<select
ref={selectRef}
className="w-full cursor-pointer bg-transparent p-2"
placeholder={t('Select a plugin') || ''}
value={plugin?.id || ''}
onChange={(e) => {
onPluginChange(
PluginList.find(
(plugin) => plugin.id === e.target.value,
) as Plugin,
);
}}
>
<option
key="none"
value=""
className="dark:bg-[#343541] dark:text-white"
>
Select Plugin
</option>
{PluginList.map((plugin) => (
<option
key={plugin.id}
value={plugin.id}
className="dark:bg-[#343541] dark:text-white"
>
{plugin.name}
</option>
))}
</select>
</div>
</div>
);
};
+1 -1
View File
@@ -19,7 +19,7 @@ export const PromptList: FC<Props> = ({
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)] max-h-52 overflow-scroll"
className="z-10 max-h-52 w-full overflow-scroll 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)]"
>
{prompts.map((prompt, index) => (
<li
+12 -6
View File
@@ -2,11 +2,8 @@ import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { SupportedExportFormats } from '@/types/export';
import { Folder } from '@/types/folder';
import {
IconFolderPlus,
IconMessagesOff,
IconPlus,
} from '@tabler/icons-react';
import { PluginKey } from '@/types/plugin';
import { IconFolderPlus, IconMessagesOff, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, useEffect, useState } from 'react';
import { ChatFolders } from '../Folders/Chat/ChatFolders';
@@ -20,6 +17,7 @@ interface Props {
lightMode: 'light' | 'dark';
selectedConversation: Conversation;
apiKey: string;
pluginKeys: PluginKey[];
folders: Folder[];
onCreateFolder: (name: string) => void;
onDeleteFolder: (folderId: string) => void;
@@ -36,6 +34,8 @@ interface Props {
onClearConversations: () => void;
onExportConversations: () => void;
onImportConversations: (data: SupportedExportFormats) => void;
onPluginKeyChange: (pluginKey: PluginKey) => void;
onClearPluginKey: (pluginKey: PluginKey) => void;
}
export const Chatbar: FC<Props> = ({
@@ -44,6 +44,7 @@ export const Chatbar: FC<Props> = ({
lightMode,
selectedConversation,
apiKey,
pluginKeys,
folders,
onCreateFolder,
onDeleteFolder,
@@ -57,6 +58,8 @@ export const Chatbar: FC<Props> = ({
onClearConversations,
onExportConversations,
onImportConversations,
onPluginKeyChange,
onClearPluginKey,
}) => {
const { t } = useTranslation('sidebar');
const [searchTerm, setSearchTerm] = useState<string>('');
@@ -185,7 +188,7 @@ export const Chatbar: FC<Props> = ({
/>
</div>
) : (
<div className="flex flex-col gap-3 items-center text-sm leading-normal mt-8 text-white opacity-50">
<div className="mt-8 flex flex-col items-center gap-3 text-sm leading-normal text-white opacity-50">
<IconMessagesOff />
{t('No conversations.')}
</div>
@@ -195,12 +198,15 @@ export const Chatbar: FC<Props> = ({
<ChatbarSettings
lightMode={lightMode}
apiKey={apiKey}
pluginKeys={pluginKeys}
conversationsCount={conversations.length}
onToggleLightMode={onToggleLightMode}
onApiKeyChange={onApiKeyChange}
onClearConversations={onClearConversations}
onExportConversations={onExportConversations}
onImportConversations={onImportConversations}
onPluginKeyChange={onPluginKeyChange}
onClearPluginKey={onClearPluginKey}
/>
</div>
);
+15
View File
@@ -1,4 +1,5 @@
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';
@@ -6,29 +7,37 @@ 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;
pluginKeys: PluginKey[];
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,
pluginKeys,
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 ? (
@@ -54,6 +63,12 @@ export const ChatbarSettings: FC<Props> = ({
/>
<Key apiKey={apiKey} onApiKeyChange={onApiKeyChange} />
<PluginKeys
pluginKeys={pluginKeys}
onPluginKeyChange={onPluginKeyChange}
onClearPluginKey={onClearPluginKey}
/>
</div>
);
};
+232
View File
@@ -0,0 +1,232 @@
import { PluginID, PluginKey } from '@/types/plugin';
import { IconKey } from '@tabler/icons-react';
import { FC, KeyboardEvent, 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;
}
export const PluginKeys: FC<Props> = ({
pluginKeys,
onPluginKeyChange,
onClearPluginKey,
}) => {
const { t } = useTranslation('sidebar');
const [isChanging, setIsChanging] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const handleEnter = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setIsChanging(false);
}
};
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
window.addEventListener('mouseup', handleMouseUp);
}
};
const handleMouseUp = (e: MouseEvent) => {
window.removeEventListener('mouseup', handleMouseUp);
setIsChanging(false);
};
window.addEventListener('mousedown', handleMouseDown);
return () => {
window.removeEventListener('mousedown', handleMouseDown);
};
}, []);
return (
<>
<SidebarButton
text={t('Plugin Keys')}
icon={<IconKey size={18} />}
onClick={() => setIsChanging(true)}
/>
{isChanging && (
<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="mb-10 text-4xl">Plugin Keys</div>
<div className="mt-6 rounded border p-4">
<div className="text-xl font-bold">Google Search Plugin</div>
<div className="mt-4 italic">
Please enter your Google API Key and Google CSE ID to enable
the Google Search Plugin.
</div>
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
Google API Key
</div>
<input
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"
type="password"
value={
pluginKeys
.find((p) => p.pluginId === PluginID.GOOGLE_SEARCH)
?.requiredKeys.find((k) => k.key === 'GOOGLE_API_KEY')
?.value
}
onChange={(e) => {
const pluginKey = pluginKeys.find(
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
);
if (pluginKey) {
const requiredKey = pluginKey.requiredKeys.find(
(k) => k.key === 'GOOGLE_API_KEY',
);
if (requiredKey) {
const updatedPluginKey = {
...pluginKey,
requiredKeys: pluginKey.requiredKeys.map((k) => {
if (k.key === 'GOOGLE_API_KEY') {
return {
...k,
value: e.target.value,
};
}
return k;
}),
};
onPluginKeyChange(updatedPluginKey);
}
} else {
const newPluginKey: PluginKey = {
pluginId: PluginID.GOOGLE_SEARCH,
requiredKeys: [
{
key: 'GOOGLE_API_KEY',
value: e.target.value,
},
{
key: 'GOOGLE_CSE_ID',
value: '',
},
],
};
onPluginKeyChange(newPluginKey);
}
}}
/>
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
Google CSE ID
</div>
<input
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"
type="password"
value={
pluginKeys
.find((p) => p.pluginId === PluginID.GOOGLE_SEARCH)
?.requiredKeys.find((k) => k.key === 'GOOGLE_CSE_ID')
?.value
}
onChange={(e) => {
const pluginKey = pluginKeys.find(
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
);
if (pluginKey) {
const requiredKey = pluginKey.requiredKeys.find(
(k) => k.key === 'GOOGLE_CSE_ID',
);
if (requiredKey) {
const updatedPluginKey = {
...pluginKey,
requiredKeys: pluginKey.requiredKeys.map((k) => {
if (k.key === 'GOOGLE_CSE_ID') {
return {
...k,
value: e.target.value,
};
}
return k;
}),
};
onPluginKeyChange(updatedPluginKey);
}
} else {
const newPluginKey: PluginKey = {
pluginId: PluginID.GOOGLE_SEARCH,
requiredKeys: [
{
key: 'GOOGLE_API_KEY',
value: '',
},
{
key: 'GOOGLE_CSE_ID',
value: e.target.value,
},
],
};
onPluginKeyChange(newPluginKey);
}
}}
/>
<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 pluginKey = pluginKeys.find(
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
);
if (pluginKey) {
onClearPluginKey(pluginKey);
}
}}
>
Clear Google Search Plugin Keys
</button>
</div>
<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={() => setIsChanging(false)}
>
{t('Save')}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
};