📂 feat: RAG Improvements (#2169)

* feat: new vector file processing strategy

* chore: remove unused client files

* chore: remove more unused client files

* chore: remove more unused client files and move used to new dir

* chore(DataIcon): add className

* WIP: Model Endpoint Settings Update, draft additional context settings

* feat: improve parsing for augmented prompt, add full context option

* chore: remove volume mounting from rag.yml as no longer necessary
This commit is contained in:
Danny Avila 2024-03-22 19:07:08 -04:00 committed by GitHub
parent f427ad792a
commit 45a95acec2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 715 additions and 2046 deletions

View file

@ -1,80 +0,0 @@
import { useState } from 'react';
import { Settings } from 'lucide-react';
import { alternateName } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { DropdownMenuRadioItem } from '~/components';
import { SetKeyDialog } from '../SetKeyDialog';
import { cn, getEndpointField } from '~/utils';
import { Icon } from '~/components/Endpoints';
import { useLocalize } from '~/hooks';
export default function ModelItem({
endpoint,
value,
isSelected,
}: {
endpoint: string;
value: string;
isSelected: boolean;
}) {
const [isDialogOpen, setDialogOpen] = useState(false);
const { data: endpointsConfig } = useGetEndpointsQuery();
const icon = Icon({
size: 20,
endpoint,
error: false,
className: 'mr-2',
message: false,
isCreatedByUser: false,
});
const userProvidesKey: boolean | null | undefined = getEndpointField(
endpointsConfig,
endpoint,
'userProvide',
);
const localize = useLocalize();
// regular model
return (
<>
<DropdownMenuRadioItem
value={value}
className={cn(
'group dark:font-semibold dark:text-gray-200 dark:hover:bg-gray-800',
isSelected ? 'active bg-gray-50 dark:bg-gray-800' : '',
)}
id={endpoint}
data-testid={`endpoint-item-${endpoint}`}
>
{icon}
{alternateName[endpoint] || endpoint}
{endpoint === 'gptPlugins' && (
<span className="py-0.25 ml-1 rounded bg-blue-200 px-1 text-[10px] font-semibold text-[#4559A4]">
Beta
</span>
)}
<div className="flex w-4 flex-1" />
{userProvidesKey ? (
<button
className={cn(
'invisible m-0 mr-1 flex-initial rounded-md p-0 text-xs font-medium text-gray-400 hover:text-gray-700 group-hover:visible dark:font-normal dark:text-gray-400 dark:hover:text-gray-200',
isSelected ? 'visible text-gray-700 dark:text-gray-200' : '',
)}
onClick={(e) => {
e.preventDefault();
setDialogOpen(true);
}}
>
<Settings className="mr-1 inline-block w-[16px] items-center stroke-1" />
{localize('com_endpoint_config_key')}
</button>
) : null}
</DropdownMenuRadioItem>
{userProvidesKey && (
<SetKeyDialog open={isDialogOpen} onOpenChange={setDialogOpen} endpoint={endpoint} />
)}
</>
);
}

View file

@ -1,22 +0,0 @@
import EndpointItem from './EndpointItem';
interface EndpointItemsProps {
endpoints: string[];
onSelect: (endpoint: string) => void;
selectedEndpoint: string;
}
export default function EndpointItems({ endpoints, selectedEndpoint }: EndpointItemsProps) {
return (
<>
{endpoints.map((endpoint) => (
<EndpointItem
isSelected={selectedEndpoint === endpoint}
key={endpoint}
value={endpoint}
endpoint={endpoint}
/>
))}
</>
);
}

View file

@ -1,275 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
import {
useDeletePresetMutation,
useCreatePresetMutation,
useGetEndpointsQuery,
} from 'librechat-data-provider/react-query';
import { Icon } from '~/components/Endpoints';
import EndpointItems from './EndpointItems';
import PresetItems from './PresetItems';
import FileUpload from './FileUpload';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
Dialog,
DialogTrigger,
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
} from '~/components/ui/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { cn, cleanupPreset, mapEndpoints } from '~/utils';
import { useLocalize, useLocalStorage, useConversation, useDefaultConvo } from '~/hooks';
import store from '~/store';
export default function NewConversationMenu() {
const localize = useLocalize();
const getDefaultConversation = useDefaultConvo();
const [menuOpen, setMenuOpen] = useState(false);
const [showPresets, setShowPresets] = useState(true);
const [showEndpoints, setShowEndpoints] = useState(true);
const [conversation, setConversation] = useRecoilState(store.conversation) ?? {};
const [messages, setMessages] = useRecoilState(store.messages);
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
const [presets, setPresets] = useRecoilState(store.presets);
const modularEndpoints = new Set(['gptPlugins', 'anthropic', 'google', 'openAI']);
const { endpoint } = conversation;
const { newConversation } = useConversation();
const deletePresetsMutation = useDeletePresetMutation();
const createPresetMutation = useCreatePresetMutation();
const importPreset = (jsonData) => {
createPresetMutation.mutate(
{ ...jsonData },
{
onSuccess: (data) => {
setPresets(data);
},
onError: (error) => {
console.error('Error uploading the preset:', error);
},
},
);
};
const onFileSelected = (jsonData) => {
const jsonPreset = { ...cleanupPreset({ preset: jsonData }), presetId: null };
importPreset(jsonPreset);
};
// save states to localStorage
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [lastModel, setLastModel] = useLocalStorage('lastSelectedModel', {});
const setLastConvo = useLocalStorage('lastConversationSetup', {})[1];
const [lastBingSettings, setLastBingSettings] = useLocalStorage('lastBingSettings', {});
useEffect(() => {
if (endpoint && endpoint !== 'bingAI') {
const lastModelUpdate = { ...lastModel, [endpoint]: conversation?.model };
if (endpoint === 'gptPlugins') {
lastModelUpdate.secondaryModel = conversation.agentOptions.model;
}
setLastModel(lastModelUpdate);
} else if (endpoint === 'bingAI') {
const { jailbreak, toneStyle } = conversation;
setLastBingSettings({ ...lastBingSettings, jailbreak, toneStyle });
}
setLastConvo(conversation);
}, [conversation]);
// set the current model
const onSelectEndpoint = (newEndpoint) => {
setMenuOpen(false);
if (!newEndpoint) {
return;
} else {
newConversation(null, { endpoint: newEndpoint });
}
};
// set the current model
const isModular = modularEndpoints.has(endpoint);
const onSelectPreset = (newPreset) => {
setMenuOpen(false);
if (!newPreset) {
return;
}
if (
isModular &&
modularEndpoints.has(newPreset?.endpoint) &&
endpoint === newPreset?.endpoint
) {
const currentConvo = getDefaultConversation({
conversation,
preset: newPreset,
});
setConversation(currentConvo);
setMessages(messages);
return;
}
newConversation({}, newPreset);
};
const clearAllPresets = () => {
deletePresetsMutation.mutate({ arg: {} });
};
const onDeletePreset = (preset) => {
deletePresetsMutation.mutate({ arg: preset });
};
const icon = Icon({
size: 32,
...conversation,
error: false,
button: true,
});
const onOpenChange = (open) => {
setMenuOpen(open);
if (newUser) {
setNewUser(false);
}
};
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<Dialog className="z-[100]">
<DropdownMenu open={menuOpen} onOpenChange={onOpenChange}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
id="new-conversation-menu"
data-testid="new-conversation-menu"
variant="outline"
className={
'group relative mb-[-12px] ml-1 mt-[-8px] items-center rounded-md border-0 p-1 outline-none focus:ring-0 focus:ring-offset-0 dark:data-[state=open]:bg-opacity-50 md:left-1 md:ml-0 md:ml-[-12px] md:pl-1'
}
>
{icon}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent forceMount={newUser} sideOffset={5}>
{localize('com_endpoint_open_menu')}
</TooltipContent>
<DropdownMenuContent
className="z-[100] w-[375px] dark:bg-gray-800 md:w-96"
onCloseAutoFocus={(event) => event.preventDefault()}
side="top"
>
<DropdownMenuLabel
className="cursor-pointer dark:text-gray-300"
onClick={() => setShowEndpoints((prev) => !prev)}
>
{showEndpoints ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '}
{localize('com_endpoint')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={endpoint}
onValueChange={onSelectEndpoint}
className="flex flex-col gap-1 overflow-y-auto"
>
{showEndpoints &&
(availableEndpoints.length ? (
<EndpointItems
selectedEndpoint={endpoint}
endpoints={availableEndpoints}
onSelect={onSelectEndpoint}
/>
) : (
<DropdownMenuLabel className="dark:text-gray-300">
{localize('com_endpoint_not_available')}
</DropdownMenuLabel>
))}
</DropdownMenuRadioGroup>
<div className="mt-2 w-full" />
<DropdownMenuLabel className="flex items-center dark:text-gray-300">
<span
className="mr-auto cursor-pointer "
onClick={() => setShowPresets((prev) => !prev)}
>
{showPresets ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '}
{localize('com_endpoint_presets')}
</span>
<FileUpload onFileSelected={onFileSelected} />
<Dialog>
<DialogTrigger asChild>
<label
htmlFor="file-upload"
className="mr-1 flex h-[32px] h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-500"
>
<svg
width="24"
height="24"
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className="mr-1 flex w-[22px] items-center"
>
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
</svg>
{localize('com_ui_clear')} {localize('com_ui_all')}
</label>
</DialogTrigger>
<DialogTemplate
title={`${localize('com_ui_clear')} ${localize('com_endpoint_presets')}`}
description={localize('com_endpoint_presets_clear_warning')}
selection={{
selectHandler: clearAllPresets,
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_clear'),
}}
className="max-w-[500px]"
/>
</Dialog>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
onValueChange={onSelectPreset}
className={cn(
'overflow-y-auto overflow-x-hidden',
showEndpoints ? 'max-h-[210px]' : 'max-h-[315px]',
)}
>
{showPresets &&
(presets.length ? (
<PresetItems
presets={presets}
onSelect={onSelectPreset}
onDeletePreset={onDeletePreset}
/>
) : (
<DropdownMenuLabel className="dark:text-gray-300">
{localize('com_endpoint_no_presets')}
</DropdownMenuLabel>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -1,88 +0,0 @@
import React, { useState } from 'react';
import { FileUp } from 'lucide-react';
import { cn } from '~/utils/';
import { useLocalize } from '~/hooks';
type FileUploadProps = {
onFileSelected: (jsonData: Record<string, unknown>) => void;
className?: string;
containerClassName?: string;
successText?: string;
invalidText?: string;
validator?: ((data: Record<string, unknown>) => boolean) | null;
text?: string;
id?: string;
};
const FileUpload: React.FC<FileUploadProps> = ({
onFileSelected,
className = '',
containerClassName = '',
successText = null,
invalidText = null,
validator = null,
text = null,
id = '1',
}) => {
const [statusColor, setStatusColor] = useState<string>('text-gray-600');
const [status, setStatus] = useState<null | string>(null);
const localize = useLocalize();
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const jsonData = JSON.parse(e.target?.result as string);
if (validator && !validator(jsonData)) {
setStatus('invalid');
setStatusColor('text-red-600');
return;
}
if (validator) {
setStatus('success');
setStatusColor('text-green-500 dark:text-green-500');
}
onFileSelected(jsonData);
};
reader.readAsText(file);
};
let statusText: string;
if (!status) {
statusText = text ?? localize('com_endpoint_import');
} else if (status === 'success') {
statusText = successText ?? localize('com_ui_upload_success');
} else {
statusText = invalidText ?? localize('com_ui_upload_invalid');
}
return (
<label
htmlFor={`file-upload-${id}`}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}
>
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
<span className="flex text-xs ">{statusText}</span>
<input
id={`file-upload-${id}`}
value=""
type="file"
className={cn('hidden ', className)}
accept=".json"
onChange={handleFileChange}
/>
</label>
);
};
export default FileUpload;

View file

@ -1,101 +0,0 @@
import type { TPresetItemProps } from '~/common';
import type { TPreset } from 'librechat-data-provider';
import { EModelEndpoint } from 'librechat-data-provider';
import { DropdownMenuRadioItem, EditIcon, TrashIcon } from '~/components';
import { Icon } from '~/components/Endpoints';
export default function PresetItem({
preset = {} as TPreset,
value,
onChangePreset,
onDeletePreset,
}: TPresetItemProps) {
const { endpoint } = preset;
const icon = Icon({
size: 20,
endpoint: preset?.endpoint,
model: preset?.model,
error: false,
className: 'mr-2',
isCreatedByUser: false,
});
const getPresetTitle = () => {
let _title = `${endpoint}`;
const { chatGptLabel, modelLabel, model, jailbreak, toneStyle } = preset;
if (endpoint === EModelEndpoint.azureOpenAI || endpoint === EModelEndpoint.openAI) {
if (model) {
_title += `: ${model}`;
}
if (chatGptLabel) {
_title += ` as ${chatGptLabel}`;
}
} else if (endpoint === EModelEndpoint.google) {
if (model) {
_title += `: ${model}`;
}
if (modelLabel) {
_title += ` as ${modelLabel}`;
}
} else if (endpoint === EModelEndpoint.bingAI) {
if (toneStyle) {
_title += `: ${toneStyle}`;
}
if (jailbreak) {
_title += ' as Sydney';
}
} else if (endpoint === EModelEndpoint.chatGPTBrowser) {
if (model) {
_title += `: ${model}`;
}
} else if (endpoint === EModelEndpoint.gptPlugins) {
if (model) {
_title += `: ${model}`;
}
} else if (endpoint === null) {
null;
} else {
null;
}
return _title;
};
// regular model
return (
<DropdownMenuRadioItem
/* @ts-ignore, value can be an object as well */
value={value}
className="group flex h-10 max-h-[44px] flex-row justify-between dark:font-semibold dark:text-gray-200 dark:hover:bg-gray-800 sm:h-auto"
>
<div className="flex items-center justify-start">
{icon}
<small className="text-[11px]">{preset?.title}</small>
<small className="invisible ml-1 flex w-0 flex-shrink text-[10px] sm:visible sm:w-auto">
({getPresetTitle()})
</small>
</div>
<div className="flex h-full items-center justify-end">
<button
className="m-0 mr-1 h-full rounded-md px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:p-2 sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
onChangePreset(preset);
}}
>
<EditIcon />
</button>
<button
className="m-0 h-full rounded-md px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:p-2 sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
onDeletePreset(preset);
}}
>
<TrashIcon />
</button>
</div>
</DropdownMenuRadioItem>
);
}

