mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
♿️ fix: Accessibility, UI consistency, dialog & avatar refactors (#9975)
* 🔧 refactor: Improve accessibility and styling in ChatGroupItem and FilterPrompts components * 🔧 fix: Add button type and keyboard accessibility to dropdown menu trigger in ChatGroupItem * 🔧 fix(757): Enhance accessibility by updating aria-labels and adding localization for prompt groups * 🔧 fix(618): Update version to 0.3.1 and enhance accessibility in InfoHoverCard component * 🔧 fix(618): Update aria-label in InfoHoverCard to use dynamic text prop for improved accessibility * 🔧 fix: Enhance accessibility by updating aria-labels and roles in Conversations components * 🔧 fix(620): Enhance accessibility by adding tabIndex to Tabs.Content components in ArtifactTabs, Settings, and Speech components * refactor: remove RevokeKeysButton component and update related components for accessibility - Deleted RevokeKeysButton component. - Updated SharedLinks and General components to use Label for accessibility. - Enhanced Personalization component with aria-labelledby and aria-describedby attributes. - Refactored ConversationModeSwitch to use ToggleSwitch for better state management. - Improved AutoSendTextSelector with local state management and accessibility attributes. - Replaced Switch components with ToggleSwitch in various Speech and TTS components for consistency. - Added aria-labelledby attributes to Dropdown components for better accessibility. - Updated translation.json to include new localization keys and improved existing ones. - Enhanced Slider component to support aria attributes for better accessibility. * 🔧 fix: Enhance user feedback for API key operations with success and error messages * 🔧 fix: Update aria-labels in Avatar component for improved localization and accessibility * 🔧 fix: Refactor handleFile and handleDrop functions for improved readability and maintainability
This commit is contained in:
parent
bcd97aad2f
commit
a5189052ec
56 changed files with 1158 additions and 857 deletions
|
|
@ -44,6 +44,7 @@ export default function ArtifactTabs({
|
|||
value="code"
|
||||
id="artifacts-code"
|
||||
className={cn('flex-grow overflow-auto')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isMermaid ? (
|
||||
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
|
||||
|
|
@ -58,7 +59,7 @@ export default function ArtifactTabs({
|
|||
/>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto">
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() {
|
|||
}
|
||||
};
|
||||
|
||||
const labelId = 'browser-voice-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<div id={labelId}>{localize('com_nav_voice_select')}</div>
|
||||
<Dropdown
|
||||
key={`browser-voice-dropdown-${voices.length}`}
|
||||
value={voice ?? ''}
|
||||
|
|
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
|
|||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="BrowserVoiceDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
|
|||
}
|
||||
};
|
||||
|
||||
const labelId = 'external-voice-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<div id={labelId}>{localize('com_nav_voice_select')}</div>
|
||||
<Dropdown
|
||||
key={`external-voice-dropdown-${voices.length}`}
|
||||
value={voice ?? ''}
|
||||
|
|
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
|
|||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="ExternalVoiceDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
|
|||
);
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||
|
||||
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
|
|
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
isLoading,
|
||||
isSearchLoading,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const convoHeight = isSmallScreen ? 44 : 34;
|
||||
|
||||
|
|
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
{isSearchLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
<span className="ml-2 text-text-primary">Loading...</span>
|
||||
<span className="ml-2 text-text-primary">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -135,8 +135,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
tabIndex={renaming ? -1 : 0}
|
||||
aria-label={`${title || localize('com_ui_untitled')} conversation`}
|
||||
onClick={(e) => {
|
||||
if (renaming) {
|
||||
return;
|
||||
|
|
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
if (renaming) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleNavigation(false);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
|
|||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
role="button"
|
||||
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
|
||||
aria-label={title || localize('com_ui_untitled')}
|
||||
>
|
||||
{title || localize('com_ui_untitled')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ function ConvoOptions({
|
|||
<Menu.MenuButton
|
||||
id={`conversation-menu-${conversationId}`}
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
aria-readonly={undefined}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
|
||||
isActiveConvo === true || isPopoverActive
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
OGDialogFooter,
|
||||
Dropdown,
|
||||
useToastContext,
|
||||
Button,
|
||||
Label,
|
||||
OGDialogTrigger,
|
||||
Spinner,
|
||||
} from '@librechat/client';
|
||||
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useRevokeAllUserKeysMutation,
|
||||
useRevokeUserKeyMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { RevokeKeysButton } from '~/components/Nav';
|
||||
import { useUserKey, useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import CustomConfig from './CustomEndpoint';
|
||||
import GoogleConfig from './GoogleConfig';
|
||||
import OpenAIConfig from './OpenAIConfig';
|
||||
import OtherConfig from './OtherConfig';
|
||||
import HelpText from './HelpText';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
const endpointComponents = {
|
||||
[EModelEndpoint.google]: GoogleConfig,
|
||||
|
|
@ -42,6 +59,94 @@ const EXPIRY = {
|
|||
NEVER: { label: 'never', value: 0 },
|
||||
};
|
||||
|
||||
const RevokeKeysButton = ({
|
||||
endpoint,
|
||||
disabled,
|
||||
setDialogOpen,
|
||||
}: {
|
||||
endpoint: string;
|
||||
disabled: boolean;
|
||||
setDialogOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { showToast } = useToastContext();
|
||||
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
|
||||
const revokeKeysMutation = useRevokeAllUserKeysMutation();
|
||||
|
||||
const handleSuccess = () => {
|
||||
showToast({
|
||||
message: localize('com_ui_revoke_key_success'),
|
||||
status: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
|
||||
if (!setDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
showToast({
|
||||
message: localize('com_ui_revoke_key_error'),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
revokeKeyMutation.mutate(
|
||||
{},
|
||||
{
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="max-w-[450px]">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_revoke_key_endpoint', { 0: endpoint })}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="py-4">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_revoke_key_confirm')}
|
||||
</Label>
|
||||
</div>
|
||||
<OGDialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
|
||||
>
|
||||
{isLoading ? <Spinner /> : localize('com_ui_revoke')}
|
||||
</Button>
|
||||
</OGDialogFooter>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SetKeyDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -83,7 +188,7 @@ const SetKeyDialog = ({
|
|||
|
||||
const submit = () => {
|
||||
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
|
||||
let expiresAt;
|
||||
let expiresAt: number | null;
|
||||
|
||||
if (selectedOption?.value === 0) {
|
||||
expiresAt = null;
|
||||
|
|
@ -92,8 +197,20 @@ const SetKeyDialog = ({
|
|||
}
|
||||
|
||||
const saveKey = (key: string) => {
|
||||
saveUserKey(key, expiresAt);
|
||||
onOpenChange(false);
|
||||
try {
|
||||
saveUserKey(key, expiresAt);
|
||||
showToast({
|
||||
message: localize('com_ui_save_key_success'),
|
||||
status: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
logger.error('Error saving user key:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_save_key_error'),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) {
|
||||
|
|
@ -148,6 +265,14 @@ const SetKeyDialog = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!userKey.trim()) {
|
||||
showToast({
|
||||
message: localize('com_ui_key_required'),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
saveKey(userKey);
|
||||
setUserKey('');
|
||||
};
|
||||
|
|
@ -159,56 +284,54 @@ const SetKeyDialog = ({
|
|||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialogTemplate
|
||||
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
|
||||
className="w-11/12 max-w-2xl"
|
||||
showCancelButton={false}
|
||||
main={
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<small className="text-red-600">
|
||||
{expiryTime === 'never'
|
||||
? localize('com_endpoint_config_key_never_expires')
|
||||
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
|
||||
expiryTime ?? 0,
|
||||
).toLocaleString()}`}
|
||||
</small>
|
||||
<Dropdown
|
||||
label="Expires "
|
||||
value={expiresAtLabel}
|
||||
onChange={handleExpirationChange}
|
||||
options={expirationOptions.map((option) => option.label)}
|
||||
sizeClasses="w-[185px]"
|
||||
portal={false}
|
||||
<OGDialogContent className="w-11/12 max-w-2xl">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>
|
||||
{`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="grid w-full items-center gap-2 py-4">
|
||||
<small className="text-red-600">
|
||||
{expiryTime === 'never'
|
||||
? localize('com_endpoint_config_key_never_expires')
|
||||
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
|
||||
expiryTime ?? 0,
|
||||
).toLocaleString()}`}
|
||||
</small>
|
||||
<Dropdown
|
||||
label="Expires "
|
||||
value={expiresAtLabel}
|
||||
onChange={handleExpirationChange}
|
||||
options={expirationOptions.map((option) => option.label)}
|
||||
sizeClasses="w-[185px]"
|
||||
portal={false}
|
||||
/>
|
||||
<div className="mt-2" />
|
||||
<FormProvider {...methods}>
|
||||
<EndpointComponent
|
||||
userKey={userKey}
|
||||
setUserKey={setUserKey}
|
||||
endpoint={
|
||||
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
|
||||
? EModelEndpoint.azureOpenAI
|
||||
: endpoint
|
||||
}
|
||||
userProvideURL={userProvideURL}
|
||||
/>
|
||||
<div className="mt-2" />
|
||||
<FormProvider {...methods}>
|
||||
<EndpointComponent
|
||||
userKey={userKey}
|
||||
setUserKey={setUserKey}
|
||||
endpoint={
|
||||
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
|
||||
? EModelEndpoint.azureOpenAI
|
||||
: endpoint
|
||||
}
|
||||
userProvideURL={userProvideURL}
|
||||
/>
|
||||
</FormProvider>
|
||||
<HelpText endpoint={endpoint} />
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: submit,
|
||||
selectClasses: 'btn btn-primary',
|
||||
selectText: localize('com_ui_submit'),
|
||||
}}
|
||||
leftButtons={
|
||||
</FormProvider>
|
||||
<HelpText endpoint={endpoint} />
|
||||
</div>
|
||||
<OGDialogFooter>
|
||||
<RevokeKeysButton
|
||||
endpoint={endpoint}
|
||||
disabled={!(expiryTime ?? '')}
|
||||
setDialogOpen={onOpenChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Button variant="submit" onClick={submit}>
|
||||
{localize('com_ui_submit')}
|
||||
</Button>
|
||||
</OGDialogFooter>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||
</svg>
|
||||
<span className="sr-only">{localize('com_ui_close')}</span>
|
||||
<span className="sr-only">{localize('com_ui_close_settings')}</span>
|
||||
</button>
|
||||
</DialogTitle>
|
||||
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
||||
|
|
@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
))}
|
||||
</Tabs.List>
|
||||
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
|
||||
<Tabs.Content value={SettingsTabValues.GENERAL}>
|
||||
<Tabs.Content value={SettingsTabValues.GENERAL} tabIndex={-1}>
|
||||
<General />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.CHAT}>
|
||||
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
|
||||
<Chat />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.COMMANDS}>
|
||||
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
|
||||
<Commands />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.SPEECH}>
|
||||
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
|
||||
<Speech />
|
||||
</Tabs.Content>
|
||||
{hasAnyPersonalizationFeature && (
|
||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
|
||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
|
||||
<Personalization
|
||||
hasMemoryOptOut={hasMemoryOptOut}
|
||||
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.DATA}>
|
||||
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
|
||||
<Data />
|
||||
</Tabs.Content>
|
||||
{startupConfig?.balance?.enabled && (
|
||||
<Tabs.Content value={SettingsTabValues.BALANCE}>
|
||||
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
|
||||
<Balance />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
|
||||
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
|
||||
<Account />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
// @ts-ignore - no type definitions available
|
||||
import AvatarEditor from 'react-avatar-editor';
|
||||
import { FileImage, RotateCw, Upload } from 'lucide-react';
|
||||
import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react';
|
||||
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
Label,
|
||||
Slider,
|
||||
Button,
|
||||
Spinner,
|
||||
|
|
@ -25,14 +27,20 @@ interface AvatarEditorRef {
|
|||
getImage: () => HTMLImageElement;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function Avatar() {
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
|
||||
const [scale, setScale] = useState<number>(1);
|
||||
const [rotation, setRotation] = useState<number>(0);
|
||||
const [position, setPosition] = useState<Position>({ x: 0.5, y: 0.5 });
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const editorRef = useRef<AvatarEditorRef | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const openButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [image, setImage] = useState<string | File | null>(null);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
|
@ -48,7 +56,6 @@ function Avatar() {
|
|||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
||||
openButtonRef.current?.click();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
|
|
@ -61,29 +68,45 @@ function Avatar() {
|
|||
handleFile(file);
|
||||
};
|
||||
|
||||
const handleFile = (file: File | undefined) => {
|
||||
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||
setImage(file);
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
} else {
|
||||
const megabytes =
|
||||
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleFile = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||
setImage(file);
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
setPosition({ x: 0.5, y: 0.5 });
|
||||
} else {
|
||||
const megabytes =
|
||||
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[fileConfig.avatarSizeLimit, localize, showToast],
|
||||
);
|
||||
|
||||
const handleScaleChange = (value: number[]) => {
|
||||
setScale(value[0]);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.2, 5));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.2, 1));
|
||||
};
|
||||
|
||||
const handleRotate = () => {
|
||||
setRotation((prev) => (prev + 90) % 360);
|
||||
};
|
||||
|
||||
const handlePositionChange = (position: Position) => {
|
||||
setPosition(position);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (editorRef.current) {
|
||||
const canvas = editorRef.current.getImageScaledToCanvas();
|
||||
|
|
@ -98,11 +121,14 @@ function Avatar() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleFile(file);
|
||||
}, []);
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleFile(file);
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -116,8 +142,15 @@ function Avatar() {
|
|||
setImage(null);
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
setPosition({ x: 0.5, y: 0.5 });
|
||||
}, []);
|
||||
|
||||
const handleReset = () => {
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
setPosition({ x: 0.5, y: 0.5 });
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
open={isDialogOpen}
|
||||
|
|
@ -125,90 +158,190 @@ function Avatar() {
|
|||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
resetImage();
|
||||
setTimeout(() => {
|
||||
openButtonRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<FileImage className="mr-2 flex w-[22px] items-center" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
|
||||
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
|
||||
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
{image != null ? (
|
||||
<>
|
||||
<div className="relative overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-full ring-4 ring-gray-200 transition-all dark:ring-gray-700',
|
||||
isDragging && 'cursor-move ring-blue-500 dark:ring-blue-400',
|
||||
)}
|
||||
onMouseDown={() => setIsDragging(true)}
|
||||
onMouseUp={() => setIsDragging(false)}
|
||||
onMouseLeave={() => setIsDragging(false)}
|
||||
>
|
||||
<AvatarEditor
|
||||
ref={editorRef}
|
||||
image={image}
|
||||
width={250}
|
||||
height={250}
|
||||
width={280}
|
||||
height={280}
|
||||
border={0}
|
||||
borderRadius={125}
|
||||
borderRadius={140}
|
||||
color={[255, 255, 255, 0.6]}
|
||||
scale={scale}
|
||||
rotate={rotation}
|
||||
position={position}
|
||||
onPositionChange={handlePositionChange}
|
||||
className="cursor-move"
|
||||
/>
|
||||
{!isDragging && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100">
|
||||
<div className="rounded-full bg-black/50 p-2">
|
||||
<Move className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col items-center space-y-4">
|
||||
<div className="flex w-full items-center justify-center space-x-4">
|
||||
<span className="text-sm">{localize('com_ui_zoom')}</span>
|
||||
<Slider
|
||||
value={[scale]}
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.001}
|
||||
onValueChange={handleScaleChange}
|
||||
className="w-2/3 max-w-xs"
|
||||
/>
|
||||
|
||||
<div className="mt-6 w-full space-y-6">
|
||||
{/* Zoom Controls */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="zoom-slider" className="text-sm font-medium">
|
||||
{localize('com_ui_zoom')}
|
||||
</Label>
|
||||
<span className="text-sm text-text-secondary">{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleZoomOut}
|
||||
disabled={scale <= 1}
|
||||
aria-label={localize('com_ui_zoom_out')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Slider
|
||||
id="zoom-slider"
|
||||
value={[scale]}
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
onValueChange={handleScaleChange}
|
||||
className="flex-1"
|
||||
aria-label={localize('com_ui_zoom_level')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleZoomIn}
|
||||
disabled={scale >= 5}
|
||||
aria-label={localize('com_ui_zoom_in')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRotate}
|
||||
className="rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500"
|
||||
>
|
||||
<RotateCw className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRotate}
|
||||
className="flex items-center space-x-2"
|
||||
aria-label={localize('com_ui_rotate_90')}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
<span className="text-sm">{localize('com_ui_rotate')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex items-center space-x-2"
|
||||
aria-label={localize('com_ui_reset_adjustments')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="text-sm">{localize('com_ui_reset')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{localize('com_ui_editor_instructions')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex w-full space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={resetImage}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="submit"
|
||||
type="button"
|
||||
className={cn('w-full', isUploading ? 'cursor-not-allowed opacity-90' : '')}
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="icon-sm mr-2" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{localize('com_ui_upload')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'btn btn-primary mt-4 flex w-full hover:bg-green-600',
|
||||
isUploading ? 'cursor-not-allowed opacity-90' : '',
|
||||
)}
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="icon-sm mr-2" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
{localize('com_ui_upload')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-64 w-11/12 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent dark:border-gray-600"
|
||||
className="flex h-72 w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent transition-colors hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openFileDialog}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openFileDialog();
|
||||
}
|
||||
}}
|
||||
aria-label={localize('com_ui_upload_avatar_label')}
|
||||
>
|
||||
<FileImage className="mb-4 size-12 text-gray-400" />
|
||||
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<FileImage className="mb-4 size-16 text-gray-400" />
|
||||
<p className="mb-2 text-center text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_drag_drop')}
|
||||
</p>
|
||||
<Button variant="secondary" onClick={openFileDialog}>
|
||||
<p className="mb-4 text-center text-xs text-text-secondary">
|
||||
{localize('com_ui_max_file_size', {
|
||||
0:
|
||||
fileConfig.avatarSizeLimit != null
|
||||
? formatBytes(fileConfig.avatarSizeLimit)
|
||||
: '2MB',
|
||||
})}
|
||||
</p>
|
||||
<Button type="button" variant="secondary" onClick={openFileDialog}>
|
||||
{localize('com_ui_select_file')}
|
||||
</Button>
|
||||
<input
|
||||
|
|
@ -217,6 +350,7 @@ function Avatar() {
|
|||
className="hidden"
|
||||
accept=".png, .jpg, .jpeg"
|
||||
onChange={handleFileChange}
|
||||
aria-label={localize('com_ui_file_input_avatar_label')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { LockIcon, Trash } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Button,
|
||||
Spinner,
|
||||
|
|
@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
<>
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_delete_account')}</span>
|
||||
<Label id="delete-account-label">{localize('com_nav_delete_account')}</Label>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
aria-labelledby="delete-account-label"
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
|
|||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
|
||||
<Label> {localize('com_nav_2fa')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
|
|||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
|
||||
<Label id="user-name-display-label">{localize('com_nav_user_name_display')}</Label>
|
||||
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
|
|||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="UsernameDisplay"
|
||||
aria-labelledby="user-name-display-label"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ const ChatDirection = () => {
|
|||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Toggle chat direction"
|
||||
aria-label={`${localize('com_nav_chat_direction')}: ${localize('com_ui_x_selected', {
|
||||
0:
|
||||
direction === 'LTR'
|
||||
? localize('chat_direction_left_to_right')
|
||||
: localize('chat_direction_right_to_left'),
|
||||
})}`}
|
||||
onClick={toggleChatDirection}
|
||||
data-testid="chatDirection"
|
||||
>
|
||||
<span aria-hidden="true">{direction.toLowerCase()}</span>
|
||||
<span id="chat-direction-status" className="sr-only">
|
||||
{direction === 'LTR'
|
||||
? localize('chat_direction_left_to_right')
|
||||
: localize('chat_direction_right_to_left')}
|
||||
</span>
|
||||
{direction.toLowerCase()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export default function FontSizeSelector() {
|
|||
{ value: 'text-xl', label: localize('com_nav_font_size_xl') },
|
||||
];
|
||||
|
||||
const labelId = 'font-size-selector-label';
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>{localize('com_nav_font_size')}</div>
|
||||
<div id={labelId}>{localize('com_nav_font_size')}</div>
|
||||
<Dropdown
|
||||
value={fontSize}
|
||||
options={options}
|
||||
|
|
@ -30,6 +32,7 @@ export default function FontSizeSelector() {
|
|||
testId="font-size-selector"
|
||||
sizeClasses="w-[150px]"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ export const ForkSettings = () => {
|
|||
<>
|
||||
<div className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div> {localize('com_ui_fork_default')} </div>
|
||||
<div id="remember-default-fork-label"> {localize('com_ui_fork_default')} </div>
|
||||
<Switch
|
||||
id="rememberDefaultFork"
|
||||
checked={remember}
|
||||
onCheckedChange={setRemember}
|
||||
className="ml-4"
|
||||
data-testid="rememberDefaultFork"
|
||||
aria-labelledby="remember-default-fork-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -34,7 +35,7 @@ export const ForkSettings = () => {
|
|||
<div className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{localize('com_ui_fork_change_default')}</div>
|
||||
<div id="fork-change-default-label">{localize('com_ui_fork_change_default')}</div>
|
||||
<InfoHoverCard
|
||||
side={ESide.Bottom}
|
||||
text={localize('com_nav_info_fork_change_default')}
|
||||
|
|
@ -47,6 +48,7 @@ export const ForkSettings = () => {
|
|||
sizeClasses="w-[200px]"
|
||||
testId="fork-setting-dropdown"
|
||||
className="z-[50]"
|
||||
aria-labelledby="fork-change-default-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -54,7 +56,7 @@ export const ForkSettings = () => {
|
|||
<div className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{localize('com_ui_fork_split_target_setting')}</div>
|
||||
<div id="split-at-target-label">{localize('com_ui_fork_split_target_setting')}</div>
|
||||
<InfoHoverCard
|
||||
side={ESide.Bottom}
|
||||
text={localize('com_nav_info_fork_split_target_setting')}
|
||||
|
|
@ -66,6 +68,7 @@ export const ForkSettings = () => {
|
|||
onCheckedChange={setSplitAtTarget}
|
||||
className="ml-4"
|
||||
data-testid="splitAtTarget"
|
||||
aria-labelledby="split-at-target-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
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"
|
||||
data-testid="atCommand"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,33 @@
|
|||
import { memo } from 'react';
|
||||
import { InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import SlashCommandSwitch from './SlashCommandSwitch';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import PlusCommandSwitch from './PlusCommandSwitch';
|
||||
import AtCommandSwitch from './AtCommandSwitch';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
const commandSwitchConfigs = [
|
||||
{
|
||||
stateAtom: store.atCommand,
|
||||
localizationKey: 'com_nav_at_command_description' as const,
|
||||
switchId: 'atCommand',
|
||||
key: 'atCommand',
|
||||
permissionType: undefined,
|
||||
},
|
||||
{
|
||||
stateAtom: store.plusCommand,
|
||||
localizationKey: 'com_nav_plus_command_description' as const,
|
||||
switchId: 'plusCommand',
|
||||
key: 'plusCommand',
|
||||
permissionType: PermissionTypes.MULTI_CONVO,
|
||||
},
|
||||
{
|
||||
stateAtom: store.slashCommand,
|
||||
localizationKey: 'com_nav_slash_command_description' as const,
|
||||
switchId: 'slashCommand',
|
||||
key: 'slashCommand',
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function Commands() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -19,6 +42,19 @@ function Commands() {
|
|||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const getShowSwitch = (permissionType?: PermissionTypes) => {
|
||||
if (!permissionType) {
|
||||
return true;
|
||||
}
|
||||
if (permissionType === PermissionTypes.MULTI_CONVO) {
|
||||
return hasAccessToMultiConvo === true;
|
||||
}
|
||||
if (permissionType === PermissionTypes.PROMPTS) {
|
||||
return hasAccessToPrompts === true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -28,19 +64,16 @@ function Commands() {
|
|||
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<AtCommandSwitch />
|
||||
</div>
|
||||
{hasAccessToMultiConvo === true && (
|
||||
<div className="pb-3">
|
||||
<PlusCommandSwitch />
|
||||
{commandSwitchConfigs.map((config) => (
|
||||
<div key={config.key} className="pb-3">
|
||||
<ToggleSwitch
|
||||
stateAtom={config.stateAtom}
|
||||
localizationKey={config.localizationKey}
|
||||
switchId={config.switchId}
|
||||
showSwitch={getShowSwitch(config.permissionType)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasAccessToPrompts === true && (
|
||||
<div className="pb-3">
|
||||
<SlashCommandSwitch />
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
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"
|
||||
data-testid="plusCommand"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
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}
|
||||
data-testid="slashCommand"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,12 +31,12 @@ export const ClearChats = () => {
|
|||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-light">{localize('com_nav_clear_all_chats')}</Label>
|
||||
<Label id="clear-all-chats-label">{localize('com_nav_clear_all_chats')}</Label>
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
aria-labelledby="clear-all-chats-label"
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{localize('com_ui_delete')}
|
||||
|
|
@ -47,7 +47,7 @@ export const ClearChats = () => {
|
|||
title={localize('com_nav_confirm_clear')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
<Label className="break-words">
|
||||
{localize('com_nav_clear_conversation_confirm_message')}
|
||||
</Label>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useOnClickOutside } from '@librechat/client';
|
||||
import ImportConversations from './ImportConversations';
|
||||
import { RevokeAllKeys } from './RevokeAllKeys';
|
||||
import { RevokeKeys } from './RevokeKeys';
|
||||
import { DeleteCache } from './DeleteCache';
|
||||
import { ClearChats } from './ClearChats';
|
||||
import SharedLinks from './SharedLinks';
|
||||
|
|
@ -20,7 +20,7 @@ function Data() {
|
|||
<SharedLinks />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<RevokeAllKeys />
|
||||
<RevokeKeys />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<DeleteCache />
|
||||
|
|
|
|||
|
|
@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
|
|||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-light">{localize('com_nav_delete_cache_storage')}</Label>
|
||||
<Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled || isCacheEmpty}
|
||||
aria-labelledby="delete-cache-label"
|
||||
>
|
||||
{localize('com_ui_delete')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,96 +1,114 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Import } from 'lucide-react';
|
||||
import { Spinner, useToastContext } from '@librechat/client';
|
||||
import type { TError } from 'librechat-data-provider';
|
||||
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
|
||||
import { useUploadConversationsMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { cn, logger } from '~/utils';
|
||||
|
||||
function ImportConversations() {
|
||||
const localize = useLocalize();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const [, setErrors] = useState<string[]>([]);
|
||||
const [allowImport, setAllowImport] = useState(true);
|
||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
showToast({
|
||||
message: localize('com_ui_import_conversation_success'),
|
||||
status: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsUploading(false);
|
||||
}, [localize, showToast]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: unknown) => {
|
||||
logger.error('Import error:', error);
|
||||
setIsUploading(false);
|
||||
|
||||
const isUnsupportedType = error?.toString().includes('Unsupported import type');
|
||||
|
||||
showToast({
|
||||
message: localize(
|
||||
isUnsupportedType
|
||||
? 'com_ui_import_conversation_file_type_error'
|
||||
: 'com_ui_import_conversation_error',
|
||||
),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
[localize, showToast],
|
||||
);
|
||||
|
||||
const uploadFile = useUploadConversationsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_import_conversation_success') });
|
||||
setAllowImport(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error: ', error);
|
||||
setAllowImport(true);
|
||||
setError(
|
||||
(error as TError).response?.data?.message ?? 'An error occurred while uploading the file.',
|
||||
);
|
||||
if (error?.toString().includes('Unsupported import type') === true) {
|
||||
showToast({
|
||||
message: localize('com_ui_import_conversation_file_type_error'),
|
||||
status: 'error',
|
||||
});
|
||||
} else {
|
||||
showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' });
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
setAllowImport(false);
|
||||
},
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
onMutate: () => setIsUploading(true),
|
||||
});
|
||||
|
||||
const startUpload = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, encodeURIComponent(file.name || 'File'));
|
||||
const handleFileUpload = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, encodeURIComponent(file.name || 'File'));
|
||||
uploadFile.mutate(formData);
|
||||
} catch (error) {
|
||||
logger.error('File processing error:', error);
|
||||
setIsUploading(false);
|
||||
showToast({
|
||||
message: localize('com_ui_import_conversation_upload_error'),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
[uploadFile, showToast, localize],
|
||||
);
|
||||
|
||||
uploadFile.mutate(formData);
|
||||
};
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
const handleFiles = async (_file: File) => {
|
||||
try {
|
||||
await startUpload(_file);
|
||||
} catch (error) {
|
||||
console.log('file handling error', error);
|
||||
setError('An error occurred while processing the file.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFiles(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleImportClick();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleImportClick();
|
||||
}
|
||||
},
|
||||
[handleImportClick],
|
||||
);
|
||||
|
||||
const isImportDisabled = isUploading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_ui_import_conversation_info')}</div>
|
||||
<button
|
||||
<Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleImportClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!allowImport}
|
||||
disabled={isImportDisabled}
|
||||
aria-label={localize('com_ui_import')}
|
||||
className="btn btn-neutral relative"
|
||||
aria-labelledby="import-conversation-label"
|
||||
>
|
||||
{allowImport ? (
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
) : (
|
||||
{isUploading ? (
|
||||
<Spinner className="mr-1 w-4" />
|
||||
) : (
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
)}
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Label } from '@librechat/client';
|
||||
import { RevokeKeysButton } from './RevokeKeysButton';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export const RevokeAllKeys = () => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-light">{localize('com_ui_revoke_info')}</Label>
|
||||
<RevokeKeysButton all={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Normal file
72
client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useRevokeAllUserKeysMutation } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
OGDialogTemplate,
|
||||
Button,
|
||||
Label,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
Spinner,
|
||||
} from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export const RevokeKeys = ({
|
||||
disabled = false,
|
||||
setDialogOpen,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
setDialogOpen?: (open: boolean) => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const revokeKeysMutation = useRevokeAllUserKeysMutation();
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (!setDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
revokeKeysMutation.mutate({}, { onSuccess: handleSuccess });
|
||||
};
|
||||
|
||||
const isLoading = revokeKeysMutation.isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label id="revoke-info-label">{localize('com_ui_revoke_info')}</Label>
|
||||
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
aria-labelledby="revoke-info-label"
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_revoke_keys')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_revoke_keys_confirm')}
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: onClick,
|
||||
selectClasses:
|
||||
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
|
||||
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
useRevokeAllUserKeysMutation,
|
||||
useRevokeUserKeyMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
OGDialogTemplate,
|
||||
Button,
|
||||
Label,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
Spinner,
|
||||
} from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export const RevokeKeysButton = ({
|
||||
endpoint = '',
|
||||
all = false,
|
||||
disabled = false,
|
||||
setDialogOpen,
|
||||
}: {
|
||||
endpoint?: string;
|
||||
all?: boolean;
|
||||
disabled?: boolean;
|
||||
setDialogOpen?: (open: boolean) => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
|
||||
const revokeKeysMutation = useRevokeAllUserKeysMutation();
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (!setDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
if (all) {
|
||||
revokeKeysMutation.mutate({});
|
||||
} else {
|
||||
revokeKeyMutation.mutate({}, { onSuccess: handleSuccess });
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = all
|
||||
? localize('com_ui_revoke_keys')
|
||||
: localize('com_ui_revoke_key_endpoint', { 0: endpoint });
|
||||
|
||||
const dialogMessage = all
|
||||
? localize('com_ui_revoke_keys_confirm')
|
||||
: localize('com_ui_revoke_key_confirm');
|
||||
|
||||
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={dialogTitle}
|
||||
className="max-w-[450px]"
|
||||
main={<Label className="text-left text-sm font-medium">{dialogMessage}</Label>}
|
||||
selection={{
|
||||
selectHandler: onClick,
|
||||
selectClasses:
|
||||
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
|
||||
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -286,11 +286,13 @@ export default function SharedLinks() {
|
|||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_shared_links')}</div>
|
||||
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
||||
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<Button variant="outline">{localize('com_ui_manage')}</Button>
|
||||
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
|
|
|
|||
|
|
@ -46,9 +46,11 @@ export const ThemeSelector = ({
|
|||
{ value: 'light', label: localize('com_nav_theme_light') },
|
||||
];
|
||||
|
||||
const labelId = 'theme-selector-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_theme')}</div>
|
||||
<div id={labelId}>{localize('com_nav_theme')}</div>
|
||||
|
||||
<Dropdown
|
||||
value={theme}
|
||||
|
|
@ -57,6 +59,7 @@ export const ThemeSelector = ({
|
|||
sizeClasses="w-[180px]"
|
||||
testId="theme-selector"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -112,9 +115,11 @@ export const LangSelector = ({
|
|||
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
|
||||
];
|
||||
|
||||
const labelId = 'language-selector-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_language')}</div>
|
||||
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||
|
||||
<Dropdown
|
||||
value={langcode}
|
||||
|
|
@ -122,6 +127,7 @@ export const LangSelector = ({
|
|||
sizeClasses="[--anchor-max-height:256px]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,10 +65,13 @@ export default function Personalization({
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div id="reference-saved-memories-label" className="flex items-center gap-2">
|
||||
{localize('com_ui_reference_saved_memories')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<div
|
||||
id="reference-saved-memories-description"
|
||||
className="mt-1 text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_reference_saved_memories_description')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,7 +79,8 @@ export default function Personalization({
|
|||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
aria-label={localize('com_ui_reference_saved_memories')}
|
||||
aria-labelledby="reference-saved-memories-label"
|
||||
aria-describedby="reference-saved-memories-description"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Switch } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ConversationModeSwitch({
|
||||
|
|
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
|
||||
const speechToText = useRecoilValue(store.speechToText);
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
const [, setAutoSendText] = useRecoilState(store.autoSendText);
|
||||
|
|
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
|
|||
setAutoTranscribeAudio(value);
|
||||
setAutoSendText(3);
|
||||
setDecibelValue(-45);
|
||||
setConversationMode(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>{localize('com_nav_conversation_mode')}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Switch
|
||||
id="ConversationMode"
|
||||
checked={conversationMode}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="ConversationMode"
|
||||
disabled={!textToSpeech || !speechToText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.conversationMode}
|
||||
localizationKey={'com_nav_conversation_mode' as const}
|
||||
switchId="ConversationMode"
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={!textToSpeech || !speechToText}
|
||||
strongLabel={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Slider, InputNumber } from '@librechat/client';
|
||||
import { Slider, InputNumber, Switch } from '@librechat/client';
|
||||
import { cn, defaultTextProps, optionText } from '~/utils/';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -11,40 +11,104 @@ export default function AutoSendTextSelector() {
|
|||
const speechToText = useRecoilValue(store.speechToText);
|
||||
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
|
||||
|
||||
// Local state for enabled/disabled toggle
|
||||
const [isEnabled, setIsEnabled] = useState(autoSendText !== -1);
|
||||
const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText);
|
||||
|
||||
// Sync local state when autoSendText changes externally
|
||||
useEffect(() => {
|
||||
setIsEnabled(autoSendText !== -1);
|
||||
if (autoSendText !== -1) {
|
||||
setDelayValue(autoSendText);
|
||||
}
|
||||
}, [autoSendText]);
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
setIsEnabled(enabled);
|
||||
if (enabled) {
|
||||
setAutoSendText(delayValue);
|
||||
} else {
|
||||
setAutoSendText(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSliderChange = (value: number[]) => {
|
||||
const newValue = value[0];
|
||||
setDelayValue(newValue);
|
||||
if (isEnabled) {
|
||||
setAutoSendText(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: number[] | null) => {
|
||||
const newValue = value ? value[0] : 3;
|
||||
setDelayValue(newValue);
|
||||
if (isEnabled) {
|
||||
setAutoSendText(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const labelId = 'auto-send-text-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_auto_send_text')}</div>
|
||||
<div className="w-2" />
|
||||
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Slider
|
||||
value={[autoSendText ?? -1]}
|
||||
onValueChange={(value) => setAutoSendText(value[0])}
|
||||
onDoubleClick={() => setAutoSendText(-1)}
|
||||
min={-1}
|
||||
max={60}
|
||||
step={1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>{localize('com_nav_auto_send_text')}</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoSendTextToggle"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
className="ml-4"
|
||||
data-testid="autoSendTextToggle"
|
||||
aria-labelledby={labelId}
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
value={`${autoSendText} s`}
|
||||
disabled={!speechToText}
|
||||
onChange={(value) => setAutoSendText(value ? value[0] : 0)}
|
||||
min={-1}
|
||||
max={60}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div id="auto-send-delay-label" className="text-sm text-text-secondary">
|
||||
{localize('com_nav_setting_delay')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Slider
|
||||
value={[delayValue]}
|
||||
onValueChange={handleSliderChange}
|
||||
onDoubleClick={() => {
|
||||
setDelayValue(3);
|
||||
if (isEnabled) {
|
||||
setAutoSendText(3);
|
||||
}
|
||||
}}
|
||||
min={0}
|
||||
max={60}
|
||||
step={1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
disabled={!speechToText || !isEnabled}
|
||||
aria-labelledby="auto-send-delay-label"
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
value={`${delayValue} s`}
|
||||
disabled={!speechToText || !isEnabled}
|
||||
onChange={handleInputChange}
|
||||
min={0}
|
||||
max={60}
|
||||
aria-labelledby="auto-send-delay-label"
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Switch } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutoTranscribeAudioSwitch({
|
||||
|
|
@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
|
||||
store.autoTranscribeAudio,
|
||||
);
|
||||
const speechToText = useRecoilValue(store.speechToText);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutoTranscribeAudio(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_auto_transcribe_audio')}</div>
|
||||
<Switch
|
||||
id="AutoTranscribeAudio"
|
||||
checked={autoTranscribeAudio}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="AutoTranscribeAudio"
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.autoTranscribeAudio}
|
||||
localizationKey={'com_nav_auto_transcribe_audio' as const}
|
||||
switchId="AutoTranscribeAudio"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function DecibelSelector() {
|
|||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_db_sensitivity')}</div>
|
||||
<div id="decibel-selector-label">{localize('com_nav_db_sensitivity')}</div>
|
||||
<div className="w-2" />
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '-45' })})
|
||||
|
|
@ -29,6 +29,7 @@ export default function DecibelSelector() {
|
|||
step={1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
disabled={!speechToText}
|
||||
aria-labelledby="decibel-selector-label"
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
|
|
@ -37,6 +38,7 @@ export default function DecibelSelector() {
|
|||
onChange={(value) => setDecibelValue(value ? value[0] : 0)}
|
||||
min={-100}
|
||||
max={-30}
|
||||
aria-labelledby="decibel-selector-label"
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
setEngineSTT(value);
|
||||
};
|
||||
|
||||
const labelId = 'engine-stt-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_engine')}</div>
|
||||
<div id={labelId}>{localize('com_nav_engine')}</div>
|
||||
<Dropdown
|
||||
value={engineSTT}
|
||||
onChange={handleSelect}
|
||||
|
|
@ -33,6 +35,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
sizeClasses="w-[180px]"
|
||||
testId="EngineSTTDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() {
|
|||
setLanguageSTT(value);
|
||||
};
|
||||
|
||||
const labelId = 'language-stt-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_language')}</div>
|
||||
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||
<Dropdown
|
||||
value={languageSTT}
|
||||
onChange={handleSelect}
|
||||
|
|
@ -104,6 +106,7 @@ export default function LanguageSTTDropdown() {
|
|||
sizeClasses="[--anchor-max-height:256px]"
|
||||
testId="LanguageSTTDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function SpeechToTextSwitch({
|
||||
|
|
@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [speechToText, setSpeechToText] = useRecoilState<boolean>(store.speechToText);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setSpeechToText(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>{localize('com_nav_speech_to_text')}</strong>
|
||||
</div>
|
||||
<Switch
|
||||
id="SpeechToText"
|
||||
checked={speechToText}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="SpeechToText"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.speechToText}
|
||||
localizationKey={'com_nav_speech_to_text' as const}
|
||||
switchId="SpeechToText"
|
||||
onCheckedChange={onCheckedChange}
|
||||
strongLabel={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from './STT';
|
||||
import ConversationModeSwitch from './ConversationModeSwitch';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function Speech() {
|
||||
|
|
@ -186,7 +186,7 @@ function Speech() {
|
|||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Tabs.Content value={'simple'}>
|
||||
<Tabs.Content value={'simple'} tabIndex={-1}>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<SpeechToTextSwitch />
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
|
|
@ -198,7 +198,7 @@ function Speech() {
|
|||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value={'advanced'}>
|
||||
<Tabs.Content value={'advanced'} tabIndex={-1}>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<ConversationModeSwitch />
|
||||
<div className="mt-2 h-px bg-border-medium" role="none" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutomaticPlaybackSwitch({
|
||||
|
|
@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutomaticPlayback(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_automatic_playback')}</div>
|
||||
<Switch
|
||||
id="AutomaticPlayback"
|
||||
checked={automaticPlayback}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="AutomaticPlayback"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.automaticPlayback}
|
||||
localizationKey={'com_nav_automatic_playback' as const}
|
||||
switchId="AutomaticPlayback"
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function CacheTTSSwitch({
|
||||
|
|
@ -8,28 +7,15 @@ export default function CacheTTSSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [cacheTTS, setCacheTTS] = useRecoilState<boolean>(store.cacheTTS);
|
||||
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setCacheTTS(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_enable_cache_tts')}</div>
|
||||
<Switch
|
||||
id="CacheTTS"
|
||||
checked={cacheTTS}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="CacheTTS"
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.cacheTTS}
|
||||
localizationKey={'com_nav_enable_cache_tts' as const}
|
||||
switchId="CacheTTS"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function CloudBrowserVoicesSwitch({
|
||||
|
|
@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
|
||||
store.cloudBrowserVoices,
|
||||
);
|
||||
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setCloudBrowserVoices(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_enable_cloud_browser_voice')}</div>
|
||||
<Switch
|
||||
id="CloudBrowserVoices"
|
||||
checked={cloudBrowserVoices}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="CloudBrowserVoices"
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.cloudBrowserVoices}
|
||||
localizationKey={'com_nav_enable_cloud_browser_voice' as const}
|
||||
switchId="CloudBrowserVoices"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
setEngineTTS(value);
|
||||
};
|
||||
|
||||
const labelId = 'engine-tts-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_engine')}</div>
|
||||
<div id={labelId}>{localize('com_nav_engine')}</div>
|
||||
<Dropdown
|
||||
value={engineTTS}
|
||||
onChange={handleSelect}
|
||||
|
|
@ -33,6 +35,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
sizeClasses="w-[180px]"
|
||||
testId="EngineTTSDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function DecibelSelector() {
|
|||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_playback_rate')}</div>
|
||||
<div id="playback-rate-label">{localize('com_nav_playback_rate')}</div>
|
||||
<div className="w-2" />
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '1' })})
|
||||
|
|
@ -29,6 +29,7 @@ export default function DecibelSelector() {
|
|||
step={0.1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
disabled={!textToSpeech}
|
||||
aria-labelledby="playback-rate-label"
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
|
|
@ -37,6 +38,7 @@ export default function DecibelSelector() {
|
|||
onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
|
||||
min={0.1}
|
||||
max={2}
|
||||
aria-labelledby="playback-rate-label"
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function TextToSpeechSwitch({
|
||||
|
|
@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [TextToSpeech, setTextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setTextToSpeech(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>{localize('com_nav_text_to_speech')}</strong>
|
||||
</div>
|
||||
<Switch
|
||||
id="TextToSpeech"
|
||||
checked={TextToSpeech}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="TextToSpeech"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.textToSpeech}
|
||||
localizationKey={'com_nav_text_to_speech' as const}
|
||||
switchId="TextToSpeech"
|
||||
onCheckedChange={onCheckedChange}
|
||||
strongLabel={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ interface ToggleSwitchProps {
|
|||
hoverCardText?: LocalizeKey;
|
||||
switchId: string;
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
showSwitch?: boolean;
|
||||
disabled?: boolean;
|
||||
strongLabel?: boolean;
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
|
|
@ -19,6 +22,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
showSwitch = true,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
const [switchState, setSwitchState] = useRecoilState(stateAtom);
|
||||
const localize = useLocalize();
|
||||
|
|
@ -28,10 +34,18 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
onCheckedChange?.(value);
|
||||
};
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
if (!showSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{localize(localizationKey)}</div>
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -40,6 +54,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
export { default as General } from './General/General';
|
||||
export { default as Chat } from './Chat/Chat';
|
||||
export { default as Data } from './Data/Data';
|
||||
export { default as Commands } from './Commands/Commands';
|
||||
export { RevokeKeysButton } from './Data/RevokeKeysButton';
|
||||
export { default as Account } from './Account/Account';
|
||||
export { default as Balance } from './Balance/Balance';
|
||||
export { default as Speech } from './Speech/Speech';
|
||||
export { default as Balance } from './Balance/Balance';
|
||||
export { default as General } from './General/General';
|
||||
export { default as Account } from './Account/Account';
|
||||
export { default as Commands } from './Commands/Commands';
|
||||
export { default as Personalization } from './Personalization';
|
||||
|
|
|
|||
|
|
@ -71,10 +71,12 @@ function ChatGroupItem({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
id={`prompt-actions-${group._id}`}
|
||||
aria-label={`${group.name} - Actions Menu`}
|
||||
aria-expanded="false"
|
||||
aria-controls={`prompt-menu-${group._id}`}
|
||||
aria-haspopup="menu"
|
||||
type="button"
|
||||
aria-label={
|
||||
localize('com_ui_sr_actions_menu', { 0: group.name }) +
|
||||
' ' +
|
||||
localize('com_ui_prompt')
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
|
|
@ -86,11 +88,6 @@ function ChatGroupItem({
|
|||
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
|
||||
<span className="sr-only">
|
||||
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
|
||||
' ' +
|
||||
localize('com_ui_prompt')}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
@ -98,30 +95,29 @@ function ChatGroupItem({
|
|||
aria-label={`Available actions for ${group.name}`}
|
||||
className="z-50 w-fit rounded-xl"
|
||||
collisionPadding={2}
|
||||
align="end"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
role="menuitem"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewDialogOpen(true);
|
||||
}}
|
||||
className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||
>
|
||||
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||
<span>{localize('com_ui_preview')}</span>
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={!canEdit}
|
||||
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||
className="cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditClick(e);
|
||||
}}
|
||||
>
|
||||
<EditIcon className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||
<span>{localize('com_ui_edit')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
|
|||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${group.name} prompt group`}
|
||||
aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 truncate pr-2">
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
|||
value={categoryFilter || SystemCategories.ALL}
|
||||
onChange={onSelect}
|
||||
options={filterOptions}
|
||||
className="bg-transparent"
|
||||
className="rounded-lg bg-transparent"
|
||||
icon={<ListFilter className="h-4 w-4" />}
|
||||
label="Filter: "
|
||||
ariaLabel={localize('com_ui_filter_prompts')}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function List({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
{isLoading && isChatRoute && (
|
||||
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const PreviewPrompt = ({
|
|||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
|
||||
<div className="p-2">
|
||||
<div>
|
||||
<PromptDetails group={group} />
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue