feat: Stop Sequences for Conversations & Presets (#2536)

* feat: `stop` conversation parameter

* feat: Tag primitive

* feat: dynamic tags

* refactor: update tag styling

* feat: add stop sequences to OpenAI settings

* fix(Presentation): prevent `SidePanel` re-renders that flicker side panel

* refactor: use stop placeholder

* feat: type and schema update for `stop` and `TPreset` in generation param related types

* refactor: pass conversation to dynamic settings

* refactor(OpenAIClient): remove default handling for `modelOptions.stop`

* docs: fix Google AI Setup formatting

* feat: current_model

* docs: WIP update

* fix(ChatRoute): prevent default preset override before `hasSetConversation.current` becomes true by including latest conversation state as template

* docs: update docs with more info on `stop`

* chore: bump config_version

* refactor: CURRENT_MODEL handling
This commit is contained in:
Danny Avila 2024-04-25 11:40:17 -04:00 committed by GitHub
parent 4121818124
commit 099aa9dead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 690 additions and 93 deletions

View file

@ -98,6 +98,7 @@ export default function HeaderOptions() {
>
<div className="px-4 py-4">
<EndpointSettings
className="[&::-webkit-scrollbar]:w-2"
conversation={conversation}
setOption={setOption}
isMultiChat={true}

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FileSources } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
@ -49,12 +49,16 @@ export default function Presentation({
}, [mutateAsync]);
const isActive = canDrop && isOver;
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
const defaultLayout = resizableLayout ? JSON.parse(resizableLayout) : undefined;
const defaultCollapsed = collapsedPanels ? JSON.parse(collapsedPanels) : undefined;
const fullCollapse = localStorage.getItem('fullPanelCollapse') === 'true';
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
return resizableLayout ? JSON.parse(resizableLayout) : undefined;
}, []);
const defaultCollapsed = useMemo(() => {
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
return collapsedPanels ? JSON.parse(collapsedPanels) : undefined;
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
const layout = () => (
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">

View file

@ -27,9 +27,7 @@ export default function Settings({
if (OptionComponent) {
return (
<div
className={cn('hide-scrollbar h-[500px] overflow-y-auto md:mb-2 md:h-[350px]', className)}
>
<div className={cn('h-[500px] overflow-y-auto md:mb-2 md:h-[350px]', className)}>
<OptionComponent
conversation={conversation}
setOption={setOption}

View file

@ -1,5 +1,12 @@
import { useMemo } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { ImageDetail, imageDetailNumeric, imageDetailValue } from 'librechat-data-provider';
import * as InputNumberPrimitive from 'rc-input-number';
import {
EModelEndpoint,
ImageDetail,
imageDetailNumeric,
imageDetailValue,
} from 'librechat-data-provider';
import {
Input,
Label,
@ -10,12 +17,15 @@ import {
SelectDropDown,
HoverCardTrigger,
} from '~/components/ui';
import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/';
import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils';
import { DynamicTags } from '~/components/SidePanel/Parameters';
import { useLocalize, useDebouncedInput } from '~/hooks';
import type { TModelSelectProps } from '~/common';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
type OnInputNumberChange = InputNumberPrimitive.InputNumberProps['onChange'];
export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) {
const localize = useLocalize();
const {
@ -62,6 +72,12 @@ export default function Settings({ conversation, setOption, models, readonly }:
initialValue: presP,
});
const optionEndpoint = useMemo(() => endpointType ?? endpoint, [endpoint, endpointType]);
const isOpenAI = useMemo(
() => optionEndpoint === EModelEndpoint.openAI || optionEndpoint === EModelEndpoint.azureOpenAI,
[optionEndpoint],
);
if (!conversation) {
return null;
}
@ -70,8 +86,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
const setResendFiles = setOption('resendFiles');
const setImageDetail = setOption('imageDetail');
const optionEndpoint = endpointType ?? endpoint;
return (
<div className="grid grid-cols-5 gap-6">
<div className="col-span-5 flex flex-col items-center justify-start gap-6 sm:col-span-3">
@ -120,6 +134,22 @@ export default function Settings({ conversation, setOption, models, readonly }:
)}
/>
</div>
<div className="grid w-full items-start gap-2">
<DynamicTags
settingKey="stop"
setOption={setOption}
label="com_endpoint_stop"
labelCode={true}
description="com_endpoint_openai_stop"
descriptionCode={true}
placeholder="com_endpoint_stop_placeholder"
placeholderCode={true}
descriptionSide="right"
maxTags={isOpenAI ? 4 : undefined}
conversation={conversation}
readonly={readonly}
/>
</div>
</div>
<div className="col-span-5 flex flex-col items-center justify-start gap-6 px-3 sm:col-span-2">
<HoverCard openDelay={300}>
@ -133,9 +163,10 @@ export default function Settings({ conversation, setOption, models, readonly }:
</Label>
<InputNumber
id="temp-int"
stringMode={false}
disabled={readonly}
value={temperatureValue as number}
onChange={setTemperature}
onChange={setTemperature as OnInputNumberChange}
max={2}
min={0}
step={0.01}

View file

@ -20,9 +20,10 @@ function DynamicCheckbox({
showDefault = true,
labelCode,
descriptionCode,
conversation,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const { preset } = useChatContext();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
const selectedValue = useMemo(() => {

View file

@ -22,9 +22,10 @@ function DynamicDropdown({
showDefault = true,
labelCode,
descriptionCode,
conversation,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const { preset } = useChatContext();
const [inputValue, setInputValue] = useState<string | null>(null);
const selectedValue = useMemo(() => {

View file

@ -22,9 +22,10 @@ function DynamicInput({
labelCode,
descriptionCode,
placeholderCode,
conversation,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const { preset } = useChatContext();
const [setInputValue, inputValue] = useDebouncedInput<string | null>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,

View file

@ -23,9 +23,10 @@ function DynamicSlider({
includeInput = true,
labelCode,
descriptionCode,
conversation,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const { preset } = useChatContext();
const isEnum = useMemo(() => !range && options && options.length > 0, [options, range]);
const [setInputValue, inputValue] = useDebouncedInput<string | number>({

View file

@ -19,9 +19,10 @@ function DynamicSwitch({
showDefault = true,
labelCode,
descriptionCode,
conversation,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const { preset } = useChatContext();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
useParameterEffects({
preset,

View file

@ -0,0 +1,193 @@
// client/src/components/SidePanel/Parameters/DynamicTags.tsx
import { useState, useMemo, useCallback, useRef } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui';
import { useChatContext, useToastContext } from '~/Providers';
import { useLocalize, useParameterEffects } from '~/hooks';
import { cn, defaultTextProps } from '~/utils';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
function DynamicTags({
label,
settingKey,
defaultValue = [],
description,
columnSpan,
setOption,
optionType,
placeholder,
readonly = false,
showDefault = true,
labelCode,
descriptionCode,
placeholderCode,
descriptionSide = ESide.Left,
conversation,
minTags,
maxTags,
}: DynamicSettingProps) {
const localize = useLocalize();
const { preset } = useChatContext();
const { showToast } = useToastContext();
const inputRef = useRef<HTMLInputElement>(null);
const [tagText, setTagText] = useState<string>('');
const [tags, setTags] = useState<string[] | undefined>(
(defaultValue as string[] | undefined) ?? [],
);
const updateState = useCallback(
(update: string[]) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setTags(update);
return;
}
setOption(settingKey)(update);
},
[optionType, setOption, settingKey],
);
const onTagClick = useCallback(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const currentTags: string[] | undefined = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return tags;
}
if (!conversation?.[settingKey]) {
return defaultValue ?? [];
}
return conversation?.[settingKey];
}, [conversation, defaultValue, optionType, settingKey, tags]);
const onTagRemove = useCallback(
(indexToRemove: number) => {
if (!currentTags) {
return;
}
if (minTags && currentTags.length <= minTags) {
showToast({
message: localize('com_ui_min_tags', minTags + ''),
status: 'warning',
});
return;
}
const update = currentTags.filter((_, index) => index !== indexToRemove);
updateState(update);
},
[localize, minTags, currentTags, showToast, updateState],
);
const onTagAdd = useCallback(() => {
if (!tagText) {
return;
}
let update = [...(currentTags ?? []), tagText];
if (maxTags && update.length > maxTags) {
showToast({
message: localize('com_ui_max_tags', maxTags + ''),
status: 'warning',
});
update = update.slice(-maxTags);
}
updateState(update);
setTagText('');
}, [tagText, currentTags, updateState, maxTags, showToast, localize]);
useParameterEffects({
preset,
settingKey,
defaultValue: typeof defaultValue === 'undefined' ? [] : defaultValue,
inputValue: tags,
setInputValue: setTags,
preventDelayedUpdate: true,
conversation,
});
return (
<div
className={`flex flex-col items-center justify-start gap-6 ${
columnSpan ? `col-span-${columnSpan}` : 'col-span-full'
}`}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}{' '}
{showDefault && (
<small className="opacity-40">
(
{typeof defaultValue === 'undefined' || !(defaultValue as string)?.length
? localize('com_endpoint_default_blank')
: `${localize('com_endpoint_default')}: ${defaultValue}`}
)
</small>
)}
</Label>
</div>
<div>
<div className="bg-muted mb-2 flex flex-wrap gap-1 break-all rounded-lg">
{currentTags?.map((tag: string, index: number) => (
<Tag
key={`${tag}-${index}`}
label={tag}
onClick={onTagClick}
onRemove={() => {
onTagRemove(index);
if (inputRef.current) {
inputRef.current.focus();
}
}}
/>
))}
<Input
ref={inputRef}
id={`${settingKey}-dynamic-input`}
disabled={readonly}
value={tagText}
onKeyDown={(e) => {
if (!currentTags) {
return;
}
if (e.key === 'Backspace' && !tagText) {
onTagRemove(currentTags.length - 1);
}
if (e.key === 'Enter') {
onTagAdd();
}
}}
onChange={(e) => setTagText(e.target.value)}
placeholder={
placeholderCode ? localize(placeholder ?? '') || placeholder : placeholder
}
className={cn(defaultTextProps, 'flex h-10 max-h-10 px-3 py-2')}
/>
</div>
</div>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={descriptionSide as ESide}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicTags;

View file

@ -22,9 +22,10 @@ function DynamicTextarea({
labelCode,
descriptionCode,
placeholderCode,
conversation,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const { preset } = useChatContext();
const [setInputValue, inputValue] = useDebouncedInput<string | null>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,

View file

@ -5,12 +5,16 @@ import type {
SettingsConfiguration,
} from 'librechat-data-provider';
import { useSetIndexOptions } from '~/hooks';
import DynamicDropdown from './DynamicDropdown';
import DynamicCheckbox from './DynamicCheckbox';
import DynamicTextarea from './DynamicTextarea';
import DynamicSlider from './DynamicSlider';
import DynamicSwitch from './DynamicSwitch';
import DynamicInput from './DynamicInput';
import { useChatContext } from '~/Providers';
import {
DynamicDropdown,
DynamicCheckbox,
DynamicTextarea,
DynamicSlider,
DynamicSwitch,
DynamicInput,
DynamicTags,
} from './';
const settingsConfiguration: SettingsConfiguration = [
{
@ -129,6 +133,22 @@ const settingsConfiguration: SettingsConfiguration = [
showDefault: false,
columnSpan: 2,
},
{
key: 'stop',
label: 'com_endpoint_stop',
labelCode: true,
description: 'com_endpoint_openai_stop',
descriptionCode: true,
placeholder: 'com_endpoint_stop_placeholder',
placeholderCode: true,
type: 'array',
default: [],
component: 'tags',
optionType: 'conversation',
columnSpan: 4,
minTags: 1,
maxTags: 4,
},
];
const componentMapping: Record<ComponentTypes, React.ComponentType<DynamicSettingProps>> = {
@ -138,9 +158,11 @@ const componentMapping: Record<ComponentTypes, React.ComponentType<DynamicSettin
[ComponentTypes.Textarea]: DynamicTextarea,
[ComponentTypes.Input]: DynamicInput,
[ComponentTypes.Checkbox]: DynamicCheckbox,
[ComponentTypes.Tags]: DynamicTags,
};
export default function Parameters() {
const { conversation } = useChatContext();
const { setOption } = useSetIndexOptions();
const temperature = settingsConfiguration.find(
@ -173,6 +195,10 @@ export default function Parameters() {
const Input = componentMapping[chatGptLabel.component];
const { key: inputKey, default: inputDefault, ...inputSettings } = chatGptLabel;
const stop = settingsConfiguration.find((setting) => setting.key === 'stop') as SettingDefinition;
const Tags = componentMapping[stop.component];
const { key: stopKey, default: stopDefault, ...stopSettings } = stop;
return (
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="grid grid-cols-4 gap-6">
@ -184,30 +210,42 @@ export default function Parameters() {
defaultValue={inputDefault}
{...inputSettings}
setOption={setOption}
conversation={conversation}
/>
<Textarea
settingKey={textareaKey}
defaultValue={textareaDefault}
{...textareaSettings}
setOption={setOption}
conversation={conversation}
/>
<TempComponent
settingKey={temp}
defaultValue={tempDefault}
{...tempSettings}
setOption={setOption}
conversation={conversation}
/>
<Switch
settingKey={switchKey}
defaultValue={switchDefault}
{...switchSettings}
setOption={setOption}
conversation={conversation}
/>
<DetailComponent
settingKey={detail}
defaultValue={detailDefault}
{...detailSettings}
setOption={setOption}
conversation={conversation}
/>
<Tags
settingKey={stopKey}
defaultValue={stopDefault}
{...stopSettings}
setOption={setOption}
conversation={conversation}
/>
</div>
</div>

View file

@ -0,0 +1,7 @@
export { default as DynamicDropdown } from './DynamicDropdown';
export { default as DynamicCheckbox } from './DynamicCheckbox';
export { default as DynamicTextarea } from './DynamicTextarea';
export { default as DynamicSlider } from './DynamicSlider';
export { default as DynamicSwitch } from './DynamicSwitch';
export { default as DynamicInput } from './DynamicInput';
export { default as DynamicTags } from './DynamicTags';

View file

@ -0,0 +1,43 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '~/utils';
type TagProps = React.ComponentPropsWithoutRef<'div'> & {
label: string;
labelClassName?: string;
CancelButton?: React.ReactNode;
onRemove: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const TagPrimitiveRoot = React.forwardRef<HTMLDivElement, TagProps>(
({ CancelButton, label, onRemove, className = '', labelClassName = '', ...props }, ref) => (
<div
ref={ref}
{...props}
className={cn(
'flex max-h-8 items-center overflow-y-hidden rounded rounded-3xl border-2 border-green-600 bg-green-600/20 text-sm text-xs text-white',
className,
)}
>
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>{label}</div>
{CancelButton ? (
CancelButton
) : (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(e);
}}
className="rounded-full bg-green-600/50"
aria-label={`Remove ${label}`}
>
<X className="m-[1.5px] p-1" />
</button>
)}
</div>
),
);
TagPrimitiveRoot.displayName = 'Tag';
export const Tag = React.memo(TagPrimitiveRoot);

View file

@ -16,6 +16,7 @@ export * from './Separator';
export * from './Switch';
export * from './Table';
export * from './Tabs';
export * from './Tag';
export * from './Templates';
export * from './Textarea';
export * from './TextareaAutosize';