View file

@ -1,20 +0,0 @@
import React from 'react';
import PresetItem from './PresetItem';
import type { TPreset } from 'librechat-data-provider';
export default function PresetItems({ presets, onSelect, onChangePreset, onDeletePreset }) {
return (
<>
{presets.map((preset: TPreset) => (
<PresetItem
key={preset?.presetId ?? Math.random()}
value={preset}
onSelect={onSelect}
onChangePreset={onChangePreset}
onDeletePreset={onDeletePreset}
preset={preset}
/>
))}
</>
);
}

View file

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

View file

@ -1,155 +0,0 @@
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
import { PluginStoreDialog } from '~/components';
import {
PopoverButtons,
EndpointSettings,
SaveAsPresetDialog,
EndpointOptionsPopover,
} from '~/components/Endpoints';
import { Button } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import { useSetOptions } from '~/hooks';
import { ModelSelect } from './ModelSelect';
import { GenerationButtons } from './Generations';
import store from '~/store';
export default function OptionsBar() {
const conversation = useRecoilValue(store.conversation);
const messagesTree = useRecoilValue(store.messagesTree);
const latestMessage = useRecoilValue(store.latestMessage);
const setShowBingToneSetting = useSetRecoilState(store.showBingToneSetting);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
);
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPopover, setShowPopover] = useRecoilState(store.showPopover);
const [opacityClass, setOpacityClass] = useState('full-opacity');
const { setOption } = useSetOptions();
const { endpoint, conversationId, jailbreak } = conversation ?? {};
const altConditions: { [key: string]: boolean } = {
bingAI: !!(latestMessage && conversation?.jailbreak && endpoint === 'bingAI'),
};
const altSettings: { [key: string]: () => void } = {
bingAI: () => setShowBingToneSetting((prev) => !prev),
};
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
[EModelEndpoint.chatGPTBrowser]: true,
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
}),
[jailbreak, conversationId],
);
useEffect(() => {
if (showPopover) {
return;
} else if (messagesTree && messagesTree.length >= 1) {
setOpacityClass('show');
} else {
setOpacityClass('full-opacity');
}
}, [messagesTree, showPopover]);
useEffect(() => {
if (endpoint && noSettings[endpoint]) {
setShowPopover(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endpoint, noSettings]);
const saveAsPreset = () => {
setSaveAsDialogShow(true);
};
if (!endpoint) {
return null;
}
const triggerAdvancedMode = altConditions[endpoint]
? altSettings[endpoint]
: () => setShowPopover((prev) => !prev);
return (
<div className="relative py-2 last:mb-2 md:mx-4 md:mb-[-16px] md:py-4 md:pt-2 md:last:mb-6 lg:mx-auto lg:mb-[-32px] lg:max-w-2xl lg:pt-6 xl:max-w-3xl">
<GenerationButtons
endpoint={endpoint}
showPopover={showPopover}
opacityClass={opacityClass}
/>
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div
className={cn(
'options-bar z-[61] flex w-full flex-wrap items-center justify-center gap-2',
showPopover ? '' : opacityClass,
)}
onMouseEnter={() => {
if (showPopover) {
return;
}
setOpacityClass('full-opacity');
}}
onMouseLeave={() => {
if (showPopover) {
return;
}
if (!messagesTree || messagesTree.length === 0) {
return;
}
setOpacityClass('show');
}}
onFocus={() => {
if (showPopover) {
return;
}
setOpacityClass('full-opacity');
}}
onBlur={() => {
if (showPopover) {
return;
}
if (!messagesTree || messagesTree.length === 0) {
return;
}
setOpacityClass('show');
}}
>
<ModelSelect conversation={conversation} setOption={setOption} />
{!noSettings[endpoint] && (
<Button
type="button"
className={cn(
cardStyle,
'min-w-4 z-50 flex h-[40px] flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
)}
onClick={triggerAdvancedMode}
>
<Settings2 className="w-4 text-gray-600 dark:text-white" />
</Button>
)}
</div>
<EndpointOptionsPopover
visible={showPopover}
saveAsPreset={saveAsPreset}
closePopover={() => setShowPopover(false)}
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
>
<div className="px-4 py-4">
<EndpointSettings conversation={conversation} setOption={setOption} />
</div>
</EndpointOptionsPopover>
<SaveAsPresetDialog
open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow}
preset={tPresetSchema.parse({ ...conversation })}
/>
<PluginStoreDialog isOpen={showPluginStoreDialog} setIsOpen={setShowPluginStoreDialog} />
</span>
</div>
);
}

