🎛️ refactor: Add keyboard command toggles & only trigger for the 1st character (#3566)

* refactor: Improve handling of key up events in useHandleKeyUp hook to only trigger for first char and fix linter issues

* refactor: Update Beta component styling with theme twcss variables

* refactor: theming for Settings, add toggle enable/disable keyboard commands
This commit is contained in:
Danny Avila 2024-08-06 22:18:07 -04:00 committed by GitHub
parent 270c6d2350
commit 01a88991ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 224 additions and 64 deletions

View file

@ -1,17 +1,10 @@
import * as Tabs from '@radix-ui/react-tabs';
import { MessageSquare } from 'lucide-react';
import { MessageSquare, Command } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider';
import type { TDialogProps } from '~/common';
import {
Button,
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
import { General, Chat, Speech, Beta, Data, Account } from './SettingsTabs';
import { General, Chat, Speech, Beta, Commands, Data, Account } from './SettingsTabs';
import { useMediaQuery, useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -30,7 +23,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50 dark:bg-black/80" aria-hidden="true" />
<div className="fixed inset-0 bg-black opacity-50 dark:opacity-80" aria-hidden="true" />
</TransitionChild>
<TransitionChild
@ -49,19 +42,19 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
>
<DialogPanel
className={cn(
'overflow-hidden rounded-xl rounded-b-lg bg-white pb-6 shadow-2xl backdrop-blur-2xl animate-in dark:bg-gray-700 sm:rounded-lg md:min-h-[373px] md:w-[680px]',
'overflow-hidden rounded-xl rounded-b-lg bg-surface-tertiary-alt pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-lg md:min-h-[373px] md:w-[680px]',
)}
>
<DialogTitle
className="mb-3 flex items-center justify-between border-b border-black/10 p-6 pb-5 text-left dark:border-white/10"
className="mb-3 flex items-center justify-between border-b border-border-medium p-6 pb-5 text-left"
as="div"
>
<h2 className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
<h2 className="text-lg font-medium leading-6 text-text-primary">
{localize('com_nav_settings')}
</h2>
<button
type="button"
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 dark:data-[state=open]:bg-gray-800"
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-border-xheavy focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-surface-primary dark:focus:ring-offset-surface-primary"
onClick={() => onOpenChange(false)}
>
<svg
@ -74,7 +67,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5 text-black dark:text-white"
className="h-5 w-5 text-text-primary"
>
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
@ -94,17 +87,17 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
aria-orientation="horizontal"
className={cn(
'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
isSmallScreen ? 'flex-row rounded-lg bg-gray-200 p-1 dark:bg-gray-800' : '',
isSmallScreen ? 'flex-row rounded-lg bg-surface-secondary' : '',
)}
style={{ outline: 'none' }}
>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.GENERAL}
style={{ userSelect: 'none' }}
@ -113,12 +106,12 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
{localize('com_nav_setting_general')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.CHAT}
style={{ userSelect: 'none' }}
@ -127,12 +120,12 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
{localize('com_nav_setting_chat')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.BETA}
style={{ userSelect: 'none' }}
@ -141,12 +134,26 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
{localize('com_nav_setting_beta')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
? 'flex-1 items-center justify-center text-nowrap text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.COMMANDS}
style={{ userSelect: 'none' }}
>
<Command className="icon-sm" />
{localize('com_nav_commands')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.SPEECH}
style={{ userSelect: 'none' }}
@ -155,12 +162,12 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
{localize('com_nav_setting_speech')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.DATA}
style={{ userSelect: 'none' }}
@ -169,12 +176,12 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
{localize('com_nav_setting_data')}
</Tabs.Trigger>
<Tabs.Trigger
tabIndex={0}
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-primary',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
: 'bg-surface-tertiary-alt',
)}
value={SettingsTabValues.ACCOUNT}
style={{ userSelect: 'none' }}
@ -187,6 +194,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<General />
<Chat />
<Beta />
<Commands />
<Speech />
<Data />
<Account />

View file

@ -11,11 +11,11 @@ function Beta() {
role="tabpanel"
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<ModularChat />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<LaTeXParsing />
</div>
</div>

View file

@ -0,0 +1,26 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AtCommandSwitch() {
const [atCommand, setAtCommand] = useRecoilState<boolean>(store.atCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAtCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_at_command_description')}</div>
<Switch
id="atCommand"
checked={atCommand}
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="atCommand"
/>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import SlashCommandSwitch from './SlashCommandSwitch';
import PlusCommandSwitch from './PlusCommandSwitch';
import AtCommandSwitch from './AtCommandSwitch';
function Commands() {
return (
<Tabs.Content
value={SettingsTabValues.COMMANDS}
role="tabpanel"
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<AtCommandSwitch />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<PlusCommandSwitch />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<SlashCommandSwitch />
</div>
</div>
</Tabs.Content>
);
}
export default memo(Commands);

View file

@ -0,0 +1,26 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function PlusCommandSwitch() {
const [plusCommand, setPlusCommand] = useRecoilState<boolean>(store.plusCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setPlusCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_plus_command_description')}</div>
<Switch
id="plusCommand"
checked={plusCommand}
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="plusCommand"
/>
</div>
);
}

View file

@ -0,0 +1,26 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function SlashCommandSwitch() {
const [slashCommand, setSlashCommand] = useRecoilState<boolean>(store.slashCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSlashCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_slash_command_description')}</div>
<Switch
id="slashCommand"
checked={slashCommand}
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="slashCommand"
/>
</div>
);
}

View file

@ -3,6 +3,7 @@ export { default as Chat } from './Chat/Chat';
export { ClearChatsButton } from './General/General';
export { default as Data } from './Data/Data';
export { default as Beta } from './Beta/Beta';
export { default as Commands } from './Commands/Commands';
export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';
export { default as Speech } from './Speech/Speech';

View file

@ -1,5 +1,5 @@
import { useSetRecoilState } from 'recoil';
import { useCallback, useMemo } from 'react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import useHasAccess from '~/hooks/Roles/useHasAccess';
@ -20,20 +20,16 @@ const shouldTriggerCommand = (
commandChar: string,
) => {
const text = textAreaRef.current?.value;
if (!(text && text[text.length - 1] === commandChar)) {
if (typeof text !== 'string' || text.length === 0 || text[0] !== commandChar) {
return false;
}
const startPos = textAreaRef.current?.selectionStart;
if (!startPos) {
if (typeof startPos !== 'number') {
return false;
}
const isAtStart = startPos === 1;
const isPrecededBySpace = textAreaRef.current?.value.charAt(startPos - 2) === ' ';
const shouldTrigger = isAtStart || isPrecededBySpace;
return shouldTrigger;
return startPos === 1;
};
/**
@ -55,26 +51,32 @@ const useHandleKeyUp = ({
permission: Permissions.USE,
});
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
// Get the current state of command toggles
const atCommandEnabled = useRecoilValue(store.atCommand);
const plusCommandEnabled = useRecoilValue(store.plusCommand);
const slashCommandEnabled = useRecoilValue(store.slashCommand);
const handleAtCommand = useCallback(() => {
if (shouldTriggerCommand(textAreaRef, '@')) {
if (atCommandEnabled && shouldTriggerCommand(textAreaRef, '@')) {
setShowMentionPopover(true);
}
}, [textAreaRef, setShowMentionPopover]);
}, [textAreaRef, setShowMentionPopover, atCommandEnabled]);
const handlePlusCommand = useCallback(() => {
if (shouldTriggerCommand(textAreaRef, '+')) {
if (plusCommandEnabled && shouldTriggerCommand(textAreaRef, '+')) {
setShowPlusPopover(true);
}
}, [textAreaRef, setShowPlusPopover]);
}, [textAreaRef, setShowPlusPopover, plusCommandEnabled]);
const handlePromptsCommand = useCallback(() => {
if (!hasAccess) {
if (!hasAccess || !slashCommandEnabled) {
return;
}
if (shouldTriggerCommand(textAreaRef, '/')) {
setShowPromptsPopover(true);
}
}, [textAreaRef, hasAccess, setShowPromptsPopover]);
}, [textAreaRef, hasAccess, setShowPromptsPopover, slashCommandEnabled]);
const commandHandlers = useMemo(
() => ({
@ -91,18 +93,18 @@ const useHandleKeyUp = ({
const handleKeyUp = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const text = textAreaRef.current?.value;
if (!text) {
if (typeof text !== 'string' || text.length === 0) {
return;
}
if (invalidKeys[event.key]) {
if (invalidKeys[event.key as keyof typeof invalidKeys]) {
return;
}
const lastChar = text[text.length - 1];
const handler = commandHandlers[lastChar];
const firstChar = text[0];
const handler = commandHandlers[firstChar as keyof typeof commandHandlers];
if (handler) {
if (typeof handler === 'function') {
handler();
}
},

View file

@ -682,6 +682,18 @@ export default {
'This action will delete all cached TTS (Text-to-Speech) audio files stored on your device. Cached audio files are used to speed up playback of previously generated TTS audio, but they can consume storage space on your device.',
com_nav_info_bookmarks_rebuild:
'If the bookmark count is incorrect, please rebuild the bookmark information. The bookmark count will be recalculated and the data will be restored to its correct state.',
// Command Settings Tab
com_nav_commands: 'Commands',
com_nav_commands_tab: 'Command Settings',
com_nav_at_command: '@-Command',
com_nav_at_command_description:
'Toggle command "@" for switching endpoints, models, presets, etc.',
com_nav_plus_command: '+-Command',
com_nav_plus_command_description: 'Toggle command "+" for adding a multi-response setting',
com_nav_slash_command: '/-Command',
com_nav_slash_command_description: 'Toggle command "/" for selecting a prompt via keyboard',
com_nav_command_settings: 'Command Settings',
com_nav_command_settings_description: 'Customize which commands are available in the chat',
com_nav_setting_general: 'General',
com_nav_setting_chat: 'Chat',
com_nav_setting_beta: 'Beta features',

View file

@ -292,3 +292,23 @@
.scrollbar-gutter-stable {
scrollbar-gutter: stable;
}
/* Styles for Chrome scrollbar */
.chrome-scrollbar::-webkit-scrollbar {
width: 12px; /* Increase the width of the scrollbar */
}
.chrome-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2); /* Color of the scroll thumb */
border-radius: 6px; /* Rounded corners on the scroll thumb */
border: 2px solid transparent; /* Creates padding around scroll thumb */
background-clip: padding-box; /* Prevents background color from leaking outside the border */
}
.chrome-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3); /* Darker color when hovering */
}
.chrome-scrollbar::-webkit-scrollbar-track {
background-color: transparent; /* Color of the tracking area */
}

View file

@ -37,6 +37,11 @@ const localStorageAtoms = {
modularChat: atomWithLocalStorage('modularChat', true),
LaTeXParsing: atomWithLocalStorage('LaTeXParsing', true),
// Commands settings
atCommand: atomWithLocalStorage('atCommand', true),
plusCommand: atomWithLocalStorage('plusCommand', true),
slashCommand: atomWithLocalStorage('slashCommand', true),
// Speech settings
conversationMode: atomWithLocalStorage('conversationMode', false),
advancedMode: atomWithLocalStorage('advancedMode', false),

View file

@ -895,6 +895,10 @@ export enum SettingsTabValues {
* Tab for Account Settings
*/
ACCOUNT = 'account',
/**
* Chat input commands
*/
COMMANDS = 'commands',
}
export enum STTProviders {