This commit is contained in:
Mckay Wrigley 2023-04-04 09:41:24 -06:00 committed by GitHub
parent e8150e7195
commit e1f286efb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1685 additions and 267 deletions

View File

@ -1,3 +1,8 @@
# Chatbot UI
DEFAULT_MODEL=gpt-3.5-turbo DEFAULT_MODEL=gpt-3.5-turbo
DEFAULT_SYSTEM_PROMPT=You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown. DEFAULT_SYSTEM_PROMPT=You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.
OPENAI_API_KEY=YOUR_KEY OPENAI_API_KEY=YOUR_KEY
# Google
GOOGLE_API_KEY=YOUR_API_KEY
GOOGLE_CSE_ID=YOUR_ENGINE_ID

View File

@ -2,14 +2,15 @@ import { Conversation, Message } from '@/types/chat';
import { KeyValuePair } from '@/types/data'; import { KeyValuePair } from '@/types/data';
import { ErrorMessage } from '@/types/error'; import { ErrorMessage } from '@/types/error';
import { OpenAIModel, OpenAIModelID } from '@/types/openai'; import { OpenAIModel, OpenAIModelID } from '@/types/openai';
import { Plugin } from '@/types/plugin';
import { Prompt } from '@/types/prompt'; import { Prompt } from '@/types/prompt';
import { throttle } from '@/utils'; import { throttle } from '@/utils';
import { IconArrowDown, IconClearAll, IconSettings } from '@tabler/icons-react'; import { IconArrowDown, IconClearAll, IconSettings } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { import {
FC, FC,
memo,
MutableRefObject, MutableRefObject,
memo,
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
@ -33,7 +34,11 @@ interface Props {
modelError: ErrorMessage | null; modelError: ErrorMessage | null;
loading: boolean; loading: boolean;
prompts: Prompt[]; prompts: Prompt[];
onSend: (message: Message, deleteCount?: number) => void; onSend: (
message: Message,
deleteCount: number,
plugin: Plugin | null,
) => void;
onUpdateConversation: ( onUpdateConversation: (
conversation: Conversation, conversation: Conversation,
data: KeyValuePair, data: KeyValuePair,
@ -116,8 +121,6 @@ export const Chat: FC<Props> = memo(
}; };
const throttledScrollDown = throttle(scrollDown, 250); const throttledScrollDown = throttle(scrollDown, 250);
// appear scroll down button only when user scrolls up
useEffect(() => { useEffect(() => {
throttledScrollDown(); throttledScrollDown();
setCurrentMessage( setCurrentMessage(
@ -300,16 +303,15 @@ export const Chat: FC<Props> = memo(
textareaRef={textareaRef} textareaRef={textareaRef}
messageIsStreaming={messageIsStreaming} messageIsStreaming={messageIsStreaming}
conversationIsEmpty={conversation.messages.length === 0} conversationIsEmpty={conversation.messages.length === 0}
messages={conversation.messages}
model={conversation.model} model={conversation.model}
prompts={prompts} prompts={prompts}
onSend={(message) => { onSend={(message, plugin) => {
setCurrentMessage(message); setCurrentMessage(message);
onSend(message); onSend(message, 0, plugin);
}} }}
onRegenerate={() => { onRegenerate={() => {
if (currentMessage) { if (currentMessage) {
onSend(currentMessage, 2); onSend(currentMessage, 2, null);
} }
}} }}
/> />

View File

@ -1,7 +1,14 @@
import { Message } from '@/types/chat'; import { Message } from '@/types/chat';
import { OpenAIModel } from '@/types/openai'; import { OpenAIModel } from '@/types/openai';
import { Plugin } from '@/types/plugin';
import { Prompt } from '@/types/prompt'; 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 { useTranslation } from 'next-i18next';
import { import {
FC, FC,
@ -12,6 +19,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { PluginSelect } from './PluginSelect';
import { PromptList } from './PromptList'; import { PromptList } from './PromptList';
import { VariableModal } from './VariableModal'; import { VariableModal } from './VariableModal';
@ -19,9 +27,8 @@ interface Props {
messageIsStreaming: boolean; messageIsStreaming: boolean;
model: OpenAIModel; model: OpenAIModel;
conversationIsEmpty: boolean; conversationIsEmpty: boolean;
messages: Message[];
prompts: Prompt[]; prompts: Prompt[];
onSend: (message: Message) => void; onSend: (message: Message, plugin: Plugin | null) => void;
onRegenerate: () => void; onRegenerate: () => void;
stopConversationRef: MutableRefObject<boolean>; stopConversationRef: MutableRefObject<boolean>;
textareaRef: MutableRefObject<HTMLTextAreaElement | null>; textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
@ -31,7 +38,6 @@ export const ChatInput: FC<Props> = ({
messageIsStreaming, messageIsStreaming,
model, model,
conversationIsEmpty, conversationIsEmpty,
messages,
prompts, prompts,
onSend, onSend,
onRegenerate, onRegenerate,
@ -47,6 +53,8 @@ export const ChatInput: FC<Props> = ({
const [promptInputValue, setPromptInputValue] = useState(''); const [promptInputValue, setPromptInputValue] = useState('');
const [variables, setVariables] = useState<string[]>([]); const [variables, setVariables] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const [showPluginSelect, setShowPluginSelect] = useState(false);
const [plugin, setPlugin] = useState<Plugin | null>(null);
const promptListRef = useRef<HTMLUListElement | null>(null); const promptListRef = useRef<HTMLUListElement | null>(null);
@ -82,8 +90,9 @@ export const ChatInput: FC<Props> = ({
return; return;
} }
onSend({ role: 'user', content }); onSend({ role: 'user', content }, plugin);
setContent(''); setContent('');
setPlugin(null);
if (window.innerWidth < 640 && textareaRef && textareaRef.current) { if (window.innerWidth < 640 && textareaRef && textareaRef.current) {
textareaRef.current.blur(); textareaRef.current.blur();
@ -149,6 +158,9 @@ export const ChatInput: FC<Props> = ({
} else if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) { } else if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} else if (e.key === '/' && e.metaKey) {
e.preventDefault();
setShowPluginSelect(!showPluginSelect);
} }
}; };
@ -214,7 +226,8 @@ export const ChatInput: FC<Props> = ({
if (textareaRef && textareaRef.current) { if (textareaRef && textareaRef.current) {
textareaRef.current.style.height = 'inherit'; textareaRef.current.style.height = 'inherit';
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`; textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
textareaRef.current.style.overflow = `${textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden' textareaRef.current.style.overflow = `${
textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
}`; }`;
} }
}, [content]); }, [content]);
@ -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"> <div className="stretch mx-2 mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:mt-[52px] md:last:mb-6 lg:mx-auto lg:max-w-3xl">
{messageIsStreaming && ( {messageIsStreaming && (
<button <button
className="absolute top-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} onClick={handleStopConversation}
> >
<IconPlayerStop size={16} /> {t('Stop Generating')} <IconPlayerStop size={16} /> {t('Stop Generating')}
@ -250,7 +263,7 @@ export const ChatInput: FC<Props> = ({
{!messageIsStreaming && !conversationIsEmpty && ( {!messageIsStreaming && !conversationIsEmpty && (
<button <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} onClick={onRegenerate}
> >
<IconRepeat size={16} /> {t('Regenerate response')} <IconRepeat size={16} /> {t('Regenerate response')}
@ -258,15 +271,41 @@ 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"> <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 <textarea
ref={textareaRef} 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={{ style={{
resize: 'none', resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`, bottom: `${textareaRef?.current?.scrollHeight}px`,
maxHeight: '400px', maxHeight: '400px',
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 overflow: `${
? 'auto' : 'hidden' textareaRef.current && textareaRef.current.scrollHeight > 400
? 'auto'
: 'hidden'
}`, }`,
}} }}
placeholder={ placeholder={
@ -279,6 +318,7 @@ export const ChatInput: FC<Props> = ({
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
<button <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" 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} onClick={handleSend}

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" className="group border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100"
style={{ overflowWrap: 'anywhere' }} style={{ overflowWrap: 'anywhere' }}
> >
<div className="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> <div className="min-w-[40px] text-right font-bold">AI:</div>
<IconDots className="animate-pulse" /> <IconDots className="animate-pulse" />
</div> </div>

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>
);
};

View File

@ -19,7 +19,7 @@ export const PromptList: FC<Props> = ({
return ( return (
<ul <ul
ref={promptListRef} 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) => ( {prompts.map((prompt, index) => (
<li <li

View File

@ -2,11 +2,8 @@ import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data'; import { KeyValuePair } from '@/types/data';
import { SupportedExportFormats } from '@/types/export'; import { SupportedExportFormats } from '@/types/export';
import { Folder } from '@/types/folder'; import { Folder } from '@/types/folder';
import { import { PluginKey } from '@/types/plugin';
IconFolderPlus, import { IconFolderPlus, IconMessagesOff, IconPlus } from '@tabler/icons-react';
IconMessagesOff,
IconPlus,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { ChatFolders } from '../Folders/Chat/ChatFolders'; import { ChatFolders } from '../Folders/Chat/ChatFolders';
@ -20,6 +17,7 @@ interface Props {
lightMode: 'light' | 'dark'; lightMode: 'light' | 'dark';
selectedConversation: Conversation; selectedConversation: Conversation;
apiKey: string; apiKey: string;
pluginKeys: PluginKey[];
folders: Folder[]; folders: Folder[];
onCreateFolder: (name: string) => void; onCreateFolder: (name: string) => void;
onDeleteFolder: (folderId: string) => void; onDeleteFolder: (folderId: string) => void;
@ -36,6 +34,8 @@ interface Props {
onClearConversations: () => void; onClearConversations: () => void;
onExportConversations: () => void; onExportConversations: () => void;
onImportConversations: (data: SupportedExportFormats) => void; onImportConversations: (data: SupportedExportFormats) => void;
onPluginKeyChange: (pluginKey: PluginKey) => void;
onClearPluginKey: (pluginKey: PluginKey) => void;
} }
export const Chatbar: FC<Props> = ({ export const Chatbar: FC<Props> = ({
@ -44,6 +44,7 @@ export const Chatbar: FC<Props> = ({
lightMode, lightMode,
selectedConversation, selectedConversation,
apiKey, apiKey,
pluginKeys,
folders, folders,
onCreateFolder, onCreateFolder,
onDeleteFolder, onDeleteFolder,
@ -57,6 +58,8 @@ export const Chatbar: FC<Props> = ({
onClearConversations, onClearConversations,
onExportConversations, onExportConversations,
onImportConversations, onImportConversations,
onPluginKeyChange,
onClearPluginKey,
}) => { }) => {
const { t } = useTranslation('sidebar'); const { t } = useTranslation('sidebar');
const [searchTerm, setSearchTerm] = useState<string>(''); const [searchTerm, setSearchTerm] = useState<string>('');
@ -185,7 +188,7 @@ export const Chatbar: FC<Props> = ({
/> />
</div> </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 /> <IconMessagesOff />
{t('No conversations.')} {t('No conversations.')}
</div> </div>
@ -195,12 +198,15 @@ export const Chatbar: FC<Props> = ({
<ChatbarSettings <ChatbarSettings
lightMode={lightMode} lightMode={lightMode}
apiKey={apiKey} apiKey={apiKey}
pluginKeys={pluginKeys}
conversationsCount={conversations.length} conversationsCount={conversations.length}
onToggleLightMode={onToggleLightMode} onToggleLightMode={onToggleLightMode}
onApiKeyChange={onApiKeyChange} onApiKeyChange={onApiKeyChange}
onClearConversations={onClearConversations} onClearConversations={onClearConversations}
onExportConversations={onExportConversations} onExportConversations={onExportConversations}
onImportConversations={onImportConversations} onImportConversations={onImportConversations}
onPluginKeyChange={onPluginKeyChange}
onClearPluginKey={onClearPluginKey}
/> />
</div> </div>
); );

View File

@ -1,4 +1,5 @@
import { SupportedExportFormats } from '@/types/export'; import { SupportedExportFormats } from '@/types/export';
import { PluginKey } from '@/types/plugin';
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react'; import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { FC } from 'react'; import { FC } from 'react';
@ -6,29 +7,37 @@ import { Import } from '../Settings/Import';
import { Key } from '../Settings/Key'; import { Key } from '../Settings/Key';
import { SidebarButton } from '../Sidebar/SidebarButton'; import { SidebarButton } from '../Sidebar/SidebarButton';
import { ClearConversations } from './ClearConversations'; import { ClearConversations } from './ClearConversations';
import { PluginKeys } from './PluginKeys';
interface Props { interface Props {
lightMode: 'light' | 'dark'; lightMode: 'light' | 'dark';
apiKey: string; apiKey: string;
pluginKeys: PluginKey[];
conversationsCount: number; conversationsCount: number;
onToggleLightMode: (mode: 'light' | 'dark') => void; onToggleLightMode: (mode: 'light' | 'dark') => void;
onApiKeyChange: (apiKey: string) => void; onApiKeyChange: (apiKey: string) => void;
onClearConversations: () => void; onClearConversations: () => void;
onExportConversations: () => void; onExportConversations: () => void;
onImportConversations: (data: SupportedExportFormats) => void; onImportConversations: (data: SupportedExportFormats) => void;
onPluginKeyChange: (pluginKey: PluginKey) => void;
onClearPluginKey: (pluginKey: PluginKey) => void;
} }
export const ChatbarSettings: FC<Props> = ({ export const ChatbarSettings: FC<Props> = ({
lightMode, lightMode,
apiKey, apiKey,
pluginKeys,
conversationsCount, conversationsCount,
onToggleLightMode, onToggleLightMode,
onApiKeyChange, onApiKeyChange,
onClearConversations, onClearConversations,
onExportConversations, onExportConversations,
onImportConversations, onImportConversations,
onPluginKeyChange,
onClearPluginKey,
}) => { }) => {
const { t } = useTranslation('sidebar'); const { t } = useTranslation('sidebar');
return ( return (
<div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm"> <div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
{conversationsCount > 0 ? ( {conversationsCount > 0 ? (
@ -54,6 +63,12 @@ export const ChatbarSettings: FC<Props> = ({
/> />
<Key apiKey={apiKey} onApiKeyChange={onApiKeyChange} /> <Key apiKey={apiKey} onApiKeyChange={onApiKeyChange} />
<PluginKeys
pluginKeys={pluginKeys}
onPluginKeyChange={onPluginKeyChange}
onClearPluginKey={onClearPluginKey}
/>
</div> </div>
); );
}; };

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>
)}
</>
);
};

21
docs/google_search.md Normal file
View File

@ -0,0 +1,21 @@
# Google Search Tool
Use the Google Search API to search the web in Chatbot UI.
## How To Enable
1. Create a new project at https://console.developers.google.com/apis/dashboard
2. Create a new API key at https://console.developers.google.com/apis/credentials
3. Enable the Custom Search API at https://console.developers.google.com/apis/library/customsearch.googleapis.com
4. Create a new Custom Search Engine at https://cse.google.com/cse/all
5. Add your API Key and your Custom Search Engine ID to your .env.local file
6. You can now select the Google Search Tool in the search tools dropdown
## Usage Limits
Google gives you 100 free searches per day. You can increase this limit by creating a billing account.

1021
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -31,7 +31,9 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@mozilla/readability": "^0.4.4",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/jsdom": "^21.1.1",
"@types/node": "18.15.0", "@types/node": "18.15.0",
"@types/react": "18.0.28", "@types/react": "18.0.28",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
@ -39,8 +41,11 @@
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@vitest/coverage-c8": "^0.29.7", "@vitest/coverage-c8": "^0.29.7",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"endent": "^2.1.0",
"eslint": "8.36.0", "eslint": "8.36.0",
"eslint-config-next": "13.2.4", "eslint-config-next": "13.2.4",
"gpt-3-encoder": "^1.1.4",
"jsdom": "^21.1.1",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.5", "prettier-plugin-tailwindcss": "^0.2.5",

130
pages/api/google.ts Normal file
View File

@ -0,0 +1,130 @@
import { ChatBody, Message } from '@/types/chat';
import { GoogleSource } from '@/types/google';
import { OPENAI_API_HOST } from '@/utils/app/const';
import { cleanSourceText } from '@/utils/server/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 {
const { messages, key, model } = req.body as ChatBody;
const userMessage = messages[messages.length - 1];
const googleRes = await fetch(
`https://customsearch.googleapis.com/customsearch/v1?key=${
process.env.GOOGLE_API_KEY
}&cx=${process.env.GOOGLE_CSE_ID}&q=${userMessage.content.trim()}&num=5`,
);
const googleData = await googleRes.json();
const sources: GoogleSource[] = googleData.items.map((item: any) => ({
title: item.title,
link: item.link,
displayLink: item.displayLink,
snippet: item.snippet,
image: item.pagemap?.cse_image?.[0]?.src,
text: '',
}));
const sourcesWithText: any = await Promise.all(
sources.map(async (source) => {
try {
const res = await fetch(source.link);
const html = await res.text();
const virtualConsole = new jsdom.VirtualConsole();
virtualConsole.on('error', (error) => {
if (!error.message.includes('Could not parse CSS stylesheet')) {
console.error(error);
}
});
const dom = new JSDOM(html, { virtualConsole });
const doc = dom.window.document;
const parsed = new Readability(doc).parse();
if (parsed) {
let sourceText = cleanSourceText(parsed.textContent);
return {
...source,
// TODO: switch to tokens
text: sourceText.slice(0, 2000),
} as GoogleSource;
}
return null;
} catch (error) {
return null;
}
}),
);
const filteredSources: GoogleSource[] = sourcesWithText.filter(Boolean);
const answerPrompt = endent`
Provide me with the information I requested. Use the sources to provide an accurate response. Respond in markdown format. Cite the sources you used as a markdown link as you use them at the end of each sentence by number of the source (ex: [[1]](link.com)). Provide an accurate response and then stop. Today's date is ${new Date().toLocaleDateString()}.
Example Input:
What's the weather in San Francisco today?
Example Sources:
[Weather in San Francisco](https://www.google.com/search?q=weather+san+francisco)
Example Response:
It's 70 degrees and sunny in San Francisco today. [[1]](https://www.google.com/search?q=weather+san+francisco)
Input:
${userMessage.content.trim()}
Sources:
${filteredSources.map((source) => {
return endent`
${source.title} (${source.link}):
${source.text}
`;
})}
Response:
`;
const answerMessage: Message = { role: 'user', content: answerPrompt };
const answerRes = await fetch(`${OPENAI_API_HOST}/v1/chat/completions`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`,
...(process.env.OPENAI_ORGANIZATION && {
'OpenAI-Organization': process.env.OPENAI_ORGANIZATION,
}),
},
method: 'POST',
body: JSON.stringify({
model: model.id,
messages: [
{
role: 'system',
content: `Use the sources to provide an accurate response. Respond in markdown format. Cite the sources you used as [1](link), etc, as you use them.`,
},
answerMessage,
],
max_tokens: 1000,
temperature: 1,
stream: false,
}),
});
const { choices: choices2 } = await answerRes.json();
const answer = choices2[0].message.content;
res.status(200).json({ answer });
} catch (error) {
return new Response('Error', { status: 500 });
}
};
export default handler;

View File

@ -8,12 +8,14 @@ import { ErrorMessage } from '@/types/error';
import { LatestExportFormat, SupportedExportFormats } from '@/types/export'; import { LatestExportFormat, SupportedExportFormats } from '@/types/export';
import { Folder, FolderType } from '@/types/folder'; import { Folder, FolderType } from '@/types/folder';
import { import {
fallbackModelID,
OpenAIModel, OpenAIModel,
OpenAIModelID, OpenAIModelID,
OpenAIModels, OpenAIModels,
fallbackModelID,
} from '@/types/openai'; } from '@/types/openai';
import { Plugin, PluginKey } from '@/types/plugin';
import { Prompt } from '@/types/prompt'; import { Prompt } from '@/types/prompt';
import { getEndpoint } from '@/utils/app/api';
import { import {
cleanConversationHistory, cleanConversationHistory,
cleanSelectedConversation, cleanSelectedConversation,
@ -33,16 +35,18 @@ import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Head from 'next/head'; import Head from 'next/head';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { v4 as uuidv4 } from 'uuid';
interface HomeProps { interface HomeProps {
serverSideApiKeyIsSet: boolean; serverSideApiKeyIsSet: boolean;
serverSidePluginKeysSet: boolean;
defaultModelId: OpenAIModelID; defaultModelId: OpenAIModelID;
} }
const Home: React.FC<HomeProps> = ({ const Home: React.FC<HomeProps> = ({
serverSideApiKeyIsSet, serverSideApiKeyIsSet,
serverSidePluginKeysSet,
defaultModelId, defaultModelId,
}) => { }) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
@ -50,6 +54,7 @@ const Home: React.FC<HomeProps> = ({
// STATE ---------------------------------------------- // STATE ----------------------------------------------
const [apiKey, setApiKey] = useState<string>(''); const [apiKey, setApiKey] = useState<string>('');
const [pluginKeys, setPluginKeys] = useState<PluginKey[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [lightMode, setLightMode] = useState<'dark' | 'light'>('dark'); const [lightMode, setLightMode] = useState<'dark' | 'light'>('dark');
const [messageIsStreaming, setMessageIsStreaming] = useState<boolean>(false); const [messageIsStreaming, setMessageIsStreaming] = useState<boolean>(false);
@ -76,7 +81,11 @@ const Home: React.FC<HomeProps> = ({
// FETCH RESPONSE ---------------------------------------------- // FETCH RESPONSE ----------------------------------------------
const handleSend = async (message: Message, deleteCount = 0) => { const handleSend = async (
message: Message,
deleteCount = 0,
plugin: Plugin | null = null,
) => {
if (selectedConversation) { if (selectedConversation) {
let updatedConversation: Conversation; let updatedConversation: Conversation;
@ -108,8 +117,10 @@ const Home: React.FC<HomeProps> = ({
prompt: updatedConversation.prompt, prompt: updatedConversation.prompt,
}; };
const endpoint = getEndpoint(plugin);
const controller = new AbortController(); const controller = new AbortController();
const response = await fetch('/api/chat', { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -133,6 +144,7 @@ const Home: React.FC<HomeProps> = ({
return; return;
} }
if (!plugin) {
if (updatedConversation.messages.length === 1) { if (updatedConversation.messages.length === 1) {
const { content } = message; const { content } = message;
const customName = const customName =
@ -217,10 +229,45 @@ const Home: React.FC<HomeProps> = ({
} }
setConversations(updatedConversations); setConversations(updatedConversations);
saveConversations(updatedConversations); saveConversations(updatedConversations);
setMessageIsStreaming(false); 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);
}
} }
}; };
@ -283,6 +330,45 @@ const Home: React.FC<HomeProps> = ({
localStorage.setItem('apiKey', 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 = () => { const handleToggleChatbar = () => {
setShowSidebar(!showSidebar); setShowSidebar(!showSidebar);
localStorage.setItem('showChatbar', JSON.stringify(!showSidebar)); localStorage.setItem('showChatbar', JSON.stringify(!showSidebar));
@ -496,8 +582,6 @@ const Home: React.FC<HomeProps> = ({
// PROMPT OPERATIONS -------------------------------------------- // PROMPT OPERATIONS --------------------------------------------
const handleCreatePrompt = () => { const handleCreatePrompt = () => {
const lastPrompt = prompts[prompts.length - 1];
const newPrompt: Prompt = { const newPrompt: Prompt = {
id: uuidv4(), id: uuidv4(),
name: `Prompt ${prompts.length + 1}`, name: `Prompt ${prompts.length + 1}`,
@ -562,11 +646,21 @@ const Home: React.FC<HomeProps> = ({
} }
const apiKey = localStorage.getItem('apiKey'); const apiKey = localStorage.getItem('apiKey');
if (apiKey) { if (serverSideApiKeyIsSet) {
fetchModels('');
setApiKey('');
localStorage.removeItem('apiKey');
} else if (apiKey) {
setApiKey(apiKey); setApiKey(apiKey);
fetchModels(apiKey); fetchModels(apiKey);
} else if (serverSideApiKeyIsSet) { }
fetchModels('');
const pluginKeys = localStorage.getItem('pluginKeys');
if (serverSidePluginKeysSet) {
setPluginKeys([]);
localStorage.removeItem('pluginKeys');
} else if (pluginKeys) {
setPluginKeys(JSON.parse(pluginKeys));
} }
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
@ -654,6 +748,7 @@ const Home: React.FC<HomeProps> = ({
lightMode={lightMode} lightMode={lightMode}
selectedConversation={selectedConversation} selectedConversation={selectedConversation}
apiKey={apiKey} apiKey={apiKey}
pluginKeys={pluginKeys}
folders={folders.filter((folder) => folder.type === 'chat')} folders={folders.filter((folder) => folder.type === 'chat')}
onToggleLightMode={handleLightMode} onToggleLightMode={handleLightMode}
onCreateFolder={(name) => handleCreateFolder(name, 'chat')} onCreateFolder={(name) => handleCreateFolder(name, 'chat')}
@ -667,6 +762,8 @@ const Home: React.FC<HomeProps> = ({
onClearConversations={handleClearConversations} onClearConversations={handleClearConversations}
onExportConversations={handleExportData} onExportConversations={handleExportData}
onImportConversations={handleImportConversations} onImportConversations={handleImportConversations}
onPluginKeyChange={handlePluginKeyChange}
onClearPluginKey={handleClearPluginKey}
/> />
<button <button
@ -755,10 +852,20 @@ export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
process.env.DEFAULT_MODEL) || process.env.DEFAULT_MODEL) ||
fallbackModelID; fallbackModelID;
let serverSidePluginKeysSet = false;
const googleApiKey = process.env.GOOGLE_API_KEY;
const googleCSEId = process.env.GOOGLE_CSE_ID;
if (googleApiKey && googleCSEId) {
serverSidePluginKeysSet = true;
}
return { return {
props: { props: {
serverSideApiKeyIsSet: !!process.env.OPENAI_API_KEY, serverSideApiKeyIsSet: !!process.env.OPENAI_API_KEY,
defaultModelId, defaultModelId,
serverSidePluginKeysSet,
...(await serverSideTranslations(locale ?? 'en', [ ...(await serverSideTranslations(locale ?? 'en', [
'common', 'common',
'chat', 'chat',

14
types/google.ts Normal file
View File

@ -0,0 +1,14 @@
import { Message } from './chat';
export interface GoogleResponse {
message: Message;
}
export interface GoogleSource {
title: string;
link: string;
displayLink: string;
snippet: string;
image: string;
text: string;
}

39
types/plugin.ts Normal file
View File

@ -0,0 +1,39 @@
import { KeyValuePair } from './data';
export interface Plugin {
id: PluginID;
name: PluginName;
requiredKeys: KeyValuePair[];
}
export interface PluginKey {
pluginId: PluginID;
requiredKeys: KeyValuePair[];
}
export enum PluginID {
GOOGLE_SEARCH = 'google-search',
}
export enum PluginName {
GOOGLE_SEARCH = 'Google Search',
}
export const Plugins: Record<PluginID, Plugin> = {
[PluginID.GOOGLE_SEARCH]: {
id: PluginID.GOOGLE_SEARCH,
name: PluginName.GOOGLE_SEARCH,
requiredKeys: [
{
key: 'GOOGLE_API_KEY',
value: '',
},
{
key: 'GOOGLE_CSE_ID',
value: '',
},
],
},
};
export const PluginList = Object.values(Plugins);

View File

@ -1,5 +1,6 @@
import { Conversation } from './chat'; import { Conversation } from './chat';
import { Folder } from './folder'; import { Folder } from './folder';
import { PluginKey } from './plugin';
import { Prompt } from './prompt'; import { Prompt } from './prompt';
// keep track of local storage schema // keep track of local storage schema
@ -15,4 +16,6 @@ export interface LocalStorage {
// added showChatbar and showPromptbar (3/26/23) // added showChatbar and showPromptbar (3/26/23)
showChatbar: boolean; showChatbar: boolean;
showPromptbar: boolean; showPromptbar: boolean;
// added plugin keys (4/3/23)
pluginKeys: PluginKey[];
} }

13
utils/app/api.ts Normal file
View File

@ -0,0 +1,13 @@
import { Plugin, PluginID } from '@/types/plugin';
export const getEndpoint = (plugin: Plugin | null) => {
if (!plugin) {
return 'api/chat';
}
if (plugin.id === PluginID.GOOGLE_SEARCH) {
return 'api/google';
}
return 'api/chat';
};

9
utils/server/google.ts Normal file
View File

@ -0,0 +1,9 @@
export const cleanSourceText = (text: string) => {
return text
.trim()
.replace(/(\n){4,}/g, '\n\n\n')
.replace(/\n\n/g, ' ')
.replace(/ {3,}/g, ' ')
.replace(/\t/g, '')
.replace(/\n+(\s*\n)*/g, '\n');
};