View file

@ -2,7 +2,7 @@ import React from 'react';
import { object, string } from 'zod';
import { AuthKeys } from 'librechat-data-provider';
import type { TConfigProps } from '~/common';
import FileUpload from '~/components/Input/EndpointMenu/FileUpload';
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
import { useLocalize, useMultipleKeys } from '~/hooks';
import InputWithLabel from './InputWithLabel';
import { Label } from '~/components/ui';

View file

@ -1,137 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { StopGeneratingIcon } from '~/components';
import { Settings } from 'lucide-react';
import { SetKeyDialog } from './SetKeyDialog';
import { useUserKey, useLocalize, useMediaQuery } from '~/hooks';
import { SendMessageIcon } from '~/components/svg';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
export default function SubmitButton({
conversation,
submitMessage,
handleStopGenerating,
disabled,
isSubmitting,
userProvidesKey,
hasText,
}) {
const { endpoint } = conversation;
const [isDialogOpen, setDialogOpen] = useState(false);
const { checkExpiry } = useUserKey(endpoint);
const [isKeyProvided, setKeyProvided] = useState(userProvidesKey ? checkExpiry() : true);
const isKeyActive = checkExpiry();
const localize = useLocalize();
const dots = ['·', '··', '···'];
const [dotIndex, setDotIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDotIndex((prevDotIndex) => (prevDotIndex + 1) % dots.length);
}, 500);
return () => clearInterval(interval);
}, [dots.length]);
useEffect(() => {
if (userProvidesKey) {
setKeyProvided(isKeyActive);
} else {
setKeyProvided(true);
}
}, [checkExpiry, endpoint, userProvidesKey, isKeyActive]);
const clickHandler = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
submitMessage();
},
[submitMessage],
);
const [isSquareGreen, setIsSquareGreen] = useState(false);
const setKey = useCallback(() => {
setDialogOpen(true);
}, []);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const iconContainerClass = `m-1 mr-0 rounded-md pb-[5px] pl-[6px] pr-[4px] pt-[5px] ${
hasText ? (isSquareGreen ? 'bg-green-500' : '') : ''
} group-hover:bg-19C37D group-disabled:hover:bg-transparent dark:${
hasText ? (isSquareGreen ? 'bg-green-500' : '') : ''
} dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent`;
useEffect(() => {
setIsSquareGreen(hasText);
}, [hasText]);
if (isSubmitting && isSmallScreen) {
return (
<button onClick={handleStopGenerating} type="button">
<div className="m-1 mr-0 rounded-md p-2 pb-[10px] pt-[10px] group-hover:bg-gray-200 group-disabled:hover:bg-transparent dark:group-hover:bg-gray-800 dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent">
<StopGeneratingIcon />
</div>
</button>
);
} else if (isSubmitting) {
return (
<div className="relative flex h-full">
<div
className="absolute text-2xl"
style={{ top: '50%', transform: 'translateY(-20%) translateX(-33px)' }}
>
{dots[dotIndex]}
</div>
</div>
);
} else if (!isKeyProvided) {
return (
<>
<button
onClick={setKey}
type="button"
className="group absolute bottom-0 right-0 z-[101] flex h-[100%] w-auto items-center justify-center bg-transparent pr-1 text-gray-500"
>
<div className="flex items-center justify-center rounded-md text-xs group-hover:bg-gray-200 group-disabled:hover:bg-transparent dark:group-hover:bg-gray-800 dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent">
<div className="m-0 mr-0 flex items-center justify-center rounded-md p-2 sm:p-2">
<Settings className="mr-1 inline-block h-auto w-[18px]" />
{localize('com_endpoint_config_key_name_placeholder')}
</div>
</div>
</button>
{userProvidesKey && (
<SetKeyDialog open={isDialogOpen} onOpenChange={setDialogOpen} endpoint={endpoint} />
)}
</>
);
} else {
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={clickHandler}
disabled={disabled}
data-testid="submit-button"
className="group absolute bottom-0 right-0 z-[101] flex h-[100%] w-[50px] items-center justify-center bg-transparent p-1 text-gray-500"
>
<div className={iconContainerClass}>
{hasText ? (
<div className="bg-19C37D flex h-[24px] w-[24px] items-center justify-center rounded-full text-white">
<SendMessageIcon />
</div>
) : (
<SendMessageIcon />
)}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={-5}>
{localize('com_nav_send_message')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
}

View file

@ -1,212 +0,0 @@
import TextareaAutosize from 'react-textarea-autosize';
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';
import React, { useEffect, useContext, useRef, useState, useCallback } from 'react';
import { EndpointMenu } from './EndpointMenu';
import SubmitButton from './SubmitButton';
import OptionsBar from './OptionsBar';
import Footer from './Footer';
import { useMessageHandler, ThemeContext } from '~/hooks';
import { cn, getEndpointField } from '~/utils';
import store from '~/store';
interface TextChatProps {
isSearchView?: boolean;
}
export default function TextChat({ isSearchView = false }: TextChatProps) {
const { ask, isSubmitting, handleStopGenerating, latestMessage, endpointsConfig } =
useMessageHandler();
const conversation = useRecoilValue(store.conversation);
const setShowBingToneSetting = useSetRecoilState(store.showBingToneSetting);
const [text, setText] = useRecoilState(store.text);
const { theme } = useContext(ThemeContext);
const isComposing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [hasText, setHasText] = useState(false);
// TODO: do we need this?
const disabled = false;
const isNotAppendable = (latestMessage?.unfinished && !isSubmitting) || latestMessage?.error;
const { conversationId, jailbreak } = conversation || {};
// auto focus to input, when entering a conversation.
useEffect(() => {
if (!conversationId) {
return;
}
// Prevents Settings from not showing on a new conversation, also prevents showing toneStyle change without jailbreak
if (conversationId === 'new' || !jailbreak) {
setShowBingToneSetting(false);
}
if (conversationId !== 'search') {
inputRef.current?.focus();
}
// setShowBingToneSetting is a recoil setter, so it doesn't need to be in the dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, jailbreak]);
useEffect(() => {
const timeoutId = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timeoutId);
}, [isSubmitting]);
const submitMessage = () => {
ask({ text });
setText('');
setHasText(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isSubmitting) {
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
if (e.key === 'Enter' && !e.shiftKey && !isComposing?.current) {
submitMessage();
}
};
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.keyCode === 8 && e.currentTarget.value.trim() === '') {
setText(e.currentTarget.value);
}
if (e.key === 'Enter' && e.shiftKey) {
return console.log('Enter + Shift');
}
if (isSubmitting) {
return;
}
};
const handleCompositionStart = () => {
isComposing.current = true;
};
const handleCompositionEnd = () => {
isComposing.current = false;
};
const changeHandler = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.target;
setText(value);
updateHasText(value);
};
const updateHasText = useCallback(
(text: string) => {
setHasText(!!text.trim() || !!latestMessage?.error);
},
[setHasText, latestMessage],
);
useEffect(() => {
updateHasText(text);
}, [text, latestMessage, updateHasText]);
const getPlaceholderText = () => {
if (isSearchView) {
return 'Click a message title to open its conversation.';
}
if (disabled) {
return 'Choose another model or customize GPT again';
}
if (isNotAppendable) {
return 'Edit your message or Regenerate.';
}
return '';
};
if (isSearchView) {
return <></>;
}
let isDark = theme === 'dark';
if (theme === 'system') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return (
<>
<div
className="no-gradient-sm fixed bottom-0 left-0 w-full pt-6 sm:bg-gradient-to-b md:absolute md:w-[calc(100%-.5rem)]"
style={{
background: `linear-gradient(to bottom,
${isDark ? 'rgba(23, 23, 23, 0)' : 'rgba(255, 255, 255, 0)'},
${isDark ? 'rgba(23, 23, 23, 0.08)' : 'rgba(255, 255, 255, 0.08)'},
${isDark ? 'rgba(23, 23, 23, 0.38)' : 'rgba(255, 255, 255, 0.38)'},
${isDark ? 'rgba(23, 23, 23, 1)' : 'rgba(255, 255, 255, 1)'},
${isDark ? '#171717' : '#ffffff'})`,
}}
>
<OptionsBar />
<div className="input-panel md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient relative w-full border-t bg-white py-2 dark:border-white/20 dark:bg-gray-800 md:border-t-0 md:border-transparent md:bg-transparent md:dark:border-transparent md:dark:bg-transparent">
<form className="stretch z-[60] mx-2 flex flex-row gap-3 last:mb-2 md:mx-4 md:pt-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
<div className="relative flex h-full flex-1 md:flex-col">
<div
className={cn(
'relative flex flex-grow flex-row rounded-xl border border-black/10 py-[10px] md:py-4 md:pl-4',
'shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]',
'dark:border-gray-800/50 dark:text-white',
disabled ? 'bg-gray-200 dark:bg-gray-800' : 'bg-white dark:bg-gray-700',
)}
>
<EndpointMenu />
<TextareaAutosize
// set test id for e2e testing
data-testid="text-input"
tabIndex={0}
autoFocus
ref={inputRef}
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
rows={1}
value={disabled || isNotAppendable ? '' : text}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onChange={changeHandler}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={getPlaceholderText()}
disabled={disabled || isNotAppendable}
className="m-0 flex h-auto max-h-52 flex-1 resize-none overflow-auto border-0 bg-transparent p-0 pl-2 pr-12 leading-6 placeholder:text-sm placeholder:text-gray-600 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder:text-gray-500 md:pl-2"
/>
<SubmitButton
conversation={conversation}
submitMessage={submitMessage}
handleStopGenerating={handleStopGenerating}
disabled={disabled || isNotAppendable}
isSubmitting={isSubmitting}
userProvidesKey={
conversation?.endpoint
? getEndpointField(endpointsConfig, conversation.endpoint, 'userProvide')
: undefined
}
hasText={hasText}
/>
</div>
</div>
</form>
<Footer />
</div>
</div>
</>
);
}