mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
🎛️ feat: Redesign Settings with Registry-Driven Dialog, Search, and Mobile Drill-In (#13722)
* i18n: add settings reorganization keys
* feat(settings): add tab/section types and tab metadata
* feat(settings): add useSettingsContext guard hook
* feat(settings): add pure settings search filter with tests
* feat(settings): extract selectors and add control wrappers
* feat(settings): add setting registry, memory and billing controls, integrity test
* feat(settings): add Section and Advanced disclosure with test
* feat(settings): add content pane with tab and search views
* feat(settings): add sidebar and dialog shell with tests
* refactor(settings): wire new dialog and remove superseded containers
* fix(settings): restore speech external engine option, escape-to-clear search, results a11y
- SpeechControls.tsx: read sttExternal/ttsExternal from useGetCustomConfigSpeechQuery
instead of hardcoding false, so external engine options appear on qualifying deployments
- Sidebar: Escape clears search input when non-empty, stops propagation to avoid closing dialog
- Content: persistent aria-live="polite" wrapper covers both populated results and empty state
- context: useMemo on returned ctx object so Content's useMemo deps are referentially stable
- locales/README.md: update stale path from deleted General.tsx to Selectors.tsx
* refactor(settings): reorganize categories, remove advanced disclosure, add About
- Re-categorize settings into logical groups (username display -> Chat/Messages,
keep-screen-awake -> Accessibility, fork/prompts surfaced into Chat sections)
- Dissolve thin Personalization tab; move Memory into Data & Privacy
- Remove the Advanced collapsible; all settings always visible, destructive
actions grouped in an always-visible Danger zone
- Wire the new About tab into the registry-driven dialog
- Standardize spacing with bordered, evenly-divided section cards
- Use semantic text-text-* / border tokens so dark mode renders correctly
- Sync LangSelector language-loading indicator from dev
* feat(settings): move archived chats to the account menu
Add an Archived chats item to the account dropdown next to My Files,
opening the archived chats table in a modal. Removes it from the
settings dialog where it no longer fit the data/privacy grouping.
* feat(settings): polish About panel and use shared CopyButton
- Flatten the build-info into a single divided key/value list (drop the
redundant inner card now that it sits inside a section card)
- Replace the hand-rolled copy button with the shared animated CopyButton
- Shorten the copied label so it fits the button without clipping
* fix(settings): set primary text color on setting rows for dark mode
Leaf control labels rendered without a text color and fell back to the
browser default (black), making them invisible on the dark panel. Set
text-text-primary on the section and search-results row containers so
labels inherit a visible color, matching the old container behavior.
* fix(settings): use visible icon for dialog close button
The plain multiplication-sign close button had no text color and was
invisible on the dark panel. Replace it with the lucide X icon using
text-text-secondary/hover:text-text-primary so it shows in both themes.
* fix(nav): drop focus ring on account menu items, use hover background only
The account-settings popover drew a 2px ring around the active menu item.
Remove that override so items show only the standard hover background,
consistent with every other menu.
* fix(settings): replace native search clear with a real X button
The settings search used type=search, whose native WebKit clear control
rendered as a blue X. Switch to a text input and add a real lucide X
clear button styled text-text-secondary, shown only when there's a query.
* fix(speech): disable dependent dropdowns and switches when STT/TTS is off
Add a disabled prop to the shared Dropdown component, then gate the
speech engine/voice/language dropdowns and the automatic-playback switch
on their parent toggle (speechToText / textToSpeech), matching the
controls that already disabled correctly.
* feat(settings): mobile drill-in navigation for settings tabs
On small screens the horizontal scrolling tab row is replaced with a
full-width vertical list (with chevrons); tapping a tab drills into its
content with a Back header. Searching shows results full-width. Desktop
keeps the side-by-side sidebar + content layout unchanged.
* chore(settings): remove orphaned i18n keys, fix import order and review notes
- Drop the i18n keys left unused after the refactor (old Commands/Balance/
Personalization tab labels, the Speech simple/advanced labels, and the
former About section headings)
- Sort imports in the rebased files the lint-staged hook never touched
- Guard the language fallback against an empty navigator.languages
- Import the RefObject type instead of leaning on the React namespace
* feat(settings): searchable language dropdown
Add an opt-in searchable mode to the shared Dropdown (Ariakit Select +
Combobox) and use it for the language selector, which has 40+ options.
The trigger styling is unchanged so it stays consistent with the other
settings rows; only the popover gains a filter input.
Accessibility: the filtered listbox is labeled, the empty state is moved
out of the listbox and announced via an aria-live status region, and the
decorative selected-state checkmark is hidden from assistive tech.
* fix(settings): restore guards dropped in dialog refactor
- Fall back to the General tab when the active tab becomes hidden
(e.g. About when buildInfo is disabled) instead of rendering an
empty panel.
- Normalize a deprecated/invalid engineTTS (e.g. 'edge') back to
browser during speech init so read-aloud controls keep rendering.
- Hide the cloud browser voices toggle unless Browser TTS is active.
* test(e2e): match agent-creation toast exactly to avoid SR-announce collision
The agent builder spec asserted the creation toast with a non-exact
getByText, which also matched Radix Toast's transient role="status"
announce region ("Notification Successfully created ..."), causing a
strict-mode violation. Mirror the mcp spec by using { exact: true }.
* fix(settings): render the active panel as a tabpanel
Wrap the non-search settings body in Tabs.Content so the selected
panel gets role=tabpanel with Radix's id/aria-labelledby wiring,
resolving the aria-controls target on each tab trigger. Search
results stay a labeled live region (the tab list is hidden during
mobile search, so a tabpanel aria-labelledby would dangle).
This commit is contained in:
parent
d8474864e9
commit
9de3249e9c
48 changed files with 1808 additions and 1392 deletions
|
|
@ -6,7 +6,7 @@ import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
|
|||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function BrowserVoiceDropdown() {
|
||||
export function BrowserVoiceDropdown({ disabled = false }: { disabled?: boolean }) {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSBrowser();
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
|
|
@ -33,12 +33,13 @@ export function BrowserVoiceDropdown() {
|
|||
testId="BrowserVoiceDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalVoiceDropdown() {
|
||||
export function ExternalVoiceDropdown({ disabled = false }: { disabled?: boolean }) {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSExternal();
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
|
|
@ -65,6 +66,7 @@ export function ExternalVoiceDropdown() {
|
|||
testId="ExternalVoiceDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, memo, useRef } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { FileText, LogOut } from 'lucide-react';
|
||||
import { FileText, Archive, LogOut } from 'lucide-react';
|
||||
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
|
||||
import { ArchivedChatsModal } from '~/components/Nav/SettingsTabs/General/ArchivedChatsModal';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
|
|
@ -17,6 +18,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
|
|
@ -72,6 +74,10 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
<FileText className="icon-md" aria-hidden="true" />
|
||||
{localize('com_nav_my_files')}
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => setShowArchived(true)} className="select-item text-sm">
|
||||
<Archive className="icon-md" aria-hidden="true" />
|
||||
{localize('com_nav_archived_chats')}
|
||||
</Menu.MenuItem>
|
||||
{startupConfig?.helpAndFaqURL !== '/' && (
|
||||
<Menu.MenuItem
|
||||
onClick={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
|
||||
|
|
@ -98,6 +104,13 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
triggerRef={accountSettingsButtonRef}
|
||||
/>
|
||||
)}
|
||||
{showArchived && (
|
||||
<ArchivedChatsModal
|
||||
open={showArchived}
|
||||
onOpenChange={setShowArchived}
|
||||
triggerRef={accountSettingsButtonRef}
|
||||
/>
|
||||
)}
|
||||
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
|
||||
</Menu.MenuProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Settings from './Settings';
|
||||
|
||||
const mockUseGetStartupConfig = jest.fn();
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/usePersonalizationAccess', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
hasMemoryOptOut: false,
|
||||
hasAnyPersonalizationFeature: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
GearIcon: () => <span aria-hidden="true" />,
|
||||
DataIcon: () => <span aria-hidden="true" />,
|
||||
UserIcon: () => <span aria-hidden="true" />,
|
||||
SpeechIcon: () => <span aria-hidden="true" />,
|
||||
PersonalizationIcon: () => <span aria-hidden="true" />,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('./SettingsTabs', () => ({
|
||||
General: () => <div data-testid="general-panel" />,
|
||||
Chat: () => <div data-testid="chat-panel" />,
|
||||
Commands: () => <div data-testid="commands-panel" />,
|
||||
Speech: () => <div data-testid="speech-panel" />,
|
||||
Personalization: () => <div data-testid="personalization-panel" />,
|
||||
Data: () => <div data-testid="data-panel" />,
|
||||
Balance: () => <div data-testid="balance-panel" />,
|
||||
Account: () => <div data-testid="account-panel" />,
|
||||
About: () => <div data-testid="about-panel" />,
|
||||
}));
|
||||
|
||||
function renderSettings() {
|
||||
return render(<Settings open={true} onOpenChange={jest.fn()} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('shows the About tab while startup config is loading', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(screen.getByText('com_nav_setting_about')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the About tab only when buildInfo is explicitly disabled', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(screen.queryByText('com_nav_setting_about')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets the active tab when loaded config disables About', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderSettings();
|
||||
|
||||
await user.click(screen.getByText('com_nav_setting_about'));
|
||||
expect(screen.getByTestId('about-panel')).toBeInTheDocument();
|
||||
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
|
||||
rerender(<Settings open={true} onOpenChange={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('about-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('general-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,286 +1,6 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import { MessageSquare, Command, DollarSign, Info } from 'lucide-react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import {
|
||||
GearIcon,
|
||||
DataIcon,
|
||||
UserIcon,
|
||||
SpeechIcon,
|
||||
useMediaQuery,
|
||||
PersonalizationIcon,
|
||||
} from '@librechat/client';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import {
|
||||
General,
|
||||
Chat,
|
||||
Commands,
|
||||
Speech,
|
||||
Personalization,
|
||||
Data,
|
||||
Balance,
|
||||
Account,
|
||||
About,
|
||||
} from './SettingsTabs';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { cn } from '~/utils';
|
||||
import { SettingsDialog } from './Settings/index';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const localize = useLocalize();
|
||||
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
|
||||
const tabRefs = useRef({});
|
||||
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
|
||||
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!aboutEnabled && activeTab === SettingsTabValues.ABOUT) {
|
||||
setActiveTab(SettingsTabValues.GENERAL);
|
||||
}
|
||||
}, [aboutEnabled, activeTab]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const tabs: SettingsTabValues[] = [
|
||||
SettingsTabValues.GENERAL,
|
||||
SettingsTabValues.CHAT,
|
||||
SettingsTabValues.COMMANDS,
|
||||
SettingsTabValues.SPEECH,
|
||||
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
|
||||
SettingsTabValues.DATA,
|
||||
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
|
||||
SettingsTabValues.ACCOUNT,
|
||||
...(aboutEnabled ? [SettingsTabValues.ABOUT] : []),
|
||||
];
|
||||
const currentIndex = tabs.indexOf(activeTab);
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setActiveTab(tabs[(currentIndex + 1) % tabs.length]);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setActiveTab(tabs[0]);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setActiveTab(tabs[tabs.length - 1]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const settingsTabs: {
|
||||
value: SettingsTabValues;
|
||||
icon: React.JSX.Element;
|
||||
label: TranslationKeys;
|
||||
}[] = [
|
||||
{
|
||||
value: SettingsTabValues.GENERAL,
|
||||
icon: <GearIcon />,
|
||||
label: 'com_nav_setting_general',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.CHAT,
|
||||
icon: <MessageSquare className="icon-sm" aria-hidden="true" />,
|
||||
label: 'com_nav_setting_chat',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.COMMANDS,
|
||||
icon: <Command className="icon-sm" aria-hidden="true" />,
|
||||
label: 'com_nav_commands',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.SPEECH,
|
||||
icon: <SpeechIcon className="icon-sm" aria-hidden="true" />,
|
||||
label: 'com_nav_setting_speech',
|
||||
},
|
||||
...(hasAnyPersonalizationFeature
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.PERSONALIZATION,
|
||||
icon: <PersonalizationIcon />,
|
||||
label: 'com_nav_setting_personalization' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: SettingsTabValues.DATA,
|
||||
icon: <DataIcon />,
|
||||
label: 'com_nav_setting_data',
|
||||
},
|
||||
...(startupConfig?.balance?.enabled
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.BALANCE,
|
||||
icon: <DollarSign size={18} />,
|
||||
label: 'com_nav_setting_balance' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
|
||||
{
|
||||
value: SettingsTabValues.ACCOUNT,
|
||||
icon: <UserIcon />,
|
||||
label: 'com_nav_setting_account',
|
||||
},
|
||||
...(aboutEnabled
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.ABOUT,
|
||||
icon: <Info className="icon-sm" aria-hidden="true" />,
|
||||
label: 'com_nav_setting_about' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
|
||||
];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as SettingsTabValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={open}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onOpenChange}>
|
||||
<TransitionChild
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black opacity-50 dark:opacity-80" aria-hidden="true" />
|
||||
</TransitionChild>
|
||||
|
||||
<TransitionChild
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className={cn('fixed inset-0 flex w-screen items-center justify-center p-4')}>
|
||||
<DialogPanel
|
||||
className={cn(
|
||||
'max-h-[90vh] overflow-hidden rounded-xl rounded-b-lg bg-background pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-2xl md:w-[680px]',
|
||||
)}
|
||||
>
|
||||
<DialogTitle
|
||||
className="mb-1 flex items-center justify-between p-6 pb-5 text-left"
|
||||
as="div"
|
||||
>
|
||||
<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-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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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>
|
||||
</svg>
|
||||
<span className="sr-only">{localize('com_ui_close_settings')}</span>
|
||||
</button>
|
||||
</DialogTitle>
|
||||
<div className="max-h-[calc(90vh-120px)] overflow-auto px-6 md:w-[680px]">
|
||||
<Tabs.Root
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col gap-10 md:flex-row"
|
||||
orientation="vertical"
|
||||
>
|
||||
<Tabs.List
|
||||
aria-label="Settings"
|
||||
className={cn(
|
||||
'min-w-auto max-w-auto relative -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
|
||||
isSmallScreen
|
||||
? 'flex-row rounded-xl bg-surface-secondary'
|
||||
: 'sticky top-0 h-full',
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{settingsTabs.map(({ value, icon, label }) => (
|
||||
<Tabs.Trigger
|
||||
key={value}
|
||||
className={cn(
|
||||
'group relative z-10 m-1 flex items-center justify-start gap-2 rounded-xl px-2 py-1.5 transition-all duration-200 ease-in-out',
|
||||
isSmallScreen
|
||||
? 'flex-1 justify-center text-nowrap p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
|
||||
: 'bg-transparent text-text-secondary radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
|
||||
)}
|
||||
value={value}
|
||||
ref={(el) => (tabRefs.current[value] = el)}
|
||||
>
|
||||
{icon}
|
||||
{localize(label)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</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} tabIndex={-1}>
|
||||
<General />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
|
||||
<Chat />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
|
||||
<Commands />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
|
||||
<Speech />
|
||||
</Tabs.Content>
|
||||
{hasAnyPersonalizationFeature && (
|
||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
|
||||
<Personalization
|
||||
hasMemoryOptOut={hasMemoryOptOut}
|
||||
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
|
||||
<Data />
|
||||
</Tabs.Content>
|
||||
{startupConfig?.balance?.enabled && (
|
||||
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
|
||||
<Balance />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
|
||||
<Account />
|
||||
</Tabs.Content>
|
||||
{aboutEnabled && (
|
||||
<Tabs.Content value={SettingsTabValues.ABOUT} tabIndex={-1}>
|
||||
<About />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
export default function Settings(props: TDialogProps) {
|
||||
return <SettingsDialog {...props} />;
|
||||
}
|
||||
|
|
|
|||
61
client/src/components/Nav/Settings/BillingControls.tsx
Normal file
61
client/src/components/Nav/Settings/BillingControls.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { TBalanceResponse } from 'librechat-data-provider';
|
||||
import AutoRefillSettings from '../SettingsTabs/Balance/AutoRefillSettings';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import TokenCreditsItem from '../SettingsTabs/Balance/TokenCreditsItem';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
|
||||
function useBalance(): Partial<TBalanceResponse> {
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const balanceQuery = useGetUserBalance({
|
||||
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
|
||||
});
|
||||
|
||||
return balanceQuery.data ?? {};
|
||||
}
|
||||
|
||||
export function TokenCredits() {
|
||||
const { tokenCredits = 0 } = useBalance();
|
||||
return <TokenCreditsItem tokenCredits={tokenCredits} />;
|
||||
}
|
||||
|
||||
export function AutoRefill() {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
autoRefillEnabled = false,
|
||||
lastRefill,
|
||||
refillAmount,
|
||||
refillIntervalUnit,
|
||||
refillIntervalValue,
|
||||
} = useBalance();
|
||||
|
||||
const hasValidRefillSettings =
|
||||
lastRefill !== undefined &&
|
||||
refillAmount !== undefined &&
|
||||
refillIntervalUnit !== undefined &&
|
||||
refillIntervalValue !== undefined;
|
||||
|
||||
if (!autoRefillEnabled) {
|
||||
return (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_nav_balance_auto_refill_disabled')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasValidRefillSettings) {
|
||||
return (
|
||||
<div className="text-sm text-red-500">{localize('com_nav_balance_auto_refill_error')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoRefillSettings
|
||||
lastRefill={lastRefill}
|
||||
refillAmount={refillAmount}
|
||||
refillIntervalUnit={refillIntervalUnit}
|
||||
refillIntervalValue={refillIntervalValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
81
client/src/components/Nav/Settings/Content.tsx
Normal file
81
client/src/components/Nav/Settings/Content.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { SettingsContextValue, SettingsTab, SettingEntry } from './types';
|
||||
import { filterSettings } from './search';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { registry } from './registry';
|
||||
import Section from './Section';
|
||||
import { TABS } from './types';
|
||||
|
||||
interface ContentProps {
|
||||
activeTab: SettingsTab;
|
||||
query: string;
|
||||
ctx: SettingsContextValue;
|
||||
}
|
||||
|
||||
function visible(entry: SettingEntry, ctx: SettingsContextValue): boolean {
|
||||
return !entry.show || entry.show(ctx);
|
||||
}
|
||||
|
||||
export default function Content({ activeTab, query, ctx }: ContentProps) {
|
||||
const localize = useLocalize();
|
||||
const tab = TABS.find((t) => t.id === activeTab)!;
|
||||
|
||||
const results = useMemo(
|
||||
() => (query.trim() ? filterSettings(registry, query, ctx, localize) : null),
|
||||
[query, ctx, localize],
|
||||
);
|
||||
|
||||
if (results !== null) {
|
||||
return (
|
||||
<div aria-label={localize('com_ui_settings_results_aria')} aria-live="polite">
|
||||
{results.length === 0 ? (
|
||||
<p className="p-2 text-sm text-text-secondary">
|
||||
{localize('com_ui_settings_no_results')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light overflow-hidden rounded-xl border border-border-light text-sm text-text-primary">
|
||||
{results.map(({ entry, label }) => {
|
||||
const Cmp = entry.Component;
|
||||
const tabMeta = TABS.find((t) => t.id === entry.tab)!;
|
||||
const sectionMeta = tabMeta.sections.find((s) => s.id === entry.section);
|
||||
return (
|
||||
<div key={entry.id} className="px-4 py-3">
|
||||
<div className="mb-1.5 text-xs text-text-tertiary">
|
||||
{localize(tabMeta.labelKey)} ›{' '}
|
||||
{sectionMeta ? localize(sectionMeta.labelKey) : label}
|
||||
</div>
|
||||
<Cmp />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tab.sections.map((section) => {
|
||||
const entries = registry.filter(
|
||||
(e) => e.tab === activeTab && e.section === section.id && visible(e, ctx),
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Section key={section.id} heading={localize(section.labelKey)} danger={section.danger}>
|
||||
{entries.map((e) => {
|
||||
const Cmp = e.Component;
|
||||
return (
|
||||
<div key={e.id} className="px-4 py-3">
|
||||
<Cmp />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
client/src/components/Nav/Settings/Dialog.tsx
Normal file
138
client/src/components/Nav/Settings/Dialog.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useState } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { X, ChevronLeft } from 'lucide-react';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import type { SettingsTab } from './types';
|
||||
import { useSettingsContext } from './context';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Sidebar from './Sidebar';
|
||||
import Content from './Content';
|
||||
import { TABS } from './types';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function SettingsDialog({ open, onOpenChange }: TDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const ctx = useSettingsContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>(SettingsTabValues.GENERAL);
|
||||
const [query, setQuery] = useState('');
|
||||
const [mobileDetail, setMobileDetail] = useState(false);
|
||||
|
||||
const searching = query.trim().length > 0;
|
||||
const inDetail = isSmallScreen && mobileDetail && !searching;
|
||||
const showSidebar = !isSmallScreen || !inDetail;
|
||||
const showContent = !isSmallScreen || inDetail || searching;
|
||||
const hideTabs = isSmallScreen && searching;
|
||||
const visibleTabs = TABS.filter((t) => !t.show || t.show(ctx));
|
||||
const effectiveTab = visibleTabs.some((t) => t.id === activeTab)
|
||||
? activeTab
|
||||
: SettingsTabValues.GENERAL;
|
||||
const activeMeta = TABS.find((t) => t.id === effectiveTab);
|
||||
|
||||
const selectTab = (tab: SettingsTab) => {
|
||||
setActiveTab(tab);
|
||||
if (isSmallScreen) {
|
||||
setMobileDetail(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={open}>
|
||||
<Dialog as="div" className="relative z-50" onClose={() => onOpenChange(false)}>
|
||||
<TransitionChild
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black opacity-50 dark:opacity-80" aria-hidden="true" />
|
||||
</TransitionChild>
|
||||
<TransitionChild
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
className={cn(
|
||||
'flex max-h-[85vh] w-full flex-col overflow-hidden rounded-2xl bg-background shadow-2xl',
|
||||
'md:h-[85vh] md:w-[900px]',
|
||||
)}
|
||||
>
|
||||
<DialogTitle
|
||||
as="div"
|
||||
className="flex items-center justify-between border-b border-border-light p-5"
|
||||
>
|
||||
{inDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileDetail(false)}
|
||||
className="-ml-1 flex items-center gap-1 rounded-lg p-1 text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-border-xheavy"
|
||||
aria-label={localize('com_ui_back')}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-lg font-medium">
|
||||
{activeMeta ? localize(activeMeta.labelKey) : localize('com_nav_settings')}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<h2 className="text-lg font-medium text-text-primary">
|
||||
{localize('com_nav_settings')}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-lg p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">{localize('com_ui_close_settings')}</span>
|
||||
</button>
|
||||
</DialogTitle>
|
||||
<Tabs.Root
|
||||
value={effectiveTab}
|
||||
onValueChange={(v) => setActiveTab(v as SettingsTab)}
|
||||
orientation="vertical"
|
||||
className="flex flex-1 flex-col gap-4 overflow-hidden p-5 md:flex-row md:gap-6"
|
||||
>
|
||||
{showSidebar && (
|
||||
<Sidebar
|
||||
ctx={ctx}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onSelectTab={selectTab}
|
||||
showChevron={isSmallScreen}
|
||||
hideTabs={hideTabs}
|
||||
/>
|
||||
)}
|
||||
{showContent && (
|
||||
<div className="flex-1 overflow-y-auto md:pr-1">
|
||||
{searching ? (
|
||||
<Content activeTab={effectiveTab} query={query} ctx={ctx} />
|
||||
) : (
|
||||
<Tabs.Content
|
||||
value={effectiveTab}
|
||||
tabIndex={-1}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Content activeTab={effectiveTab} query={query} ctx={ctx} />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Tabs.Root>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
49
client/src/components/Nav/Settings/MemoryToggle.tsx
Normal file
49
client/src/components/Nav/Settings/MemoryToggle.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Switch, useToastContext } from '@librechat/client';
|
||||
import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function MemoryToggle() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: user } = useGetUserQuery();
|
||||
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
|
||||
|
||||
const mutation = useUpdateMemoryPreferencesMutation({
|
||||
onSuccess: () =>
|
||||
showToast({ message: localize('com_ui_preferences_updated'), status: 'success' }),
|
||||
onError: () => {
|
||||
showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' });
|
||||
setReferenceSavedMemories((prev) => !prev);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.personalization?.memories !== undefined) {
|
||||
setReferenceSavedMemories(user.personalization.memories);
|
||||
}
|
||||
}, [user?.personalization?.memories]);
|
||||
|
||||
const onToggle = (checked: boolean) => {
|
||||
setReferenceSavedMemories(checked);
|
||||
mutation.mutate({ memories: checked });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div id="reference-saved-memories-label">{localize('com_ui_reference_saved_memories')}</div>
|
||||
<div id="reference-saved-memories-description" className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_reference_saved_memories_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={referenceSavedMemories}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={mutation.isLoading}
|
||||
aria-labelledby="reference-saved-memories-label"
|
||||
aria-describedby="reference-saved-memories-description"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
client/src/components/Nav/Settings/Section.tsx
Normal file
31
client/src/components/Nav/Settings/Section.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface SectionProps {
|
||||
heading: string;
|
||||
danger?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Section({ heading, danger, children }: SectionProps) {
|
||||
return (
|
||||
<section className="mb-7">
|
||||
<h3
|
||||
className={cn(
|
||||
'mb-2 px-1 text-xs font-semibold uppercase tracking-wide',
|
||||
danger ? 'text-red-500' : 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{heading}
|
||||
</h3>
|
||||
<div
|
||||
className={cn(
|
||||
'divide-y divide-border-light overflow-hidden rounded-xl border text-sm text-text-primary',
|
||||
danger ? 'border-red-500/30' : 'border-border-light',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
92
client/src/components/Nav/Settings/Sidebar.tsx
Normal file
92
client/src/components/Nav/Settings/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Search, X, ChevronRight } from 'lucide-react';
|
||||
import type { SettingsContextValue, SettingsTab } from './types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { TABS } from './types';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface SidebarProps {
|
||||
ctx: SettingsContextValue;
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
onSelectTab: (tab: SettingsTab) => void;
|
||||
showChevron?: boolean;
|
||||
hideTabs?: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
ctx,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSelectTab,
|
||||
showChevron = false,
|
||||
hideTabs = false,
|
||||
}: SidebarProps) {
|
||||
const localize = useLocalize();
|
||||
const tabs = TABS.filter((t) => !t.show || t.show(ctx));
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 md:w-[230px]">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && query.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQueryChange('');
|
||||
}
|
||||
}}
|
||||
placeholder={localize('com_ui_settings_search_placeholder')}
|
||||
aria-label={localize('com_ui_settings_search_placeholder')}
|
||||
className="w-full rounded-lg bg-surface-secondary py-2 pl-8 pr-8 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQueryChange('')}
|
||||
aria-label={localize('com_ui_clear_search')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!hideTabs && (
|
||||
<Tabs.List
|
||||
aria-label={localize('com_nav_settings')}
|
||||
className="flex flex-col gap-1 overflow-visible"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Trigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-sm text-text-secondary transition-colors hover:bg-surface-hover md:py-2',
|
||||
'radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.icon}
|
||||
<span className="whitespace-nowrap">{localize(tab.labelKey)}</span>
|
||||
</span>
|
||||
{showChevron && (
|
||||
<ChevronRight
|
||||
className="h-4 w-4 flex-shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
client/src/components/Nav/Settings/SpeechControls.tsx
Normal file
13
client/src/components/Nav/Settings/SpeechControls.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
|
||||
import { EngineSTTDropdown } from '../SettingsTabs/Speech/STT';
|
||||
import { EngineTTSDropdown } from '../SettingsTabs/Speech/TTS';
|
||||
|
||||
export function EngineSTTSetting() {
|
||||
const { data } = useGetCustomConfigSpeechQuery();
|
||||
return <EngineSTTDropdown external={Boolean(data?.sttExternal)} />;
|
||||
}
|
||||
|
||||
export function EngineTTSSetting() {
|
||||
const { data } = useGetCustomConfigSpeechQuery();
|
||||
return <EngineTTSDropdown external={Boolean(data?.ttsExternal)} />;
|
||||
}
|
||||
38
client/src/components/Nav/Settings/__tests__/Dialog.spec.tsx
Normal file
38
client/src/components/Nav/Settings/__tests__/Dialog.spec.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* eslint-disable i18next/no-literal-string */
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from 'test/layout-test-utils';
|
||||
import { SettingsDialog } from '../index';
|
||||
|
||||
jest.mock('../Content', () =>
|
||||
jest.fn(({ query }: { activeTab: string; query: string; ctx: unknown }) =>
|
||||
query.trim() ? (
|
||||
<div aria-label="Search results" aria-live="polite" />
|
||||
) : (
|
||||
<div>
|
||||
<h3>Appearance</h3>
|
||||
</div>
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
describe('SettingsDialog', () => {
|
||||
it('renders the General tab by default with its sections', () => {
|
||||
render(<SettingsDialog open onOpenChange={jest.fn()} />);
|
||||
expect(screen.getByRole('heading', { name: /settings/i, level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByText('Appearance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the active panel as a tabpanel wired to its trigger', () => {
|
||||
render(<SettingsDialog open onOpenChange={jest.fn()} />);
|
||||
const panel = screen.getByRole('tabpanel');
|
||||
const trigger = screen.getByRole('tab', { selected: true });
|
||||
expect(trigger).toHaveAttribute('aria-controls', panel.id);
|
||||
expect(panel).toHaveAttribute('aria-labelledby', trigger.id);
|
||||
});
|
||||
|
||||
it('switches to search results when typing a query', async () => {
|
||||
render(<SettingsDialog open onOpenChange={jest.fn()} />);
|
||||
await userEvent.type(screen.getByRole('textbox'), 'language');
|
||||
expect(await screen.findByLabelText('Search results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import type { SettingsContextValue } from '../types';
|
||||
import { render, screen } from 'test/layout-test-utils';
|
||||
import Sidebar from '../Sidebar';
|
||||
|
||||
const ctx: SettingsContextValue = {
|
||||
balanceEnabled: false,
|
||||
hasAnyPersonalizationFeature: false,
|
||||
hasMemoryOptOut: false,
|
||||
hasRemoteAgents: false,
|
||||
hasMultiConvo: false,
|
||||
hasPrompts: false,
|
||||
isLocalProvider: true,
|
||||
twoFactorEnabled: false,
|
||||
allowAccountDeletion: true,
|
||||
aboutEnabled: false,
|
||||
engineTTS: 'browser',
|
||||
};
|
||||
|
||||
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {
|
||||
const onQueryChange = jest.fn();
|
||||
render(
|
||||
<Tabs.Root value={SettingsTabValues.GENERAL}>
|
||||
<Sidebar
|
||||
ctx={{ ...ctx, ...extra }}
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
onSelectTab={jest.fn()}
|
||||
/>
|
||||
</Tabs.Root>,
|
||||
);
|
||||
return { onQueryChange };
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('hides the About tab when build info is disabled', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('About')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the About tab when build info is enabled', () => {
|
||||
setup({ aboutEnabled: true });
|
||||
expect(screen.getByText('About')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards typing to onQueryChange', async () => {
|
||||
const { onQueryChange } = setup();
|
||||
await userEvent.type(screen.getByRole('textbox'), 'theme');
|
||||
expect(onQueryChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Escape clears search when query is non-empty', async () => {
|
||||
const { onQueryChange } = setup({}, 'theme');
|
||||
const input = screen.getByRole('textbox');
|
||||
input.focus();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(onQueryChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('Escape does not call onQueryChange when query is empty', async () => {
|
||||
const { onQueryChange } = setup({}, '');
|
||||
const input = screen.getByRole('textbox');
|
||||
input.focus();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a clear button that clears the query when there is text', async () => {
|
||||
const { onQueryChange } = setup({}, 'theme');
|
||||
await userEvent.click(screen.getByRole('button', { name: /clear search/i }));
|
||||
expect(onQueryChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('hides the clear button when the query is empty', () => {
|
||||
setup({}, '');
|
||||
expect(screen.queryByRole('button', { name: /clear search/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { isValidElementType } from 'react-is';
|
||||
import en from '~/locales/en/translation.json';
|
||||
import { registry } from '../registry';
|
||||
import { TABS } from '../types';
|
||||
|
||||
const validTabSections = new Map(TABS.map((t) => [t.id, new Set(t.sections.map((s) => s.id))]));
|
||||
|
||||
describe('settings registry', () => {
|
||||
it('has unique ids', () => {
|
||||
const ids = registry.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('references a valid tab and section for every entry', () => {
|
||||
for (const entry of registry) {
|
||||
const sections = validTabSections.get(entry.tab);
|
||||
expect(sections).toBeDefined();
|
||||
expect(sections!.has(entry.section)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses label keys that exist in the English locale', () => {
|
||||
for (const entry of registry) {
|
||||
expect(en).toHaveProperty(entry.labelKey);
|
||||
}
|
||||
});
|
||||
|
||||
it('has a renderable Component for every entry', () => {
|
||||
for (const entry of registry) {
|
||||
expect(isValidElementType(entry.Component)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
30
client/src/components/Nav/Settings/__tests__/search.spec.ts
Normal file
30
client/src/components/Nav/Settings/__tests__/search.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { normalize, matchesQuery } from '../search';
|
||||
|
||||
describe('normalize', () => {
|
||||
it('lowercases and strips diacritics', () => {
|
||||
expect(normalize('Café')).toBe('cafe');
|
||||
expect(normalize(' Théme ')).toBe('theme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesQuery', () => {
|
||||
const haystack = { label: 'Theme', keywords: ['dark', 'light', 'appearance'] };
|
||||
|
||||
it('matches the label case-insensitively', () => {
|
||||
expect(matchesQuery('the', haystack)).toBe(true);
|
||||
expect(matchesQuery('THEME', haystack)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a keyword synonym', () => {
|
||||
expect(matchesQuery('dark', haystack)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when nothing matches', () => {
|
||||
expect(matchesQuery('zzz', haystack)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches everything on empty query', () => {
|
||||
expect(matchesQuery('', haystack)).toBe(true);
|
||||
expect(matchesQuery(' ', haystack)).toBe(true);
|
||||
});
|
||||
});
|
||||
66
client/src/components/Nav/Settings/context.tsx
Normal file
66
client/src/components/Nav/Settings/context.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { SettingsContextValue } from './types';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
import { useHasAccess, useAuthContext } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
export function useSettingsContext(): SettingsContextValue {
|
||||
const { user } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
|
||||
|
||||
const hasRemoteAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.REMOTE_AGENTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasMultiConvo = useHasAccess({
|
||||
permissionType: PermissionTypes.MULTI_CONVO,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasPrompts = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const balanceEnabled = startupConfig?.balance?.enabled === true;
|
||||
const isLocalProvider = user?.provider === 'local';
|
||||
const twoFactorEnabled = user?.twoFactorEnabled === true;
|
||||
const allowAccountDeletion = startupConfig?.allowAccountDeletion !== false;
|
||||
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
|
||||
const hasRemoteAgentsBool = hasRemoteAgents === true;
|
||||
const hasMultiConvoBool = hasMultiConvo === true;
|
||||
const hasPromptsBool = hasPrompts === true;
|
||||
const engineTTS = useRecoilValue<string>(store.engineTTS);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
balanceEnabled,
|
||||
hasAnyPersonalizationFeature,
|
||||
hasMemoryOptOut,
|
||||
hasRemoteAgents: hasRemoteAgentsBool,
|
||||
hasMultiConvo: hasMultiConvoBool,
|
||||
hasPrompts: hasPromptsBool,
|
||||
isLocalProvider,
|
||||
twoFactorEnabled,
|
||||
allowAccountDeletion,
|
||||
aboutEnabled,
|
||||
engineTTS,
|
||||
}),
|
||||
[
|
||||
balanceEnabled,
|
||||
hasAnyPersonalizationFeature,
|
||||
hasMemoryOptOut,
|
||||
hasRemoteAgentsBool,
|
||||
hasMultiConvoBool,
|
||||
hasPromptsBool,
|
||||
isLocalProvider,
|
||||
twoFactorEnabled,
|
||||
allowAccountDeletion,
|
||||
aboutEnabled,
|
||||
engineTTS,
|
||||
],
|
||||
);
|
||||
}
|
||||
50
client/src/components/Nav/Settings/controls.tsx
Normal file
50
client/src/components/Nav/Settings/controls.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useContext, useCallback } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ThemeContext } from '@librechat/client';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { ThemeSelector, LangSelector } from '../SettingsTabs/General/Selectors';
|
||||
import ToggleSwitch from '../SettingsTabs/ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export function toggleControl(opts: {
|
||||
stateAtom: Parameters<typeof ToggleSwitch>[0]['stateAtom'];
|
||||
localizationKey: TranslationKeys;
|
||||
switchId: string;
|
||||
hoverCardText?: TranslationKeys;
|
||||
}): ComponentType {
|
||||
const Control = () => (
|
||||
<ToggleSwitch
|
||||
stateAtom={opts.stateAtom}
|
||||
localizationKey={opts.localizationKey}
|
||||
switchId={opts.switchId}
|
||||
hoverCardText={opts.hoverCardText}
|
||||
/>
|
||||
);
|
||||
Control.displayName = `Toggle(${opts.switchId})`;
|
||||
return Control;
|
||||
}
|
||||
|
||||
export function ThemeSetting() {
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const onChange = useCallback((value: string) => setTheme(value), [setTheme]);
|
||||
return <ThemeSelector theme={theme} onChange={onChange} />;
|
||||
}
|
||||
|
||||
export function LangSetting() {
|
||||
const [langcode, setLangcode] = useRecoilState(store.lang);
|
||||
const onChange = useCallback(
|
||||
(value: string) => {
|
||||
const userLang =
|
||||
value === 'auto' ? navigator.language || navigator.languages?.[0] || 'en-US' : value;
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.lang = userLang;
|
||||
});
|
||||
setLangcode(userLang);
|
||||
Cookies.set('lang', userLang, { expires: 365 });
|
||||
},
|
||||
[setLangcode],
|
||||
);
|
||||
return <LangSelector langcode={langcode} onChange={onChange} />;
|
||||
}
|
||||
1
client/src/components/Nav/Settings/index.ts
Normal file
1
client/src/components/Nav/Settings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as SettingsDialog } from './Dialog';
|
||||
562
client/src/components/Nav/Settings/registry.tsx
Normal file
562
client/src/components/Nav/Settings/registry.tsx
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import type { SettingEntry } from './types';
|
||||
import {
|
||||
TextToSpeechSwitch,
|
||||
VoiceDropdown,
|
||||
CacheTTSSwitch,
|
||||
AutomaticPlaybackSwitch,
|
||||
CloudBrowserVoicesSwitch,
|
||||
PlaybackRate,
|
||||
} from '../SettingsTabs/Speech/TTS';
|
||||
import {
|
||||
SpeechToTextSwitch,
|
||||
LanguageSTTDropdown,
|
||||
AutoTranscribeAudioSwitch,
|
||||
AutoSendTextSelector,
|
||||
DecibelSelector,
|
||||
} from '../SettingsTabs/Speech/STT';
|
||||
import DisplayUsernameMessages from '../SettingsTabs/Account/DisplayUsernameMessages';
|
||||
import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitch';
|
||||
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
|
||||
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
|
||||
import { toggleControl, ThemeSetting, LangSetting } from './controls';
|
||||
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
|
||||
import { EngineSTTSetting, EngineTTSSetting } from './SpeechControls';
|
||||
import FontSizeSelector from '../SettingsTabs/Chat/FontSizeSelector';
|
||||
import AdvancedPrompts from '../SettingsTabs/Chat/AdvancedPrompts';
|
||||
import DeleteAccount from '../SettingsTabs/Account/DeleteAccount';
|
||||
import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings';
|
||||
import { AgentApiKeys } from '../SettingsTabs/Data/AgentApiKeys';
|
||||
import ChatDirection from '../SettingsTabs/Chat/ChatDirection';
|
||||
import { DeleteCache } from '../SettingsTabs/Data/DeleteCache';
|
||||
import { RevokeKeys } from '../SettingsTabs/Data/RevokeKeys';
|
||||
import { ClearChats } from '../SettingsTabs/Data/ClearChats';
|
||||
import { TokenCredits, AutoRefill } from './BillingControls';
|
||||
import SharedLinks from '../SettingsTabs/Data/SharedLinks';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import Avatar from '../SettingsTabs/Account/Avatar';
|
||||
import About from '../SettingsTabs/About/About';
|
||||
import MemoryToggle from './MemoryToggle';
|
||||
import { TTSEndpoints } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
const { GENERAL, CHAT, SPEECH, DATA, ACCOUNT, ABOUT } = SettingsTabValues;
|
||||
|
||||
export const registry: SettingEntry[] = [
|
||||
// General · Appearance
|
||||
{
|
||||
id: 'theme',
|
||||
tab: GENERAL,
|
||||
section: 'appearance',
|
||||
labelKey: 'com_nav_theme',
|
||||
keywords: ['dark', 'light', 'appearance', 'color'],
|
||||
Component: ThemeSetting,
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
tab: GENERAL,
|
||||
section: 'appearance',
|
||||
labelKey: 'com_nav_language',
|
||||
keywords: ['locale', 'translation'],
|
||||
Component: LangSetting,
|
||||
},
|
||||
{
|
||||
id: 'fontSize',
|
||||
tab: GENERAL,
|
||||
section: 'appearance',
|
||||
labelKey: 'com_nav_font_size',
|
||||
keywords: ['text', 'zoom'],
|
||||
Component: FontSizeSelector,
|
||||
},
|
||||
{
|
||||
id: 'chatDirection',
|
||||
tab: GENERAL,
|
||||
section: 'appearance',
|
||||
labelKey: 'com_nav_chat_direction',
|
||||
keywords: ['rtl', 'ltr'],
|
||||
Component: ChatDirection,
|
||||
},
|
||||
// General · Layout
|
||||
{
|
||||
id: 'maximizeChatSpace',
|
||||
tab: GENERAL,
|
||||
section: 'layout',
|
||||
labelKey: 'com_nav_maximize_chat_space',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.maximizeChatSpace,
|
||||
localizationKey: 'com_nav_maximize_chat_space',
|
||||
switchId: 'maximizeChatSpace',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'centerFormOnLanding',
|
||||
tab: GENERAL,
|
||||
section: 'layout',
|
||||
labelKey: 'com_nav_center_chat_input',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.centerFormOnLanding,
|
||||
localizationKey: 'com_nav_center_chat_input',
|
||||
switchId: 'centerFormOnLanding',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'showScrollButton',
|
||||
tab: GENERAL,
|
||||
section: 'layout',
|
||||
labelKey: 'com_nav_scroll_button',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.showScrollButton,
|
||||
localizationKey: 'com_nav_scroll_button',
|
||||
switchId: 'showScrollButton',
|
||||
}),
|
||||
},
|
||||
// General · Accessibility
|
||||
{
|
||||
id: 'keepScreenAwake',
|
||||
tab: GENERAL,
|
||||
section: 'accessibility',
|
||||
labelKey: 'com_nav_keep_screen_awake',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.keepScreenAwake,
|
||||
localizationKey: 'com_nav_keep_screen_awake',
|
||||
switchId: 'keepScreenAwake',
|
||||
}),
|
||||
},
|
||||
|
||||
// Chat · Sending
|
||||
{
|
||||
id: 'enterToSend',
|
||||
tab: CHAT,
|
||||
section: 'sending',
|
||||
labelKey: 'com_nav_enter_to_send',
|
||||
keywords: ['return', 'newline'],
|
||||
Component: toggleControl({
|
||||
stateAtom: store.enterToSend,
|
||||
localizationKey: 'com_nav_enter_to_send',
|
||||
switchId: 'enterToSend',
|
||||
hoverCardText: 'com_nav_info_enter_to_send',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'saveDrafts',
|
||||
tab: CHAT,
|
||||
section: 'sending',
|
||||
labelKey: 'com_nav_save_drafts',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.saveDrafts,
|
||||
localizationKey: 'com_nav_save_drafts',
|
||||
switchId: 'saveDrafts',
|
||||
hoverCardText: 'com_nav_info_save_draft',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'saveBadgesState',
|
||||
tab: CHAT,
|
||||
section: 'sending',
|
||||
labelKey: 'com_nav_save_badges_state',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.saveBadgesState,
|
||||
localizationKey: 'com_nav_save_badges_state',
|
||||
switchId: 'showBadges',
|
||||
hoverCardText: 'com_nav_info_save_badges_state',
|
||||
}),
|
||||
},
|
||||
// Chat · Commands
|
||||
{
|
||||
id: 'atCommand',
|
||||
tab: CHAT,
|
||||
section: 'commands',
|
||||
labelKey: 'com_nav_at_command_description',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.atCommand,
|
||||
localizationKey: 'com_nav_at_command_description',
|
||||
switchId: 'atCommand',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'plusCommand',
|
||||
tab: CHAT,
|
||||
section: 'commands',
|
||||
labelKey: 'com_nav_plus_command_description',
|
||||
show: (ctx) => ctx.hasMultiConvo,
|
||||
Component: toggleControl({
|
||||
stateAtom: store.plusCommand,
|
||||
localizationKey: 'com_nav_plus_command_description',
|
||||
switchId: 'plusCommand',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'slashCommand',
|
||||
tab: CHAT,
|
||||
section: 'commands',
|
||||
labelKey: 'com_nav_slash_command_description',
|
||||
show: (ctx) => ctx.hasPrompts,
|
||||
Component: toggleControl({
|
||||
stateAtom: store.slashCommand,
|
||||
localizationKey: 'com_nav_slash_command_description',
|
||||
switchId: 'slashCommand',
|
||||
}),
|
||||
},
|
||||
// Chat · Messages
|
||||
{
|
||||
id: 'enableUserMsgMarkdown',
|
||||
tab: CHAT,
|
||||
section: 'messages',
|
||||
labelKey: 'com_nav_user_msg_markdown',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.enableUserMsgMarkdown,
|
||||
localizationKey: 'com_nav_user_msg_markdown',
|
||||
switchId: 'enableUserMsgMarkdown',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'usernameDisplay',
|
||||
tab: CHAT,
|
||||
section: 'messages',
|
||||
labelKey: 'com_nav_user_name_display',
|
||||
keywords: ['username', 'name'],
|
||||
Component: DisplayUsernameMessages,
|
||||
},
|
||||
{
|
||||
id: 'latexParsing',
|
||||
tab: CHAT,
|
||||
section: 'messages',
|
||||
labelKey: 'com_nav_latex_parsing',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.LaTeXParsing,
|
||||
localizationKey: 'com_nav_latex_parsing',
|
||||
switchId: 'latexParsing',
|
||||
hoverCardText: 'com_nav_info_latex_parsing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'showThinking',
|
||||
tab: CHAT,
|
||||
section: 'messages',
|
||||
labelKey: 'com_nav_show_thinking',
|
||||
Component: toggleControl({
|
||||
stateAtom: showThinkingAtom,
|
||||
localizationKey: 'com_nav_show_thinking',
|
||||
switchId: 'showThinking',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'autoExpandTools',
|
||||
tab: CHAT,
|
||||
section: 'messages',
|
||||
labelKey: 'com_nav_auto_expand_tools',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.autoExpandTools,
|
||||
localizationKey: 'com_nav_auto_expand_tools',
|
||||
switchId: 'autoExpandTools',
|
||||
}),
|
||||
},
|
||||
// Chat · Conversations
|
||||
{
|
||||
id: 'newChatSwitchToHistory',
|
||||
tab: CHAT,
|
||||
section: 'conversations',
|
||||
labelKey: 'com_nav_new_chat_switch_to_history',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.newChatSwitchToHistory,
|
||||
localizationKey: 'com_nav_new_chat_switch_to_history',
|
||||
switchId: 'newChatSwitchToHistory',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'autoScroll',
|
||||
tab: CHAT,
|
||||
section: 'conversations',
|
||||
labelKey: 'com_nav_auto_scroll',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.autoScroll,
|
||||
localizationKey: 'com_nav_auto_scroll',
|
||||
switchId: 'autoScroll',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'modularChat',
|
||||
tab: CHAT,
|
||||
section: 'conversations',
|
||||
labelKey: 'com_nav_modular_chat',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.modularChat,
|
||||
localizationKey: 'com_nav_modular_chat',
|
||||
switchId: 'modularChat',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'defaultTemporaryChat',
|
||||
tab: CHAT,
|
||||
section: 'conversations',
|
||||
labelKey: 'com_nav_default_temporary_chat',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.defaultTemporaryChat,
|
||||
localizationKey: 'com_nav_default_temporary_chat',
|
||||
switchId: 'defaultTemporaryChat',
|
||||
hoverCardText: 'com_nav_info_default_temporary_chat',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forkSettings',
|
||||
tab: CHAT,
|
||||
section: 'conversations',
|
||||
labelKey: 'com_ui_fork_default',
|
||||
keywords: ['fork', 'branch', 'split'],
|
||||
Component: ForkSettings,
|
||||
},
|
||||
// Chat · Prompts
|
||||
{
|
||||
id: 'advancedPrompts',
|
||||
tab: CHAT,
|
||||
section: 'prompts',
|
||||
labelKey: 'com_nav_advanced_prompts',
|
||||
keywords: ['prompt'],
|
||||
Component: AdvancedPrompts,
|
||||
},
|
||||
{
|
||||
id: 'alwaysMakeProd',
|
||||
tab: CHAT,
|
||||
section: 'prompts',
|
||||
labelKey: 'com_nav_always_make_prod',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.alwaysMakeProd,
|
||||
localizationKey: 'com_nav_always_make_prod',
|
||||
switchId: 'alwaysMakeProd',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'autoSendPrompts',
|
||||
tab: CHAT,
|
||||
section: 'prompts',
|
||||
labelKey: 'com_nav_auto_send_prompts',
|
||||
Component: toggleControl({
|
||||
stateAtom: store.autoSendPrompts,
|
||||
localizationKey: 'com_nav_auto_send_prompts',
|
||||
switchId: 'autoSendPrompts',
|
||||
hoverCardText: 'com_nav_auto_send_prompts_desc',
|
||||
}),
|
||||
},
|
||||
|
||||
// Speech · Speech-to-text
|
||||
{
|
||||
id: 'speechToText',
|
||||
tab: SPEECH,
|
||||
section: 'stt',
|
||||
labelKey: 'com_nav_speech_to_text',
|
||||
Component: SpeechToTextSwitch,
|
||||
},
|
||||
{
|
||||
id: 'engineSTT',
|
||||
tab: SPEECH,
|
||||
section: 'stt',
|
||||
labelKey: 'com_ui_settings_label_engine_stt',
|
||||
Component: EngineSTTSetting,
|
||||
},
|
||||
{
|
||||
id: 'languageSTT',
|
||||
tab: SPEECH,
|
||||
section: 'stt',
|
||||
labelKey: 'com_ui_settings_label_language_stt',
|
||||
Component: LanguageSTTDropdown,
|
||||
},
|
||||
{
|
||||
id: 'autoTranscribeAudio',
|
||||
tab: SPEECH,
|
||||
section: 'stt',
|
||||
labelKey: 'com_nav_auto_transcribe_audio',
|
||||
Component: AutoTranscribeAudioSwitch,
|
||||
},
|
||||
{
|
||||
id: 'decibelValue',
|
||||
tab: SPEECH,
|
||||
section: 'stt',
|
||||
labelKey: 'com_ui_settings_label_decibel',
|
||||
Component: DecibelSelector,
|
||||
},
|
||||
{
|
||||
id: 'autoSendText',
|
||||
tab: SPEECH,
|
||||
section: 'stt',
|
||||
labelKey: 'com_nav_auto_send_text',
|
||||
Component: AutoSendTextSelector,
|
||||
},
|
||||
// Speech · Text-to-speech
|
||||
{
|
||||
id: 'textToSpeech',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_nav_text_to_speech',
|
||||
Component: TextToSpeechSwitch,
|
||||
},
|
||||
{
|
||||
id: 'engineTTS',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_ui_settings_label_engine_tts',
|
||||
Component: EngineTTSSetting,
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_ui_settings_label_voice',
|
||||
Component: VoiceDropdown,
|
||||
},
|
||||
{
|
||||
id: 'conversationMode',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_ui_settings_label_conversation_mode',
|
||||
Component: ConversationModeSwitch,
|
||||
},
|
||||
{
|
||||
id: 'automaticPlayback',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_nav_automatic_playback',
|
||||
Component: AutomaticPlaybackSwitch,
|
||||
},
|
||||
{
|
||||
id: 'cloudBrowserVoices',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_nav_enable_cloud_browser_voice',
|
||||
show: (ctx) => ctx.engineTTS === TTSEndpoints.browser,
|
||||
Component: CloudBrowserVoicesSwitch,
|
||||
},
|
||||
{
|
||||
id: 'playbackRate',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_ui_settings_label_playback_rate',
|
||||
Component: PlaybackRate,
|
||||
},
|
||||
{
|
||||
id: 'cacheTTS',
|
||||
tab: SPEECH,
|
||||
section: 'tts',
|
||||
labelKey: 'com_nav_enable_cache_tts',
|
||||
Component: CacheTTSSwitch,
|
||||
},
|
||||
|
||||
// Data controls · Memory
|
||||
{
|
||||
id: 'referenceSavedMemories',
|
||||
tab: DATA,
|
||||
section: 'memory',
|
||||
labelKey: 'com_ui_reference_saved_memories',
|
||||
keywords: ['memory', 'personalization'],
|
||||
show: (ctx) => ctx.hasMemoryOptOut,
|
||||
Component: MemoryToggle,
|
||||
},
|
||||
// Data controls · Your data
|
||||
{
|
||||
id: 'importConversations',
|
||||
tab: DATA,
|
||||
section: 'data',
|
||||
labelKey: 'com_ui_settings_label_import',
|
||||
Component: ImportConversations,
|
||||
},
|
||||
{
|
||||
id: 'sharedLinks',
|
||||
tab: DATA,
|
||||
section: 'data',
|
||||
labelKey: 'com_ui_settings_label_shared_links',
|
||||
Component: SharedLinks,
|
||||
},
|
||||
// Data controls · API keys
|
||||
{
|
||||
id: 'agentApiKeys',
|
||||
tab: DATA,
|
||||
section: 'apiKeys',
|
||||
labelKey: 'com_ui_settings_label_agent_api_keys',
|
||||
show: (ctx) => ctx.hasRemoteAgents,
|
||||
Component: AgentApiKeys,
|
||||
},
|
||||
{
|
||||
id: 'revokeKeys',
|
||||
tab: DATA,
|
||||
section: 'apiKeys',
|
||||
labelKey: 'com_ui_settings_label_revoke_keys',
|
||||
Component: RevokeKeys,
|
||||
},
|
||||
// Data controls · Danger zone
|
||||
{
|
||||
id: 'deleteCache',
|
||||
tab: DATA,
|
||||
section: 'danger',
|
||||
labelKey: 'com_ui_settings_label_delete_cache',
|
||||
Component: DeleteCache,
|
||||
},
|
||||
{
|
||||
id: 'clearChats',
|
||||
tab: DATA,
|
||||
section: 'danger',
|
||||
labelKey: 'com_ui_settings_label_clear_chats',
|
||||
Component: ClearChats,
|
||||
},
|
||||
|
||||
// Account · Profile
|
||||
{
|
||||
id: 'avatar',
|
||||
tab: ACCOUNT,
|
||||
section: 'profile',
|
||||
labelKey: 'com_ui_settings_label_avatar',
|
||||
Component: Avatar,
|
||||
},
|
||||
// Account · Security
|
||||
{
|
||||
id: 'twoFactor',
|
||||
tab: ACCOUNT,
|
||||
section: 'security',
|
||||
labelKey: 'com_ui_settings_label_2fa',
|
||||
show: (ctx) => ctx.isLocalProvider,
|
||||
Component: EnableTwoFactorItem,
|
||||
},
|
||||
{
|
||||
id: 'backupCodes',
|
||||
tab: ACCOUNT,
|
||||
section: 'security',
|
||||
labelKey: 'com_ui_settings_label_backup_codes',
|
||||
show: (ctx) => ctx.isLocalProvider && ctx.twoFactorEnabled,
|
||||
Component: BackupCodesItem,
|
||||
},
|
||||
// Account · Billing
|
||||
{
|
||||
id: 'tokenCredits',
|
||||
tab: ACCOUNT,
|
||||
section: 'billing',
|
||||
labelKey: 'com_ui_settings_label_credits',
|
||||
show: (ctx) => ctx.balanceEnabled,
|
||||
Component: TokenCredits,
|
||||
},
|
||||
{
|
||||
id: 'autoRefill',
|
||||
tab: ACCOUNT,
|
||||
section: 'billing',
|
||||
labelKey: 'com_ui_settings_label_auto_refill',
|
||||
show: (ctx) => ctx.balanceEnabled,
|
||||
Component: AutoRefill,
|
||||
},
|
||||
// Account · Danger zone
|
||||
{
|
||||
id: 'deleteAccount',
|
||||
tab: ACCOUNT,
|
||||
section: 'danger',
|
||||
labelKey: 'com_ui_settings_label_delete_account',
|
||||
show: (ctx) => ctx.allowAccountDeletion,
|
||||
Component: DeleteAccount,
|
||||
},
|
||||
|
||||
// About
|
||||
{
|
||||
id: 'about',
|
||||
tab: ABOUT,
|
||||
section: 'about',
|
||||
labelKey: 'com_nav_setting_about',
|
||||
keywords: ['version', 'build', 'diagnostics'],
|
||||
show: (ctx) => ctx.aboutEnabled,
|
||||
Component: About,
|
||||
},
|
||||
];
|
||||
43
client/src/components/Nav/Settings/search.ts
Normal file
43
client/src/components/Nav/Settings/search.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { SettingEntry, SettingsContextValue } from './types';
|
||||
|
||||
export function normalize(value: string): string {
|
||||
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesQuery(
|
||||
query: string,
|
||||
haystack: { label: string; keywords?: string[] },
|
||||
): boolean {
|
||||
const q = normalize(query);
|
||||
if (q.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (normalize(haystack.label).includes(q)) {
|
||||
return true;
|
||||
}
|
||||
return (haystack.keywords ?? []).some((k) => normalize(k).includes(q));
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
entry: SettingEntry;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function filterSettings(
|
||||
entries: SettingEntry[],
|
||||
query: string,
|
||||
ctx: SettingsContextValue,
|
||||
localize: (key: SettingEntry['labelKey']) => string,
|
||||
): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.show && !entry.show(ctx)) {
|
||||
continue;
|
||||
}
|
||||
const label = localize(entry.labelKey);
|
||||
if (matchesQuery(query, { label, keywords: entry.keywords })) {
|
||||
results.push({ entry, label });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
135
client/src/components/Nav/Settings/types.ts
Normal file
135
client/src/components/Nav/Settings/types.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { createElement } from 'react';
|
||||
import { MessageSquare, Info } from 'lucide-react';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import { GearIcon, DataIcon, UserIcon, SpeechIcon } from '@librechat/client';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
|
||||
export type SettingsTab =
|
||||
| SettingsTabValues.GENERAL
|
||||
| SettingsTabValues.CHAT
|
||||
| SettingsTabValues.SPEECH
|
||||
| SettingsTabValues.DATA
|
||||
| SettingsTabValues.ACCOUNT
|
||||
| SettingsTabValues.ABOUT;
|
||||
|
||||
export type SectionId =
|
||||
| 'appearance'
|
||||
| 'layout'
|
||||
| 'accessibility'
|
||||
| 'sending'
|
||||
| 'commands'
|
||||
| 'messages'
|
||||
| 'conversations'
|
||||
| 'prompts'
|
||||
| 'stt'
|
||||
| 'tts'
|
||||
| 'memory'
|
||||
| 'data'
|
||||
| 'apiKeys'
|
||||
| 'danger'
|
||||
| 'profile'
|
||||
| 'security'
|
||||
| 'billing'
|
||||
| 'about';
|
||||
|
||||
export interface SettingsContextValue {
|
||||
balanceEnabled: boolean;
|
||||
hasAnyPersonalizationFeature: boolean;
|
||||
hasMemoryOptOut: boolean;
|
||||
hasRemoteAgents: boolean;
|
||||
hasMultiConvo: boolean;
|
||||
hasPrompts: boolean;
|
||||
isLocalProvider: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
allowAccountDeletion: boolean;
|
||||
aboutEnabled: boolean;
|
||||
engineTTS: string;
|
||||
}
|
||||
|
||||
export interface SettingEntry {
|
||||
id: string;
|
||||
tab: SettingsTab;
|
||||
section: SectionId;
|
||||
labelKey: TranslationKeys;
|
||||
keywords?: string[];
|
||||
Component: ComponentType;
|
||||
show?: (ctx: SettingsContextValue) => boolean;
|
||||
}
|
||||
|
||||
export interface SectionMeta {
|
||||
id: SectionId;
|
||||
labelKey: TranslationKeys;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface TabMeta {
|
||||
id: SettingsTab;
|
||||
labelKey: TranslationKeys;
|
||||
icon: ReactNode;
|
||||
sections: SectionMeta[];
|
||||
show?: (ctx: SettingsContextValue) => boolean;
|
||||
}
|
||||
|
||||
export const TABS: TabMeta[] = [
|
||||
{
|
||||
id: SettingsTabValues.GENERAL,
|
||||
labelKey: 'com_nav_setting_general',
|
||||
icon: createElement(GearIcon),
|
||||
sections: [
|
||||
{ id: 'appearance', labelKey: 'com_ui_settings_section_appearance' },
|
||||
{ id: 'layout', labelKey: 'com_ui_settings_section_layout' },
|
||||
{ id: 'accessibility', labelKey: 'com_ui_settings_section_accessibility' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.CHAT,
|
||||
labelKey: 'com_nav_setting_chat',
|
||||
icon: createElement(MessageSquare, { className: 'icon-sm', 'aria-hidden': true }),
|
||||
sections: [
|
||||
{ id: 'sending', labelKey: 'com_ui_settings_section_sending' },
|
||||
{ id: 'commands', labelKey: 'com_ui_settings_section_commands' },
|
||||
{ id: 'messages', labelKey: 'com_ui_settings_section_messages' },
|
||||
{ id: 'conversations', labelKey: 'com_ui_settings_section_conversations' },
|
||||
{ id: 'prompts', labelKey: 'com_ui_settings_section_prompts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.SPEECH,
|
||||
labelKey: 'com_nav_setting_speech',
|
||||
icon: createElement(SpeechIcon, { className: 'icon-sm' }),
|
||||
sections: [
|
||||
{ id: 'stt', labelKey: 'com_ui_settings_section_stt' },
|
||||
{ id: 'tts', labelKey: 'com_ui_settings_section_tts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.DATA,
|
||||
labelKey: 'com_ui_settings_tab_data',
|
||||
icon: createElement(DataIcon),
|
||||
sections: [
|
||||
{ id: 'memory', labelKey: 'com_ui_settings_section_memory' },
|
||||
{ id: 'data', labelKey: 'com_ui_settings_section_data' },
|
||||
{ id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' },
|
||||
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.ACCOUNT,
|
||||
labelKey: 'com_nav_setting_account',
|
||||
icon: createElement(UserIcon),
|
||||
sections: [
|
||||
{ id: 'profile', labelKey: 'com_ui_settings_section_profile' },
|
||||
{ id: 'security', labelKey: 'com_ui_settings_section_security' },
|
||||
{ id: 'billing', labelKey: 'com_ui_settings_section_billing' },
|
||||
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.ABOUT,
|
||||
labelKey: 'com_nav_setting_about',
|
||||
icon: createElement(Info, { className: 'icon-sm', 'aria-hidden': true }),
|
||||
sections: [{ id: 'about', labelKey: 'com_nav_setting_about' }],
|
||||
show: (ctx) => ctx.aboutEnabled,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -38,9 +38,9 @@ function buildDiagnosticsBlob(
|
|||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-1.5">
|
||||
<div className="text-text-secondary">{label}</div>
|
||||
<div className="break-all text-right font-mono text-xs text-text-primary">{value}</div>
|
||||
<div className="flex items-center justify-between gap-4 py-2.5 first:pt-0 last:pb-0">
|
||||
<dt className="text-text-secondary">{label}</dt>
|
||||
<dd className="break-all text-right font-mono text-xs text-text-primary">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ function Row({ label, value }: { label: string; value: string }) {
|
|||
function About() {
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const buildInfo = startupConfig?.buildInfo;
|
||||
|
|
@ -68,71 +68,53 @@ function About() {
|
|||
[],
|
||||
);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
const handleCopy = useCallback(() => {
|
||||
const succeeded = copy(diagnosticsBlob, { format: 'text/plain' });
|
||||
if (!succeeded) {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
setIsCopied(true);
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
copyResetTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
copyResetTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
|
||||
}, [diagnosticsBlob]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<section aria-labelledby="about-version-heading" className="flex flex-col">
|
||||
<h3 id="about-version-heading" className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_nav_about_version_heading')}
|
||||
</h3>
|
||||
<div className="rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
<Row label={localize('com_nav_about_version')} value={version} />
|
||||
<Row
|
||||
label={localize('com_nav_about_commit')}
|
||||
value={buildInfo?.commitShort ?? UNKNOWN_PLACEHOLDER}
|
||||
/>
|
||||
<Row
|
||||
label={localize('com_nav_about_branch')}
|
||||
value={buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}
|
||||
/>
|
||||
<Row
|
||||
label={localize('com_nav_about_build_date')}
|
||||
value={formatBuildDate(buildInfo?.buildDate)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex flex-col text-sm text-text-primary">
|
||||
<dl className="flex flex-col divide-y divide-border-light">
|
||||
<Row label={localize('com_nav_about_version')} value={version} />
|
||||
<Row
|
||||
label={localize('com_nav_about_commit')}
|
||||
value={buildInfo?.commitShort ?? UNKNOWN_PLACEHOLDER}
|
||||
/>
|
||||
<Row
|
||||
label={localize('com_nav_about_branch')}
|
||||
value={buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}
|
||||
/>
|
||||
<Row
|
||||
label={localize('com_nav_about_build_date')}
|
||||
value={formatBuildDate(buildInfo?.buildDate)}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<section aria-labelledby="about-diagnostics-heading" className="flex flex-col">
|
||||
<h3 id="about-diagnostics-heading" className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_nav_about_diagnostics_heading')}
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-text-secondary">
|
||||
<div className="mt-4 flex flex-col items-start gap-3 border-t border-border-light pt-4">
|
||||
<p className="text-xs text-text-secondary">
|
||||
{localize('com_nav_about_diagnostics_description')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center justify-center gap-2 self-start rounded-md border border-border-light bg-surface-secondary px-3 py-1.5 text-xs font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_nav_about_diagnostics_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_nav_about_diagnostics_copy')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<CopyButton
|
||||
isCopied={isCopied}
|
||||
onClick={handleCopy}
|
||||
label={localize('com_nav_about_diagnostics_copy')}
|
||||
copiedLabel={localize('com_nav_about_diagnostics_copied')}
|
||||
className="ml-0 gap-2 self-start rounded-lg border border-border-light bg-surface-secondary px-3 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
|
||||
/>
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copied ? localize('com_nav_about_diagnostics_copied') : ''}
|
||||
{isCopied ? localize('com_nav_about_diagnostics_copied') : ''}
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(About);
|
||||
export default memo(About);
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import React from 'react';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import Account from './Account';
|
||||
|
||||
jest.mock('./DisplayUsernameMessages', () => () => <div data-testid="display-username" />);
|
||||
jest.mock('./Avatar', () => () => <div data-testid="avatar" />);
|
||||
jest.mock('./TwoFactorAuthentication', () => () => <div data-testid="two-factor" />);
|
||||
jest.mock('./BackupCodesItem', () => () => <div data-testid="backup-codes" />);
|
||||
jest.mock('./DeleteAccount', () => () => <div data-testid="delete-account" />);
|
||||
|
||||
const mockUseAuthContext = jest.fn();
|
||||
const mockUseGetStartupConfig = jest.fn();
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useAuthContext: () => mockUseAuthContext(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
const baseUser: TUser = {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: '',
|
||||
role: SystemRoles.USER,
|
||||
provider: 'local',
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthContext.mockReturnValue({ user: baseUser });
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { allowAccountDeletion: true } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Account', () => {
|
||||
describe('DeleteAccount visibility', () => {
|
||||
it('renders DeleteAccount when allowAccountDeletion is true', () => {
|
||||
render(<Account />);
|
||||
expect(screen.getByTestId('delete-account')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides DeleteAccount when allowAccountDeletion is false', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { allowAccountDeletion: false } });
|
||||
render(<Account />);
|
||||
expect(screen.queryByTestId('delete-account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows DeleteAccount when startup config is still loading', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
|
||||
render(<Account />);
|
||||
expect(screen.getByTestId('delete-account')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import React from 'react';
|
||||
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
||||
import DeleteAccount from './DeleteAccount';
|
||||
import Avatar from './Avatar';
|
||||
import EnableTwoFactorItem from './TwoFactorAuthentication';
|
||||
import BackupCodesItem from './BackupCodesItem';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const { user } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{user?.twoFactorEnabled && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{startupConfig?.allowAccountDeletion !== false && (
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Account);
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import AutoRefillSettings from './AutoRefillSettings';
|
||||
import TokenCreditsItem from './TokenCreditsItem';
|
||||
|
||||
function Balance() {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const balanceQuery = useGetUserBalance({
|
||||
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
|
||||
});
|
||||
const balanceData = balanceQuery.data;
|
||||
|
||||
// Pull out all the fields we need, with safe defaults
|
||||
const {
|
||||
tokenCredits = 0,
|
||||
autoRefillEnabled = false,
|
||||
lastRefill,
|
||||
refillAmount,
|
||||
refillIntervalUnit,
|
||||
refillIntervalValue,
|
||||
} = balanceData ?? {};
|
||||
|
||||
// Check that all auto-refill props are present
|
||||
const hasValidRefillSettings =
|
||||
lastRefill !== undefined &&
|
||||
refillAmount !== undefined &&
|
||||
refillIntervalUnit !== undefined &&
|
||||
refillIntervalValue !== undefined;
|
||||
|
||||
const renderAutoRefill = () => {
|
||||
if (!autoRefillEnabled) {
|
||||
return (
|
||||
<div className="text-sm text-gray-600">
|
||||
{localize('com_nav_balance_auto_refill_disabled')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hasValidRefillSettings) {
|
||||
return (
|
||||
<div className="text-sm text-red-600">{localize('com_nav_balance_auto_refill_error')}</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AutoRefillSettings
|
||||
lastRefill={lastRefill}
|
||||
refillAmount={refillAmount}
|
||||
refillIntervalUnit={refillIntervalUnit}
|
||||
refillIntervalValue={refillIntervalValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 text-sm text-text-primary">
|
||||
{/* Token credits display */}
|
||||
<TokenCreditsItem tokenCredits={tokenCredits} />
|
||||
|
||||
{/* Auto-refill display */}
|
||||
{renderAutoRefill()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Balance);
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import AdvancedPrompts from './AdvancedPrompts';
|
||||
import FontSizeSelector from './FontSizeSelector';
|
||||
import { ForkSettings } from './ForkSettings';
|
||||
import ChatDirection from './ChatDirection';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
const toggleSwitchConfigs = [
|
||||
{
|
||||
stateAtom: store.alwaysMakeProd,
|
||||
localizationKey: 'com_nav_always_make_prod' as const,
|
||||
switchId: 'alwaysMakeProd',
|
||||
hoverCardText: undefined,
|
||||
key: 'alwaysMakeProd',
|
||||
},
|
||||
{
|
||||
stateAtom: store.autoSendPrompts,
|
||||
localizationKey: 'com_nav_auto_send_prompts' as const,
|
||||
switchId: 'autoSendPrompts',
|
||||
hoverCardText: 'com_nav_auto_send_prompts_desc' as const,
|
||||
key: 'autoSendPrompts',
|
||||
},
|
||||
{
|
||||
stateAtom: store.enterToSend,
|
||||
localizationKey: 'com_nav_enter_to_send' as const,
|
||||
switchId: 'enterToSend',
|
||||
hoverCardText: 'com_nav_info_enter_to_send' as const,
|
||||
key: 'enterToSend',
|
||||
},
|
||||
{
|
||||
stateAtom: store.maximizeChatSpace,
|
||||
localizationKey: 'com_nav_maximize_chat_space' as const,
|
||||
switchId: 'maximizeChatSpace',
|
||||
hoverCardText: undefined,
|
||||
key: 'maximizeChatSpace',
|
||||
},
|
||||
{
|
||||
stateAtom: store.centerFormOnLanding,
|
||||
localizationKey: 'com_nav_center_chat_input' as const,
|
||||
switchId: 'centerFormOnLanding',
|
||||
hoverCardText: undefined,
|
||||
key: 'centerFormOnLanding',
|
||||
},
|
||||
{
|
||||
stateAtom: showThinkingAtom,
|
||||
localizationKey: 'com_nav_show_thinking' as const,
|
||||
switchId: 'showThinking',
|
||||
hoverCardText: undefined,
|
||||
key: 'showThinking',
|
||||
},
|
||||
{
|
||||
stateAtom: store.autoExpandTools,
|
||||
localizationKey: 'com_nav_auto_expand_tools' as const,
|
||||
switchId: 'autoExpandTools',
|
||||
hoverCardText: undefined,
|
||||
key: 'autoExpandTools',
|
||||
},
|
||||
{
|
||||
stateAtom: store.LaTeXParsing,
|
||||
localizationKey: 'com_nav_latex_parsing' as const,
|
||||
switchId: 'latexParsing',
|
||||
hoverCardText: 'com_nav_info_latex_parsing' as const,
|
||||
key: 'latexParsing',
|
||||
},
|
||||
{
|
||||
stateAtom: store.saveDrafts,
|
||||
localizationKey: 'com_nav_save_drafts' as const,
|
||||
switchId: 'saveDrafts',
|
||||
hoverCardText: 'com_nav_info_save_draft' as const,
|
||||
key: 'saveDrafts',
|
||||
},
|
||||
{
|
||||
stateAtom: store.showScrollButton,
|
||||
localizationKey: 'com_nav_scroll_button' as const,
|
||||
switchId: 'showScrollButton',
|
||||
hoverCardText: undefined,
|
||||
key: 'showScrollButton',
|
||||
},
|
||||
{
|
||||
stateAtom: store.saveBadgesState,
|
||||
localizationKey: 'com_nav_save_badges_state' as const,
|
||||
switchId: 'showBadges',
|
||||
hoverCardText: 'com_nav_info_save_badges_state' as const,
|
||||
key: 'showBadges',
|
||||
},
|
||||
{
|
||||
stateAtom: store.modularChat,
|
||||
localizationKey: 'com_nav_modular_chat' as const,
|
||||
switchId: 'modularChat',
|
||||
hoverCardText: undefined,
|
||||
key: 'modularChat',
|
||||
},
|
||||
{
|
||||
stateAtom: store.defaultTemporaryChat,
|
||||
localizationKey: 'com_nav_default_temporary_chat' as const,
|
||||
switchId: 'defaultTemporaryChat',
|
||||
hoverCardText: 'com_nav_info_default_temporary_chat' as const,
|
||||
key: 'defaultTemporaryChat',
|
||||
},
|
||||
];
|
||||
|
||||
function Chat() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<FontSizeSelector />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<ChatDirection />
|
||||
</div>
|
||||
{toggleSwitchConfigs.map((config) => (
|
||||
<div key={config.key} className="pb-3">
|
||||
<ToggleSwitch
|
||||
stateAtom={config.stateAtom}
|
||||
localizationKey={config.localizationKey}
|
||||
hoverCardText={config.hoverCardText}
|
||||
switchId={config.switchId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="pb-3">
|
||||
<AdvancedPrompts />
|
||||
</div>
|
||||
<ForkSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Chat);
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import { InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
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();
|
||||
|
||||
const hasAccessToPrompts = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const hasAccessToMultiConvo = useHasAccess({
|
||||
permissionType: PermissionTypes.MULTI_CONVO,
|
||||
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">
|
||||
<h3 className="text-lg font-medium text-text-primary">
|
||||
{localize('com_nav_chat_commands')}
|
||||
</h3>
|
||||
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Commands);
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useOnClickOutside } from '@librechat/client';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import ImportConversations from './ImportConversations';
|
||||
import { AgentApiKeys } from './AgentApiKeys';
|
||||
import { DeleteCache } from './DeleteCache';
|
||||
import { RevokeKeys } from './RevokeKeys';
|
||||
import { ClearChats } from './ClearChats';
|
||||
import SharedLinks from './SharedLinks';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
|
||||
function Data() {
|
||||
const dataTabRef = useRef(null);
|
||||
const [confirmClearConvos, setConfirmClearConvos] = useState(false);
|
||||
useOnClickOutside(dataTabRef, () => confirmClearConvos && setConfirmClearConvos(false), []);
|
||||
const hasAccessToApiKeys = useHasAccess({
|
||||
permissionType: PermissionTypes.REMOTE_AGENTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<ImportConversations />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<SharedLinks />
|
||||
</div>
|
||||
{hasAccessToApiKeys && (
|
||||
<div className="pb-3">
|
||||
<AgentApiKeys />
|
||||
</div>
|
||||
)}
|
||||
<div className="pb-3">
|
||||
<RevokeKeys />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<DeleteCache />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<ClearChats />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Data);
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ArchivedChats() {
|
||||
const localize = useLocalize();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_archived_chats')}</div>
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ArchivedChatsTable onOpenChange={setIsOpen} />}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
|
||||
import type { RefObject } from 'react';
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export function ArchivedChatsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerRef,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triggerRef?: RefObject<HTMLButtonElement | HTMLDivElement | null>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="w-11/12 max-w-[1000px] bg-background text-text-primary shadow-2xl"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<ArchivedChatsTable onOpenChange={onOpenChange} />
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { LangSelector } from './General';
|
||||
import { LangSelector } from './Selectors';
|
||||
import store from '~/store';
|
||||
|
||||
describe('LangSelector', () => {
|
||||
|
|
|
|||
|
|
@ -1,43 +1,8 @@
|
|||
import React, { useContext, useCallback } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Dropdown, Spinner, ThemeContext } from '@librechat/client';
|
||||
import ArchivedChats from './ArchivedChats';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Dropdown, Spinner } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
const toggleSwitchConfigs = [
|
||||
{
|
||||
stateAtom: store.enableUserMsgMarkdown,
|
||||
localizationKey: 'com_nav_user_msg_markdown' as const,
|
||||
switchId: 'enableUserMsgMarkdown',
|
||||
hoverCardText: undefined,
|
||||
key: 'enableUserMsgMarkdown',
|
||||
},
|
||||
{
|
||||
stateAtom: store.autoScroll,
|
||||
localizationKey: 'com_nav_auto_scroll' as const,
|
||||
switchId: 'autoScroll',
|
||||
hoverCardText: undefined,
|
||||
key: 'autoScroll',
|
||||
},
|
||||
{
|
||||
stateAtom: store.keepScreenAwake,
|
||||
localizationKey: 'com_nav_keep_screen_awake' as const,
|
||||
switchId: 'keepScreenAwake',
|
||||
hoverCardText: undefined,
|
||||
key: 'keepScreenAwake',
|
||||
},
|
||||
{
|
||||
stateAtom: store.newChatSwitchToHistory,
|
||||
localizationKey: 'com_nav_new_chat_switch_to_history' as const,
|
||||
switchId: 'newChatSwitchToHistory',
|
||||
hoverCardText: undefined,
|
||||
key: 'newChatSwitchToHistory',
|
||||
},
|
||||
];
|
||||
|
||||
export const ThemeSelector = ({
|
||||
theme,
|
||||
onChange,
|
||||
|
|
@ -151,65 +116,16 @@ export const LangSelector = ({
|
|||
<Dropdown
|
||||
value={langcode}
|
||||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
|
||||
sizeClasses="[--anchor-max-height:256px] max-h-[60vh] w-[220px]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
portal={portal}
|
||||
searchable
|
||||
searchPlaceholder={localize('com_ui_search_language')}
|
||||
searchEmptyText={localize('com_ui_no_results_found')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function General() {
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
|
||||
const [langcode, setLangcode] = useRecoilState(store.lang);
|
||||
|
||||
const changeTheme = useCallback(
|
||||
(value: string) => {
|
||||
setTheme(value);
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
|
||||
const changeLang = useCallback(
|
||||
(value: string) => {
|
||||
let userLang = value;
|
||||
if (value === 'auto') {
|
||||
userLang = navigator.language || navigator.languages[0];
|
||||
}
|
||||
|
||||
setLangcode(userLang);
|
||||
Cookies.set('lang', userLang, { expires: 365 });
|
||||
},
|
||||
[setLangcode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<ThemeSelector theme={theme} onChange={changeTheme} />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<LangSelector langcode={langcode} onChange={changeLang} />
|
||||
</div>
|
||||
{toggleSwitchConfigs.map((config) => (
|
||||
<div key={config.key} className="pb-3">
|
||||
<ToggleSwitch
|
||||
stateAtom={config.stateAtom}
|
||||
localizationKey={config.localizationKey}
|
||||
hoverCardText={config.hoverCardText}
|
||||
switchId={config.switchId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="pb-3">
|
||||
<ArchivedChats />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(General);
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
// ThemeSelector.spec.tsx
|
||||
import 'test/matchMedia.mock';
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { ThemeSelector } from './General';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { ThemeSelector } from './Selectors';
|
||||
|
||||
describe('ThemeSelector', () => {
|
||||
let mockOnChange;
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Switch, useToastContext } from '@librechat/client';
|
||||
import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PersonalizationProps {
|
||||
hasMemoryOptOut: boolean;
|
||||
hasAnyPersonalizationFeature: boolean;
|
||||
}
|
||||
|
||||
export default function Personalization({
|
||||
hasMemoryOptOut,
|
||||
hasAnyPersonalizationFeature,
|
||||
}: PersonalizationProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: user } = useGetUserQuery();
|
||||
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
|
||||
|
||||
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_preferences_updated'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_error_updating_preferences'),
|
||||
status: 'error',
|
||||
});
|
||||
// Revert the toggle on error
|
||||
setReferenceSavedMemories((prev) => !prev);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize state from user data
|
||||
useEffect(() => {
|
||||
if (user?.personalization?.memories !== undefined) {
|
||||
setReferenceSavedMemories(user.personalization.memories);
|
||||
}
|
||||
}, [user?.personalization?.memories]);
|
||||
|
||||
const handleMemoryToggle = (checked: boolean) => {
|
||||
setReferenceSavedMemories(checked);
|
||||
updateMemoryPreferencesMutation.mutate({ memories: checked });
|
||||
};
|
||||
|
||||
if (!hasAnyPersonalizationFeature) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<div className="text-text-secondary">{localize('com_ui_no_personalization_available')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
{/* Memory Settings Section */}
|
||||
{hasMemoryOptOut && (
|
||||
<>
|
||||
<div className="border-b border-border-medium pb-3">
|
||||
<div className="text-base font-semibold">{localize('com_ui_memory')}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div id="reference-saved-memories-label" className="flex items-center gap-2">
|
||||
{localize('com_ui_reference_saved_memories')}
|
||||
</div>
|
||||
<div
|
||||
id="reference-saved-memories-description"
|
||||
className="mt-1 text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_reference_saved_memories_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
aria-labelledby="reference-saved-memories-label"
|
||||
aria-describedby="reference-saved-memories-description"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Dropdown } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ interface EngineSTTDropdownProps {
|
|||
const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
||||
const localize = useLocalize();
|
||||
const [engineSTT, setEngineSTT] = useRecoilState<string>(store.engineSTT);
|
||||
const speechToText = useRecoilValue(store.speechToText);
|
||||
|
||||
const endpointOptions = external
|
||||
? [
|
||||
|
|
@ -36,6 +37,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
testId="EngineSTTDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Dropdown } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function LanguageSTTDropdown() {
|
||||
const localize = useLocalize();
|
||||
const [languageSTT, setLanguageSTT] = useRecoilState<string>(store.languageSTT);
|
||||
const speechToText = useRecoilValue(store.speechToText);
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'af', label: 'Afrikaans' },
|
||||
|
|
@ -107,6 +108,7 @@ export default function LanguageSTTDropdown() {
|
|||
testId="LanguageSTTDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Lightbulb, Cog } from 'lucide-react';
|
||||
import { useOnClickOutside, useMediaQuery } from '@librechat/client';
|
||||
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
CloudBrowserVoicesSwitch,
|
||||
AutomaticPlaybackSwitch,
|
||||
TextToSpeechSwitch,
|
||||
EngineTTSDropdown,
|
||||
CacheTTSSwitch,
|
||||
VoiceDropdown,
|
||||
PlaybackRate,
|
||||
} from './TTS';
|
||||
import {
|
||||
AutoTranscribeAudioSwitch,
|
||||
LanguageSTTDropdown,
|
||||
SpeechToTextSwitch,
|
||||
AutoSendTextSelector,
|
||||
EngineSTTDropdown,
|
||||
DecibelSelector,
|
||||
} from './STT';
|
||||
import ConversationModeSwitch from './ConversationModeSwitch';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function Speech() {
|
||||
const localize = useLocalize();
|
||||
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const { data } = useGetCustomConfigSpeechQuery();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
const [sttExternal, setSttExternal] = useState(false);
|
||||
const [ttsExternal, setTtsExternal] = useState(false);
|
||||
const [advancedMode, setAdvancedMode] = useRecoilState(store.advancedMode);
|
||||
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState(store.autoTranscribeAudio);
|
||||
const [conversationMode, setConversationMode] = useRecoilState(store.conversationMode);
|
||||
const [speechToText, setSpeechToText] = useRecoilState(store.speechToText);
|
||||
const [textToSpeech, setTextToSpeech] = useRecoilState(store.textToSpeech);
|
||||
const [cacheTTS, setCacheTTS] = useRecoilState(store.cacheTTS);
|
||||
const [engineSTT, setEngineSTT] = useRecoilState<string>(store.engineSTT);
|
||||
const [languageSTT, setLanguageSTT] = useRecoilState<string>(store.languageSTT);
|
||||
const [decibelValue, setDecibelValue] = useRecoilState(store.decibelValue);
|
||||
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
|
||||
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
|
||||
store.cloudBrowserVoices,
|
||||
);
|
||||
const [languageTTS, setLanguageTTS] = useRecoilState<string>(store.languageTTS);
|
||||
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
|
||||
const [playbackRate, setPlaybackRate] = useRecoilState(store.playbackRate);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(key: string, newValue: string | number) => {
|
||||
const settings = {
|
||||
sttExternal: { value: sttExternal, setFunc: setSttExternal },
|
||||
ttsExternal: { value: ttsExternal, setFunc: setTtsExternal },
|
||||
conversationMode: { value: conversationMode, setFunc: setConversationMode },
|
||||
advancedMode: { value: advancedMode, setFunc: setAdvancedMode },
|
||||
speechToText: { value: speechToText, setFunc: setSpeechToText },
|
||||
textToSpeech: { value: textToSpeech, setFunc: setTextToSpeech },
|
||||
cacheTTS: { value: cacheTTS, setFunc: setCacheTTS },
|
||||
engineSTT: { value: engineSTT, setFunc: setEngineSTT },
|
||||
languageSTT: { value: languageSTT, setFunc: setLanguageSTT },
|
||||
autoTranscribeAudio: { value: autoTranscribeAudio, setFunc: setAutoTranscribeAudio },
|
||||
decibelValue: { value: decibelValue, setFunc: setDecibelValue },
|
||||
autoSendText: { value: autoSendText, setFunc: setAutoSendText },
|
||||
engineTTS: { value: engineTTS, setFunc: setEngineTTS },
|
||||
voice: { value: voice, setFunc: setVoice },
|
||||
cloudBrowserVoices: { value: cloudBrowserVoices, setFunc: setCloudBrowserVoices },
|
||||
languageTTS: { value: languageTTS, setFunc: setLanguageTTS },
|
||||
automaticPlayback: { value: automaticPlayback, setFunc: setAutomaticPlayback },
|
||||
playbackRate: { value: playbackRate, setFunc: setPlaybackRate },
|
||||
};
|
||||
|
||||
const setting = settings[key];
|
||||
if (setting) {
|
||||
setting.setFunc(newValue);
|
||||
}
|
||||
},
|
||||
[
|
||||
sttExternal,
|
||||
ttsExternal,
|
||||
conversationMode,
|
||||
advancedMode,
|
||||
speechToText,
|
||||
textToSpeech,
|
||||
cacheTTS,
|
||||
engineSTT,
|
||||
languageSTT,
|
||||
autoTranscribeAudio,
|
||||
decibelValue,
|
||||
autoSendText,
|
||||
engineTTS,
|
||||
voice,
|
||||
cloudBrowserVoices,
|
||||
languageTTS,
|
||||
automaticPlayback,
|
||||
playbackRate,
|
||||
setSttExternal,
|
||||
setTtsExternal,
|
||||
setConversationMode,
|
||||
setAdvancedMode,
|
||||
setSpeechToText,
|
||||
setTextToSpeech,
|
||||
setCacheTTS,
|
||||
setEngineSTT,
|
||||
setLanguageSTT,
|
||||
setAutoTranscribeAudio,
|
||||
setDecibelValue,
|
||||
setAutoSendText,
|
||||
setEngineTTS,
|
||||
setVoice,
|
||||
setCloudBrowserVoices,
|
||||
setLanguageTTS,
|
||||
setAutomaticPlayback,
|
||||
setPlaybackRate,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.message !== 'not_found') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
// Only apply config values as defaults if no user preference exists in localStorage
|
||||
const existingValue = localStorage.getItem(key);
|
||||
if (existingValue === null && key !== 'sttExternal' && key !== 'ttsExternal') {
|
||||
updateSetting(key, value);
|
||||
} else if (key === 'sttExternal' || key === 'ttsExternal') {
|
||||
updateSetting(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
// Reset engineTTS if it is set to a removed/invalid value (e.g., 'edge')
|
||||
// TODO: remove this once the 'edge' engine is fully deprecated
|
||||
useEffect(() => {
|
||||
const validEngines = ['browser', 'external'];
|
||||
if (!validEngines.includes(engineTTS)) {
|
||||
setEngineTTS('browser');
|
||||
}
|
||||
}, [engineTTS, setEngineTTS]);
|
||||
|
||||
const contentRef = useRef(null);
|
||||
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
|
||||
|
||||
return (
|
||||
<Tabs.Root
|
||||
defaultValue={'simple'}
|
||||
orientation="horizontal"
|
||||
value={advancedMode ? 'advanced' : 'simple'}
|
||||
>
|
||||
<div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700">
|
||||
<Tabs.List className="flex justify-center bg-background">
|
||||
<Tabs.Trigger
|
||||
onClick={() => setAdvancedMode(false)}
|
||||
className={cn(
|
||||
'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
|
||||
isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
|
||||
'w-full',
|
||||
)}
|
||||
value="simple"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<Lightbulb aria-hidden="true" />
|
||||
{localize('com_ui_simple')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
onClick={() => setAdvancedMode(true)}
|
||||
className={cn(
|
||||
'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
|
||||
isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
|
||||
'w-full',
|
||||
)}
|
||||
value="advanced"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<Cog aria-hidden="true" />
|
||||
{localize('com_ui_advanced')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Tabs.Content value={'simple'} tabIndex={-1}>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<SpeechToTextSwitch />
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
<LanguageSTTDropdown />
|
||||
<div className="h-px bg-border-medium" role="none" />
|
||||
<TextToSpeechSwitch />
|
||||
<EngineTTSDropdown external={ttsExternal} />
|
||||
<VoiceDropdown />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<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" />
|
||||
<SpeechToTextSwitch />
|
||||
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
|
||||
<LanguageSTTDropdown />
|
||||
<div className="pb-2">
|
||||
<AutoTranscribeAudioSwitch />
|
||||
</div>
|
||||
{autoTranscribeAudio && (
|
||||
<div className="pb-2">
|
||||
<DecibelSelector />
|
||||
</div>
|
||||
)}
|
||||
<div className="pb-2">
|
||||
<AutoSendTextSelector />
|
||||
</div>
|
||||
<div className="h-px bg-border-medium" role="none" />
|
||||
<div className="pb-3">
|
||||
<TextToSpeechSwitch />
|
||||
</div>
|
||||
<AutomaticPlaybackSwitch />
|
||||
<EngineTTSDropdown external={ttsExternal} />
|
||||
<VoiceDropdown />
|
||||
{engineTTS === 'browser' && (
|
||||
<div className="pb-2">
|
||||
<CloudBrowserVoicesSwitch />
|
||||
</div>
|
||||
)}
|
||||
<div className="pb-2">
|
||||
<PlaybackRate />
|
||||
</div>
|
||||
<CacheTTSSwitch />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Speech);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -6,12 +7,14 @@ export default function AutomaticPlaybackSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
return (
|
||||
<ToggleSwitch
|
||||
stateAtom={store.automaticPlayback}
|
||||
localizationKey={'com_nav_automatic_playback' as const}
|
||||
switchId="AutomaticPlayback"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Dropdown } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ interface EngineTTSDropdownProps {
|
|||
const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
||||
const localize = useLocalize();
|
||||
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
|
||||
const endpointOptions = external
|
||||
? [
|
||||
|
|
@ -36,6 +37,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
testId="EngineTTSDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ const voiceDropdownComponentsMap = {
|
|||
|
||||
export default function VoiceDropdown() {
|
||||
const engineTTS = useRecoilValue<string>(store.engineTTS);
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
const VoiceDropdownComponent = voiceDropdownComponentsMap[engineTTS];
|
||||
|
||||
if (!VoiceDropdownComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <VoiceDropdownComponent />;
|
||||
return <VoiceDropdownComponent disabled={!textToSpeech} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,2 @@
|
|||
export { default as Chat } from './Chat/Chat';
|
||||
export { default as Data } from './Data/Data';
|
||||
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';
|
||||
export { default as About } from './About/About';
|
||||
export { ThemeSelector, LangSelector } from './General/Selectors';
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
} from '@librechat/client';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/Selectors';
|
||||
import { ShareMessagesProvider } from './ShareMessagesProvider';
|
||||
import { ShareArtifactsContainer } from './ShareArtifacts';
|
||||
import { useLocalize, useDocumentTitle } from '~/hooks';
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
|
||||
import { TTSEndpoints } from '~/common';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const VALID_TTS_ENGINES: string[] = [TTSEndpoints.browser, TTSEndpoints.external];
|
||||
|
||||
/**
|
||||
* Initializes speech-related Recoil values from the server-side custom
|
||||
* configuration on first load (only when the user is authenticated)
|
||||
*/
|
||||
export default function useSpeechSettingsInit(isAuthenticated: boolean) {
|
||||
const { data } = useGetCustomConfigSpeechQuery({ enabled: isAuthenticated });
|
||||
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
|
||||
|
||||
const setters = useRef({
|
||||
conversationMode: useSetRecoilState(store.conversationMode),
|
||||
|
|
@ -22,7 +26,7 @@ export default function useSpeechSettingsInit(isAuthenticated: boolean) {
|
|||
autoTranscribeAudio: useSetRecoilState(store.autoTranscribeAudio),
|
||||
decibelValue: useSetRecoilState(store.decibelValue),
|
||||
autoSendText: useSetRecoilState(store.autoSendText),
|
||||
engineTTS: useSetRecoilState(store.engineTTS),
|
||||
engineTTS: setEngineTTS,
|
||||
voice: useSetRecoilState(store.voice),
|
||||
cloudBrowserVoices: useSetRecoilState(store.cloudBrowserVoices),
|
||||
languageTTS: useSetRecoilState(store.languageTTS),
|
||||
|
|
@ -47,4 +51,10 @@ export default function useSpeechSettingsInit(isAuthenticated: boolean) {
|
|||
}
|
||||
});
|
||||
}, [isAuthenticated, data, setters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (VALID_TTS_ENGINES.includes(engineTTS)) return;
|
||||
logger.log(`Resetting invalid TTS engine "${engineTTS}" to ${TTSEndpoints.browser}`);
|
||||
setEngineTTS(TTSEndpoints.browser);
|
||||
}, [engineTTS, setEngineTTS]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ To add a new language to LibreChat, follow these steps:
|
|||
|
||||
### 2. Update the Language Selector Component
|
||||
|
||||
Edit `client/src/components/Nav/SettingsTabs/General/General.tsx` and add your new language option to the `languageOptions` array:
|
||||
Edit `client/src/components/Nav/SettingsTabs/General/Selectors.tsx` and add your new language option to the `languageOptions` array:
|
||||
|
||||
```typescript
|
||||
{ value: 'language-code', label: localize('com_nav_lang_language_name') },
|
||||
|
|
|
|||
|
|
@ -408,12 +408,10 @@
|
|||
"com_nav_about_branch": "Branch",
|
||||
"com_nav_about_build_date": "Built",
|
||||
"com_nav_about_commit": "Commit",
|
||||
"com_nav_about_diagnostics_copied": "Copied to clipboard",
|
||||
"com_nav_about_diagnostics_copied": "Copied",
|
||||
"com_nav_about_diagnostics_copy": "Copy diagnostics",
|
||||
"com_nav_about_diagnostics_description": "Copy this block when opening a support issue so maintainers can identify the exact build you're running.",
|
||||
"com_nav_about_diagnostics_heading": "Diagnostics",
|
||||
"com_nav_about_version": "Version",
|
||||
"com_nav_about_version_heading": "Build information",
|
||||
"com_nav_account_settings": "Account Settings",
|
||||
"com_nav_advanced_prompts": "Advanced prompts editor",
|
||||
"com_nav_advanced_prompts_desc": "Enable versioning and production control for prompts",
|
||||
|
|
@ -457,8 +455,6 @@
|
|||
"com_nav_browser": "Browser",
|
||||
"com_nav_center_chat_input": "Center Chat Input on Welcome Screen",
|
||||
"com_nav_change_picture": "Change picture",
|
||||
"com_nav_chat_commands": "Chat Commands",
|
||||
"com_nav_chat_commands_info": "These commands are activated by typing specific characters at the beginning of your message. Each command is triggered by its designated prefix. You can disable them if you frequently use these characters to start messages.",
|
||||
"com_nav_chat_direction": "Chat direction",
|
||||
"com_nav_chat_direction_selected": "Chat direction: {{direction}}",
|
||||
"com_nav_clear_all_chats": "Clear all chats",
|
||||
|
|
@ -466,7 +462,6 @@
|
|||
"com_nav_clear_conversation": "Clear conversations",
|
||||
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
|
||||
"com_nav_close_sidebar": "Close sidebar",
|
||||
"com_nav_commands": "Commands",
|
||||
"com_nav_confirm_clear": "Confirm Clear",
|
||||
"com_nav_control_panel": "Control Panel",
|
||||
"com_nav_conversation_mode": "Conversation Mode",
|
||||
|
|
@ -594,13 +589,10 @@
|
|||
"com_nav_send_message": "Send message",
|
||||
"com_nav_setting_about": "About",
|
||||
"com_nav_setting_account": "Account",
|
||||
"com_nav_setting_balance": "Balance",
|
||||
"com_nav_setting_chat": "Chat",
|
||||
"com_nav_setting_data": "Data controls",
|
||||
"com_nav_setting_delay": "Delay (s)",
|
||||
"com_nav_setting_general": "General",
|
||||
"com_nav_setting_mcp": "MCP Settings",
|
||||
"com_nav_setting_personalization": "Personalization",
|
||||
"com_nav_setting_speech": "Speech",
|
||||
"com_nav_settings": "Settings",
|
||||
"com_nav_shared_links": "Shared links",
|
||||
|
|
@ -1317,7 +1309,6 @@
|
|||
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
|
||||
"com_ui_no_memories_match": "No memories match your search",
|
||||
"com_ui_no_memories_title": "No memories yet",
|
||||
"com_ui_no_personalization_available": "No personalization options are currently available",
|
||||
"com_ui_no_project_chats": "No chats yet",
|
||||
"com_ui_no_projects": "No projects yet",
|
||||
"com_ui_no_prompts_title": "No prompts yet",
|
||||
|
|
@ -1510,6 +1501,7 @@
|
|||
"com_ui_search_above_to_add_people": "Search above to add people",
|
||||
"com_ui_search_agent_category": "Search categories...",
|
||||
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
|
||||
"com_ui_search_language": "Search languages...",
|
||||
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
|
||||
"com_ui_search_projects": "Search projects",
|
||||
"com_ui_search_result_count": "{{count}} result found",
|
||||
|
|
@ -1534,6 +1526,46 @@
|
|||
"com_ui_select_search_provider": "Search provider by name",
|
||||
"com_ui_select_search_region": "Search region by name",
|
||||
"com_ui_set": "Set",
|
||||
"com_ui_settings_label_2fa": "Two-factor authentication",
|
||||
"com_ui_settings_label_agent_api_keys": "Agent API keys",
|
||||
"com_ui_settings_label_auto_refill": "Auto-refill",
|
||||
"com_ui_settings_label_avatar": "Avatar",
|
||||
"com_ui_settings_label_backup_codes": "Backup codes",
|
||||
"com_ui_settings_label_clear_chats": "Clear all chats",
|
||||
"com_ui_settings_label_conversation_mode": "Conversation mode",
|
||||
"com_ui_settings_label_credits": "Token balance",
|
||||
"com_ui_settings_label_decibel": "Decibel threshold",
|
||||
"com_ui_settings_label_delete_account": "Delete account",
|
||||
"com_ui_settings_label_delete_cache": "Delete cache",
|
||||
"com_ui_settings_label_engine_stt": "Speech-to-text engine",
|
||||
"com_ui_settings_label_engine_tts": "Text-to-speech engine",
|
||||
"com_ui_settings_label_import": "Import conversations",
|
||||
"com_ui_settings_label_language_stt": "Speech-to-text language",
|
||||
"com_ui_settings_label_playback_rate": "Playback rate",
|
||||
"com_ui_settings_label_revoke_keys": "Revoke keys",
|
||||
"com_ui_settings_label_shared_links": "Shared links",
|
||||
"com_ui_settings_label_voice": "Voice",
|
||||
"com_ui_settings_no_results": "No settings match your search",
|
||||
"com_ui_settings_results_aria": "Search results",
|
||||
"com_ui_settings_search_placeholder": "Search settings",
|
||||
"com_ui_settings_section_accessibility": "Accessibility",
|
||||
"com_ui_settings_section_api_keys": "API keys",
|
||||
"com_ui_settings_section_appearance": "Appearance",
|
||||
"com_ui_settings_section_billing": "Billing",
|
||||
"com_ui_settings_section_commands": "Commands",
|
||||
"com_ui_settings_section_conversations": "Conversations",
|
||||
"com_ui_settings_section_danger_zone": "Danger zone",
|
||||
"com_ui_settings_section_data": "Your data",
|
||||
"com_ui_settings_section_layout": "Layout",
|
||||
"com_ui_settings_section_memory": "Memory",
|
||||
"com_ui_settings_section_messages": "Messages",
|
||||
"com_ui_settings_section_profile": "Profile",
|
||||
"com_ui_settings_section_prompts": "Prompts",
|
||||
"com_ui_settings_section_security": "Security",
|
||||
"com_ui_settings_section_sending": "Sending",
|
||||
"com_ui_settings_section_stt": "Speech to text",
|
||||
"com_ui_settings_section_tts": "Text to speech",
|
||||
"com_ui_settings_tab_data": "Data & Privacy",
|
||||
"com_ui_share": "Share",
|
||||
"com_ui_share_create_message": "Your name and any messages you add after sharing stay private.",
|
||||
"com_ui_share_delete_error": "There was an error deleting the shared link",
|
||||
|
|
@ -1559,7 +1591,6 @@
|
|||
"com_ui_show_n_files": "Show {{0}} files",
|
||||
"com_ui_show_qr": "Show QR Code",
|
||||
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
|
||||
"com_ui_simple": "Simple",
|
||||
"com_ui_size": "Size",
|
||||
"com_ui_size_sort": "Sort by Size",
|
||||
"com_ui_skill": "Skill",
|
||||
|
|
|
|||
|
|
@ -2703,10 +2703,6 @@ html {
|
|||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.account-settings-popover .select-item[data-active-item] {
|
||||
box-shadow: 0 0 0 2px hsl(var(--ring));
|
||||
}
|
||||
|
||||
.popover-ui[data-enter] {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
|
|
|
|||
|
|
@ -157,7 +157,9 @@ test.describe('agent builder', () => {
|
|||
const createdAgent = (await createResponse.json()) as AgentDetail;
|
||||
createdAgentId = createdAgent.id;
|
||||
|
||||
await expect(page.getByText(`Successfully created ${agentName}`)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Successfully created ${agentName}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const persistedAgent = await waitForPersistedAgent(page, agentName, DESCRIPTION);
|
||||
expect(persistedAgent).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import * as Combobox from '@ariakit/react/combobox';
|
||||
import type { Option } from '~/common';
|
||||
import { cn } from '~/utils/';
|
||||
import './Dropdown.css';
|
||||
|
|
@ -18,6 +21,10 @@ interface DropdownProps {
|
|||
ariaLabel?: string;
|
||||
'aria-labelledby'?: string;
|
||||
portal?: boolean;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchEmptyText?: string;
|
||||
}
|
||||
|
||||
const isDivider = (item: string | Option | { divider: true }): item is { divider: true } =>
|
||||
|
|
@ -26,6 +33,9 @@ const isDivider = (item: string | Option | { divider: true }): item is { divider
|
|||
const isOption = (item: string | Option | { divider: true }): item is Option =>
|
||||
typeof item === 'object' && 'value' in item && 'label' in item;
|
||||
|
||||
const normalizeOption = (item: string | Option): Option =>
|
||||
typeof item === 'string' ? { value: item, label: item } : item;
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
value: selectedValue,
|
||||
label = '',
|
||||
|
|
@ -40,12 +50,25 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
portal = true,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
searchPlaceholder,
|
||||
searchEmptyText,
|
||||
}) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const comboboxStore = Combobox.useComboboxStore({
|
||||
resetValueOnHide: true,
|
||||
value: searchValue,
|
||||
setValue: setSearchValue,
|
||||
});
|
||||
|
||||
const selectProps = Select.useSelectStore({
|
||||
combobox: searchable ? comboboxStore : undefined,
|
||||
value: selectedValue,
|
||||
setValue: handleChange,
|
||||
});
|
||||
|
|
@ -56,8 +79,8 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
}
|
||||
return options
|
||||
.filter((o) => !isDivider(o))
|
||||
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
|
||||
.find((o) => isOption(o) && o.value === val) as Option | undefined;
|
||||
.map((o) => normalizeOption(o as string | Option))
|
||||
.find((o) => o.value === val);
|
||||
};
|
||||
|
||||
const getOptionLabel = (currentValue: string | undefined) => {
|
||||
|
|
@ -68,12 +91,54 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
return option ? option.label : currentValue;
|
||||
};
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!searchable) {
|
||||
return [];
|
||||
}
|
||||
const optionItems = options
|
||||
.filter((o) => !isDivider(o))
|
||||
.map((o) => normalizeOption(o as string | Option));
|
||||
return matchSorter(optionItems, searchValue, {
|
||||
keys: ['label', 'value'],
|
||||
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
|
||||
});
|
||||
}, [searchable, options, searchValue]);
|
||||
|
||||
const renderOptionContent = (option: Option) => (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{selectedValue === option.value && (
|
||||
<span className="ml-auto pl-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Select.Select
|
||||
store={selectProps}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background disabled:hover:text-text-primary',
|
||||
iconOnly ? 'size-10' : 'w-fit gap-2',
|
||||
className,
|
||||
)}
|
||||
|
|
@ -109,49 +174,74 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
|
||||
)}
|
||||
>
|
||||
{options.map((item, index) => {
|
||||
if (isDivider(item)) {
|
||||
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
|
||||
}
|
||||
|
||||
const option = typeof item === 'string' ? { value: item, label: item } : item;
|
||||
if (!isOption(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.SelectItem
|
||||
key={`option-${index}`}
|
||||
value={String(option.value)}
|
||||
className="select-item"
|
||||
data-theme={option.value}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{selectedValue === option.value && (
|
||||
<span className="ml-auto pl-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{searchable ? (
|
||||
<>
|
||||
<div className="sticky -top-2 z-10 -mx-2 -mt-2 mb-1 bg-surface-primary px-2 pb-1.5 pt-2 dark:bg-surface-secondary">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Combobox
|
||||
store={comboboxStore}
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
className="w-full rounded-lg bg-transparent py-1.5 pl-8 pr-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
|
||||
/>
|
||||
</div>
|
||||
</Select.SelectItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Combobox.ComboboxList
|
||||
store={comboboxStore}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{matches.map((option) => (
|
||||
<Combobox.ComboboxItem
|
||||
key={`option-${String(option.value)}`}
|
||||
value={String(option.value)}
|
||||
className="select-item"
|
||||
render={
|
||||
<Select.SelectItem value={String(option.value)} data-theme={option.value} />
|
||||
}
|
||||
>
|
||||
{renderOptionContent(option)}
|
||||
</Combobox.ComboboxItem>
|
||||
))}
|
||||
</Combobox.ComboboxList>
|
||||
{matches.length === 0 && (
|
||||
<div className="px-2 py-6 text-center text-sm text-text-secondary" aria-hidden="true">
|
||||
{searchEmptyText}
|
||||
</div>
|
||||
)}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{matches.length === 0 ? searchEmptyText : ''}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
options.map((item, index) => {
|
||||
if (isDivider(item)) {
|
||||
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
|
||||
}
|
||||
|
||||
const option = normalizeOption(item);
|
||||
if (!isOption(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.SelectItem
|
||||
key={`option-${index}`}
|
||||
value={String(option.value)}
|
||||
className="select-item"
|
||||
data-theme={option.value}
|
||||
>
|
||||
{renderOptionContent(option)}
|
||||
</Select.SelectItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Select.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue