mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🎛️ feat: Consistent Dialogs, Clearer Settings, and a Keyboard Shortcuts Switch (#14882)
* refactor(AdminSettings): consolidate every admin dialog on one implementation The People Picker admin dialog was a standalone reimplementation of the shared AdminSettingsDialog with a better layout, leaving two components to keep in sync by hand. Port that layout into the shared component and rewrite People Picker to configure it, so all eight admin dialogs render from one place. The shared dialog gains the icon-tile header, the role selector and permission switches as bordered cards, and a real footer. Its header row was also top-aligning the 40px icon tile against a single-line title, leaving the icon hanging 6px low; it now centers. Both behaviors the standalone version lacked are preserved: the admin access warning and the confirm-before-disable flow used by Prompts. Adds an optional descriptionKey for a screen-reader description, and closes the dialog when the mutation reports success, which is how People Picker kept its auto-close. Permission switch ids are prefixed with useId so two mounted dialogs cannot collide. Marketplace dropped its dialogContentClassName override because the max-w-md and background it set conflicted with the new padding. * feat(ui): add FieldMessage for helper text that never shifts layout Form fields across the app render their validation error conditionally, so the error appearing pushes every field below it down. FieldMessage always occupies one line and only swaps its content and color between a resting hint, an error, and nothing, so the surrounding layout is fixed by construction rather than by whichever message happens to be showing. * fix(Memories): validate the key and value on the client Creating or updating a memory only learned that its key was malformed or already taken after the request came back, and the failure arrived as a toast. getMemoryKeyError mirrors the schema validator in data-schemas and checks for a duplicate within the same agent partition, so both dialogs resolve the outcome before sending anything. Errors render inline under the field instead of as a toast, live from the first character typed, and Create and Save stay disabled while any error stands. A pristine empty field shows only its hint, so the form does not report a problem before there is one. The edit dialog also closed itself in onMutate, discarding the user's edits whenever the server rejected the write and leaving the error toast to land on a dialog that was already gone. It now closes on success. This drops six toasts to two: the field-required and duplicate-key toasts are covered inline, leaving one generic toast per dialog for unexpected server failures alongside the existing success toast. * fix(Bookmarks): consolidate title validation and show it inline The duplicate-title check ran from three sources against two different strings: an inline validator reading the bookmark context, plus two warning toasts in onSubmit reading the tags prop and the conversationTags cache. All three now feed one helper behind the single inline validator, so both toasts are gone and the message is always com_ui_bookmarks_tag_exists. The title error was rendered conditionally, so it pushed the description field down as it appeared and disappeared; it now uses FieldMessage. The description registered a maxLength rule but rendered its error nowhere, so exceeding 1048 characters silently refused to submit with nothing on screen. It now reports like the title does. Submitting also closed the dialog immediately, throwing away what the user typed if the request failed, even though the mutation's onSuccess already closed it. Dropping that leaves the form with no reason to take setOpen. Renaming is no longer blocked when the title is unchanged: the tags-prop check had no exemption for the bookmark's own title, so editing just the description of a bookmark attached to the current conversation reported a duplicate and refused to save. The two tests that asserted the removed toasts now assert the inline error and that no toast fires. * feat(Skills): label the availability toggle and drop the detail icon The toggle in the skill detail header was a bare switch whose only name was an aria-label reading "Toggle skill active state", so nothing on screen said what it did, and "active" did not say active for what. It now carries a visible "Available to agent" label bound to the switch, plus a tooltip stating the effect: when on, the agent can use this skill in new messages. The label text stays fixed while the switch carries the state, so flipping it cannot resize the action row and nudge the buttons beside it. Also removes the decorative ScrollText circle from the detail header. It conveyed nothing the heading did not already say, and dropping it lets the title block sit at the top level instead of nested inside a flex row that now has a single child. * feat(Settings): move file management into Data Controls and clarify its labels Files were reachable only from the account dropdown, away from the other data-management entries. A Manage files row now sits in Data Controls beside Import conversations and Shared links, opening the same modal, and the account menu item is gone so there is one place to look. Two labels renamed for accuracy and consistency: - "Revoke all user provided credentials" becomes "Revoke all provider API keys". It sits in the API keys section beside Provider API keys and Agent API keys, so it should name what it revokes; "credentials" was vague and "user provided" described the system's perspective rather than the user's. - "Clear all chats" becomes "Delete all chats", matching its own Delete button and the "Delete TTS cache storage" row beside it. The action is irreversible, which delete states more plainly than clear. The TTS cache row gains an InfoHoverCard, the same explanation affordance used by the API keys dialog. Nothing previously said what the cache held or why its button is so often greyed out, which happens whenever the cache is empty, including for anyone who has never used text-to-speech. * feat(Shortcuts): add a switch that disables every keyboard shortcut There was no way to turn shortcuts off short of rebinding each one to nothing, which loses the bindings. A switch at the top of the shortcuts dialog now suppresses all of them at once while keeping every custom binding intact, so turning it back on restores the previous setup. The preference persists per browser in localStorage next to the custom bindings. Enforcement is a single guard in the window keydown handler, which already owns every shortcut, so nothing dispatches while it is on. Nothing is exempt, including the chord that opens this dialog. The dialog is still reachable from the account menu, so the switch cannot lock anyone out, and an exception would contradict what it says. useShortcutDisplay and useShortcutAriaKey return empty while it is on, so tooltips and aria-keyshortcuts across the app stop naming chords that would not fire. The binding rows stay editable, so shortcuts can be configured before turning them back on. * style(Skills,Prompts): align side panel spacing with the other panels Memories and Bookmarks share one spacing contract: 8px above the header, 12px down each side, and 12px under the last row. Skills and Prompts each drifted from it, so switching panels nudged the content. Skills sat at 16px per side and 12px on top, with the list running flush into the bottom edge. Its top padding now comes from the panel root like the other panels, its header and list use the shared 12px sides, and the list gets the same bottom inset. Prompts was applying the top padding twice, once on the panel root from the accordion and again on its own header, for 16px, and its list also ran flush into the bottom. The header no longer adds its own, and the list gets the bottom inset. Its asymmetric pl-3 pr-1 is left alone: the list reserves an 8px scrollbar gutter, so those values already render as an even 12px on both sides. Squaring the padding numbers would have made the panel visibly lopsided. Measured after the change, all four panels report 8px top, 12px bottom, and 12px on each side. * refactor(Shortcuts): invert the switch to an enabled-by-default control The control read "Disable keyboard shortcuts", so it was on when the feature was off. Inverting it makes the switch agree with the thing it names: it now reads "Keyboard Shortcuts", ships on, and turning it off is what stops the shortcuts. The stored value follows, from keyboardShortcutsDisabled to keyboardShortcutsEnabled defaulting to true. Nothing migrates the old key because the previous shape never shipped, and an absent value now means enabled, which is the default anyway. The row loses its filled card and sits as a plain bottom-bordered row under the title, reading as a section header for the list rather than a block competing with it. Every binding row now renders as disabled while the switch is off, dimmed with its edit and reset buttons actually disabled rather than merely looking inert. Any row left mid-edit is closed when the switch goes off, so the recorder cannot keep capturing keys for a shortcut that would not fire. * style(Shortcuts): fit the dialog on desktop without a scrollbar Open panels was a full-width block stacked under the two shortcut columns, so opening the dialog on a desktop viewport always started with a scrollbar, at about 100px of overflow. It becomes the third column instead. That removes the stacked block entirely, the three columns land at comparable heights, and the content now fits with nothing to scroll. The dialog widens on large screens to hold the extra column. Narrower viewports are unchanged in spirit: the panels list spans both columns below the shortcuts at tablet width and everything stacks into one column on a phone, scrolling as it did before. Reflowing the groups with CSS multi-column was the other option and looked worse: the short groups left a tall void beside Chat, and squeezing the panel rows into four columns truncated their labels. * style(Skills): make Edit an icon button and drop the detail text below the actions Edit was the only text button in a row of icon buttons, so it read as a different kind of control than Share and Delete beside it. It becomes a pencil icon at the same 36px size, carrying its label through a tooltip and an aria-label so the accessible name survives. The header row also centred its two halves against each other, which pinned the title level with the action buttons. The actions now pin to the top of the row and the text column starts below them, giving the title, author, date, and description a little room without moving the controls. * test: mock shortcut setting in expanded panel * remove unused com_ui_skill_toggle_active i18n key Superseded by com_ui_skill_available and com_ui_skill_available_hint in SkillToggle.tsx, but the old key was left behind in the locale files. * fix: honor shortcut switch in composer * fix: defer file loading until dialog opens * fix: preserve memory API errors * chore: restore automated locale entries * fix: honor shortcut switch during generation * refactor: share field message primitive * fix: reserve helper height for wrapping field messages * fix: wrap skill detail actions at narrow widths * fix: reset the memory create dialog when it closes
This commit is contained in:
parent
8a946290f6
commit
d79d1ff76a
35 changed files with 921 additions and 563 deletions
|
|
@ -1,10 +1,10 @@
|
|||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
|
||||
|
|
@ -50,7 +50,6 @@ const MarketplaceAdminSettings = ({ compact = false }: { compact?: boolean }) =>
|
|||
menuId="marketplace-role-dropdown"
|
||||
mutation={mutation}
|
||||
trigger={trigger}
|
||||
dialogContentClassName="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary"
|
||||
showAdminWarning={false}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ const BookmarkEditDialog = ({
|
|||
main={
|
||||
<BookmarkForm
|
||||
tags={tags}
|
||||
setOpen={setOpen}
|
||||
mutation={mutation}
|
||||
conversationId={conversationId}
|
||||
bookmark={bookmark}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Checkbox, Label, TextareaAutosize, Input, useToastContext } from '@librechat/client';
|
||||
import { Checkbox, FieldMessage, Label, TextareaAutosize, Input } from '@librechat/client';
|
||||
import type { TConversationTag, TConversationTagRequest } from 'librechat-data-provider';
|
||||
import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
|
|
@ -14,7 +14,6 @@ type TBookmarkFormProps = {
|
|||
bookmark?: TConversationTag;
|
||||
conversationId?: string;
|
||||
formRef: React.RefObject<HTMLFormElement>;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
mutation: ReturnType<typeof useConversationTagMutation>;
|
||||
};
|
||||
const BookmarkForm = ({
|
||||
|
|
@ -22,12 +21,10 @@ const BookmarkForm = ({
|
|||
bookmark,
|
||||
mutation,
|
||||
conversationId,
|
||||
setOpen,
|
||||
formRef,
|
||||
}: TBookmarkFormProps) => {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
|
||||
const {
|
||||
|
|
@ -48,12 +45,27 @@ const BookmarkForm = ({
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bookmark && bookmark.tag) {
|
||||
const [prevBookmark, setPrevBookmark] = React.useState(bookmark);
|
||||
|
||||
if (bookmark !== prevBookmark) {
|
||||
setPrevBookmark(bookmark);
|
||||
if (bookmark?.tag != null && bookmark.tag !== '') {
|
||||
setValue('tag', bookmark.tag);
|
||||
setValue('description', bookmark.description ?? '');
|
||||
}
|
||||
}, [bookmark, setValue]);
|
||||
}
|
||||
|
||||
/** Every source that could already hold the title, checked before the request is sent. */
|
||||
const isTagTaken = (value: string) => {
|
||||
const allTags =
|
||||
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
|
||||
|
||||
return (
|
||||
(tags ?? []).includes(value) ||
|
||||
allTags.some((tag) => tag.tag === value) ||
|
||||
bookmarks.some((existing) => existing.tag === value)
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = (data: TConversationTagRequest) => {
|
||||
logger.log('tag_mutation', 'BookmarkForm - onSubmit: data', data);
|
||||
|
|
@ -63,25 +75,8 @@ const BookmarkForm = ({
|
|||
if (data.tag === bookmark?.tag && data.description === bookmark?.description) {
|
||||
return;
|
||||
}
|
||||
if (data.tag != null && (tags ?? []).includes(data.tag)) {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_create_exists'),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allTags =
|
||||
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
|
||||
if (allTags.some((tag) => tag.tag === data.tag && tag.tag !== bookmark?.tag)) {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_create_exists'),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate(data);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -106,23 +101,18 @@ const BookmarkForm = ({
|
|||
}),
|
||||
},
|
||||
validate: (value) => {
|
||||
return (
|
||||
value === bookmark?.tag ||
|
||||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
|
||||
localize('com_ui_bookmarks_tag_exists')
|
||||
);
|
||||
if (value == null || value === '' || value === bookmark?.tag) {
|
||||
return true;
|
||||
}
|
||||
return !isTagTaken(value) || localize('com_ui_bookmarks_tag_exists');
|
||||
},
|
||||
})}
|
||||
className="w-full"
|
||||
aria-invalid={!!errors.tag}
|
||||
placeholder={localize('com_ui_enter_name')}
|
||||
aria-describedby={errors.tag ? 'bookmark-tag-error' : undefined}
|
||||
aria-describedby="bookmark-tag-error"
|
||||
/>
|
||||
{errors.tag && (
|
||||
<span id="bookmark-tag-error" role="alert" className="text-sm text-text-destructive">
|
||||
{errors.tag.message}
|
||||
</span>
|
||||
)}
|
||||
<FieldMessage id="bookmark-tag-error" message={errors.tag?.message} />
|
||||
</div>
|
||||
|
||||
{/* Description textarea */}
|
||||
|
|
@ -154,7 +144,10 @@ const BookmarkForm = ({
|
|||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
aria-labelledby="bookmark-description-label"
|
||||
aria-invalid={!!errors.description}
|
||||
aria-describedby="bookmark-description-error"
|
||||
/>
|
||||
<FieldMessage id="bookmark-description-error" message={errors.description?.message} />
|
||||
</div>
|
||||
|
||||
{/* Add to conversation checkbox */}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import React, { createRef } from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import BookmarkForm from '../BookmarkForm';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import BookmarkForm from '../BookmarkForm';
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockGetQueryData = jest.fn();
|
||||
const mockSetOpen = jest.fn();
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, params?: Record<string, unknown>) => {
|
||||
|
|
@ -48,6 +47,20 @@ jest.mock('@librechat/client', () => {
|
|||
}),
|
||||
Label: ({ children, ...props }: { children: React.ReactNode }) =>
|
||||
ActualReact.createElement('label', props, children),
|
||||
FieldMessage: ({
|
||||
id,
|
||||
message,
|
||||
hint,
|
||||
}: {
|
||||
id: string;
|
||||
message?: string | null;
|
||||
hint?: string | null;
|
||||
}) =>
|
||||
ActualReact.createElement(
|
||||
'p',
|
||||
{ id, role: message ? 'alert' : undefined },
|
||||
message || hint || '',
|
||||
),
|
||||
TextareaAutosize: ActualReact.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
|
@ -136,7 +149,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -160,7 +172,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
);
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not submit when both tag and description are unchanged', async () => {
|
||||
|
|
@ -179,7 +190,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -191,12 +201,11 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
await waitFor(() => {
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Renaming a tag to an existing tag (should show error)', () => {
|
||||
it('should show error toast when renaming to an existing tag name (via allTags)', async () => {
|
||||
describe('Renaming a tag to an existing tag (should show inline error)', () => {
|
||||
it('should show an inline error when renaming to an existing tag name (via allTags)', async () => {
|
||||
const existingBookmark = createMockBookmark({
|
||||
tag: 'Original Tag',
|
||||
description: 'Description',
|
||||
|
|
@ -218,7 +227,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -234,16 +242,15 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'This bookmark already exists',
|
||||
status: 'warning',
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'A bookmark with this title already exists',
|
||||
);
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error toast when renaming to an existing tag name (via tags prop)', async () => {
|
||||
it('should show an inline error when renaming to an existing tag name (via tags prop)', async () => {
|
||||
const existingBookmark = createMockBookmark({
|
||||
tag: 'Original Tag',
|
||||
description: 'Description',
|
||||
|
|
@ -260,7 +267,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -276,13 +282,12 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'This bookmark already exists',
|
||||
status: 'warning',
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'A bookmark with this title already exists',
|
||||
);
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -303,7 +308,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -327,7 +331,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
);
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should allow keeping the same tag name when editing (not trigger duplicate error)', async () => {
|
||||
|
|
@ -346,7 +349,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -396,7 +398,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -412,11 +413,11 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'This bookmark already exists',
|
||||
status: 'warning',
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'A bookmark with this title already exists',
|
||||
);
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -436,7 +437,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -472,7 +472,6 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
|||
typeof import('~/data-provider').useConversationTagMutation
|
||||
>
|
||||
}
|
||||
setOpen={mockSetOpen}
|
||||
formRef={formRef}
|
||||
/>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const DuringRunSendButton = React.memo(
|
|||
const localize = useLocalize();
|
||||
const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault);
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const { submitOverride, yieldedChords } = useComposerBindings();
|
||||
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
|
||||
const { steering } = props;
|
||||
const data = useWatch({ control: props.control });
|
||||
const content = data?.text?.trim();
|
||||
|
|
@ -80,6 +80,7 @@ const DuringRunSendButton = React.memo(
|
|||
isSubmitting: true,
|
||||
allowSubmitWhileGenerating: true,
|
||||
hasDuringRunModifier: true,
|
||||
shortcutsEnabled,
|
||||
enterToSend,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
|
|
@ -96,7 +97,7 @@ const DuringRunSendButton = React.memo(
|
|||
modShiftEnter: chord({ ...mod, shiftKey: true }),
|
||||
altEnter: chord({ altKey: true }),
|
||||
};
|
||||
}, [enterToSend, submitOverride, yieldedChords]);
|
||||
}, [enterToSend, shortcutsEnabled, submitOverride, yieldedChords]);
|
||||
|
||||
/**
|
||||
* With the preference on, plain Enter routes through `submitDuringRun`,
|
||||
|
|
|
|||
|
|
@ -67,16 +67,24 @@ function Harness({ steering }: { steering: SteeringControls }) {
|
|||
type MenuOptions = StubOptions & {
|
||||
enterInterrupts?: boolean;
|
||||
enterToSend?: boolean;
|
||||
shortcutsEnabled?: boolean;
|
||||
customShortcuts?: Record<string, ShortcutOverride>;
|
||||
};
|
||||
|
||||
function openMenu(options: MenuOptions = {}) {
|
||||
const { enterInterrupts = false, enterToSend = true, customShortcuts = {}, ...stub } = options;
|
||||
const {
|
||||
enterInterrupts = false,
|
||||
enterToSend = true,
|
||||
shortcutsEnabled = true,
|
||||
customShortcuts = {},
|
||||
...stub
|
||||
} = options;
|
||||
render(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
set(store.steerInterruptsByDefault, enterInterrupts);
|
||||
set(store.enterToSend, enterToSend);
|
||||
set(store.shortcutsEnabled, shortcutsEnabled);
|
||||
set(store.customShortcuts, customShortcuts);
|
||||
}}
|
||||
>
|
||||
|
|
@ -257,4 +265,12 @@ describe('DuringRunSendButton — hints follow the effective bindings', () => {
|
|||
expect(kbdFor('com_ui_interrupt_steer')).toBe('Ctrl ⇧ ⏎');
|
||||
expect(kbdFor('com_ui_interrupt_send')).toBe('Alt ⏎');
|
||||
});
|
||||
|
||||
test('keeps plain Enter but hides shortcut hints when shortcuts are disabled', () => {
|
||||
openMenu({ canSteer: true, shortcutsEnabled: false });
|
||||
expect(kbdFor('com_ui_steer')).toBe('⏎');
|
||||
expect(kbdFor('com_ui_queue')).toBeNull();
|
||||
expect(kbdFor('com_ui_interrupt_steer')).toBeNull();
|
||||
expect(kbdFor('com_ui_interrupt_send')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
Archive,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
FileText,
|
||||
Keyboard,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
|
|
@ -14,7 +13,6 @@ import {
|
|||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
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';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -100,7 +98,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
enabled: !!isAuthenticated && startupConfig?.balance?.enabled,
|
||||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -160,10 +157,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
privacyPolicyURL={startupConfig?.interface?.privacyPolicy?.externalUrl}
|
||||
onShowShortcuts={() => setShowShortcutsDialog(true)}
|
||||
/>
|
||||
<Menu.MenuItem onClick={() => setShowFiles(true)} className="select-item text-sm">
|
||||
<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')}
|
||||
|
|
@ -182,13 +175,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
{localize('com_nav_log_out')}
|
||||
</Menu.MenuItem>
|
||||
</Menu.Menu>
|
||||
{showFiles && (
|
||||
<MyFilesModal
|
||||
open={showFiles}
|
||||
onOpenChange={setShowFiles}
|
||||
triggerRef={accountSettingsButtonRef}
|
||||
/>
|
||||
)}
|
||||
{showArchived && (
|
||||
<ArchivedChatsModal
|
||||
open={showArchived}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useId, useMemo, useState } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogClose } from '@librechat/client';
|
||||
import {
|
||||
Label,
|
||||
Switch,
|
||||
OGDialog,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
import type { ShortcutActionId, ShortcutBindingInfo } from '~/hooks/useKeyboardShortcuts';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { ShortcutBinding } from '~/utils/shortcuts';
|
||||
|
|
@ -83,6 +90,7 @@ function EditingRow({
|
|||
function ShortcutRow({
|
||||
info,
|
||||
isEditing,
|
||||
disabled,
|
||||
onStartEdit,
|
||||
onStopEdit,
|
||||
bindingMap,
|
||||
|
|
@ -92,6 +100,7 @@ function ShortcutRow({
|
|||
}: {
|
||||
info: ShortcutBindingInfo;
|
||||
isEditing: boolean;
|
||||
disabled: boolean;
|
||||
onStartEdit: (id: ShortcutActionId) => void;
|
||||
onStopEdit: () => void;
|
||||
bindingMap: Map<string, ShortcutActionId>;
|
||||
|
|
@ -105,7 +114,7 @@ function ShortcutRow({
|
|||
const editAriaLabel = localize('com_shortcut_edit_aria', { 0: label });
|
||||
const isUnset = displayKeys.length === 0;
|
||||
|
||||
if (isEditing) {
|
||||
if (isEditing && !disabled) {
|
||||
return (
|
||||
<div className="px-2 py-2">
|
||||
<EditingRow
|
||||
|
|
@ -121,11 +130,16 @@ function ShortcutRow({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between gap-3 px-2 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center justify-between gap-3 px-2 py-2',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-[13px]',
|
||||
isUnset ? 'text-text-secondary' : 'text-text-primary',
|
||||
isUnset || disabled ? 'text-text-secondary' : 'text-text-primary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
|
@ -134,6 +148,7 @@ function ShortcutRow({
|
|||
{info.isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => resetBinding(info.id)}
|
||||
className="text-[11.5px] text-text-secondary opacity-0 transition-opacity hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary group-hover:opacity-100"
|
||||
>
|
||||
|
|
@ -143,6 +158,7 @@ function ShortcutRow({
|
|||
{isUnset ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onStartEdit(info.id)}
|
||||
aria-label={editAriaLabel}
|
||||
data-testid={`edit-shortcut-${info.id}`}
|
||||
|
|
@ -154,6 +170,7 @@ function ShortcutRow({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onStartEdit(info.id)}
|
||||
aria-label={editAriaLabel}
|
||||
data-testid={`edit-shortcut-${info.id}`}
|
||||
|
|
@ -171,6 +188,7 @@ function ShortcutGroup({
|
|||
groupKey,
|
||||
bindings,
|
||||
editingId,
|
||||
disabled,
|
||||
onStartEdit,
|
||||
onStopEdit,
|
||||
bindingMap,
|
||||
|
|
@ -181,6 +199,7 @@ function ShortcutGroup({
|
|||
groupKey: string;
|
||||
bindings: ShortcutBindingInfo[];
|
||||
editingId: ShortcutActionId | null;
|
||||
disabled: boolean;
|
||||
onStartEdit: (id: ShortcutActionId) => void;
|
||||
onStopEdit: () => void;
|
||||
bindingMap: Map<string, ShortcutActionId>;
|
||||
|
|
@ -200,6 +219,7 @@ function ShortcutGroup({
|
|||
key={info.id}
|
||||
info={info}
|
||||
isEditing={editingId === info.id}
|
||||
disabled={disabled}
|
||||
onStartEdit={onStartEdit}
|
||||
onStopEdit={onStopEdit}
|
||||
bindingMap={bindingMap}
|
||||
|
|
@ -216,6 +236,7 @@ function ShortcutGroup({
|
|||
function PanelsSection({
|
||||
bindings,
|
||||
editingId,
|
||||
disabled,
|
||||
onStartEdit,
|
||||
onStopEdit,
|
||||
bindingMap,
|
||||
|
|
@ -225,6 +246,7 @@ function PanelsSection({
|
|||
}: {
|
||||
bindings: ShortcutBindingInfo[];
|
||||
editingId: ShortcutActionId | null;
|
||||
disabled: boolean;
|
||||
onStartEdit: (id: ShortcutActionId) => void;
|
||||
onStopEdit: () => void;
|
||||
bindingMap: Map<string, ShortcutActionId>;
|
||||
|
|
@ -234,8 +256,8 @@ function PanelsSection({
|
|||
}) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<section className="border-t border-border-light px-5 pb-2 pt-4">
|
||||
<div className="mb-2 flex items-baseline justify-between gap-3 px-2">
|
||||
<section className="mb-6 border-t border-border-light pt-4 last:mb-0 md:col-span-2 lg:col-span-1 lg:border-t-0 lg:pt-0">
|
||||
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 px-2">
|
||||
<h3 className="text-[12px] font-medium text-text-secondary">
|
||||
{localize('com_shortcut_group_panels')}
|
||||
</h3>
|
||||
|
|
@ -243,12 +265,13 @@ function PanelsSection({
|
|||
{localize('com_shortcut_group_panels_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2 lg:grid-cols-1">
|
||||
{bindings.map((info) => (
|
||||
<ShortcutRow
|
||||
key={info.id}
|
||||
info={info}
|
||||
isEditing={editingId === info.id}
|
||||
disabled={disabled}
|
||||
onStartEdit={onStartEdit}
|
||||
onStopEdit={onStopEdit}
|
||||
bindingMap={bindingMap}
|
||||
|
|
@ -266,7 +289,9 @@ function KeyboardShortcutsDialog() {
|
|||
const localize = useLocalize();
|
||||
const { bindings, bindingMap, setBinding, resetBinding, resetAll } = useShortcutBindings();
|
||||
const [open, setOpen] = useRecoilState(store.showShortcutsDialog);
|
||||
const [enabled, setEnabled] = useRecoilState(store.shortcutsEnabled);
|
||||
const [editingId, setEditingId] = useState<ShortcutActionId | null>(null);
|
||||
const enableSwitchId = useId();
|
||||
|
||||
const grouped = useMemo<GroupedBindings>(() => {
|
||||
const groups: GroupedBindings = {};
|
||||
|
|
@ -323,7 +348,7 @@ function KeyboardShortcutsDialog() {
|
|||
>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[85vh] w-11/12 max-w-3xl flex-col overflow-hidden p-0"
|
||||
className="flex max-h-[85vh] w-11/12 max-w-3xl flex-col overflow-hidden p-0 lg:max-w-5xl"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-4 px-7 pt-6">
|
||||
<OGDialogTitle className="text-[16px] font-semibold text-text-primary">
|
||||
|
|
@ -335,8 +360,34 @@ function KeyboardShortcutsDialog() {
|
|||
</OGDialogClose>
|
||||
</header>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-4 border-b border-border-light px-7 pb-3">
|
||||
<div className="min-w-0">
|
||||
<Label
|
||||
htmlFor={enableSwitchId}
|
||||
className="cursor-pointer select-none text-[13px] font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_shortcut_keyboard_shortcuts')}
|
||||
</Label>
|
||||
<p className="mt-0.5 text-[11.5px] text-text-secondary">
|
||||
{localize('com_shortcut_enable_all_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={enableSwitchId}
|
||||
checked={enabled}
|
||||
onCheckedChange={(value) => {
|
||||
const next = value !== false;
|
||||
if (!next) {
|
||||
setEditingId(null);
|
||||
}
|
||||
setEnabled(next);
|
||||
}}
|
||||
aria-label={localize('com_shortcut_keyboard_shortcuts')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-x-10 px-5 pb-2 pt-5 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-x-10 px-5 pb-2 pt-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
{leftColumn.map(([groupKey, items]) => (
|
||||
<ShortcutGroup
|
||||
|
|
@ -344,6 +395,7 @@ function KeyboardShortcutsDialog() {
|
|||
groupKey={groupKey}
|
||||
bindings={items}
|
||||
editingId={editingId}
|
||||
disabled={!enabled}
|
||||
onStartEdit={handleStartEdit}
|
||||
onStopEdit={handleStopEdit}
|
||||
bindingMap={bindingMap}
|
||||
|
|
@ -360,6 +412,7 @@ function KeyboardShortcutsDialog() {
|
|||
groupKey={groupKey}
|
||||
bindings={items}
|
||||
editingId={editingId}
|
||||
disabled={!enabled}
|
||||
onStartEdit={handleStartEdit}
|
||||
onStopEdit={handleStopEdit}
|
||||
bindingMap={bindingMap}
|
||||
|
|
@ -369,19 +422,20 @@ function KeyboardShortcutsDialog() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
{panelEntries.length > 0 && (
|
||||
<PanelsSection
|
||||
bindings={panelEntries}
|
||||
editingId={editingId}
|
||||
disabled={!enabled}
|
||||
onStartEdit={handleStartEdit}
|
||||
onStopEdit={handleStopEdit}
|
||||
bindingMap={bindingMap}
|
||||
getActionLabel={getActionLabel}
|
||||
setBinding={setBinding}
|
||||
resetBinding={resetBinding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{panelEntries.length > 0 && (
|
||||
<PanelsSection
|
||||
bindings={panelEntries}
|
||||
editingId={editingId}
|
||||
onStartEdit={handleStartEdit}
|
||||
onStopEdit={handleStopEdit}
|
||||
bindingMap={bindingMap}
|
||||
getActionLabel={getActionLabel}
|
||||
setBinding={setBinding}
|
||||
resetBinding={resetBinding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAnyCustom && (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import DeleteAccount from '../SettingsTabs/Account/DeleteAccount';
|
|||
import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings';
|
||||
import ChatDirection from '../SettingsTabs/Chat/ChatDirection';
|
||||
import { DeleteCache } from '../SettingsTabs/Data/DeleteCache';
|
||||
import { ManageFiles } from '../SettingsTabs/Data/ManageFiles';
|
||||
import { smoothStreamingAtom } from '~/store/smoothStreaming';
|
||||
import { RevokeKeys } from '../SettingsTabs/Data/RevokeKeys';
|
||||
import { ClearChats } from '../SettingsTabs/Data/ClearChats';
|
||||
|
|
@ -523,6 +524,14 @@ export const registry: SettingEntry[] = [
|
|||
labelKey: 'com_ui_settings_label_shared_links',
|
||||
Component: SharedLinks,
|
||||
},
|
||||
{
|
||||
id: 'manageFiles',
|
||||
tab: DATA,
|
||||
section: 'data',
|
||||
labelKey: 'com_ui_settings_label_manage_files',
|
||||
keywords: ['file', 'files', 'upload', 'uploads', 'storage', 'attachments'],
|
||||
Component: ManageFiles,
|
||||
},
|
||||
// Data controls · API keys
|
||||
{
|
||||
id: 'providerApiKeys',
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import {
|
|||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
Spinner,
|
||||
InfoHoverCard,
|
||||
useOnClickOutside,
|
||||
} from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -38,7 +40,10 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
|
|||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
|
||||
<InfoHoverCard side={ESide.Top} text={localize('com_nav_delete_cache_storage_info')} />
|
||||
</div>
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
25
client/src/components/Nav/SettingsTabs/Data/ManageFiles.tsx
Normal file
25
client/src/components/Nav/SettingsTabs/Data/ManageFiles.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import { Label, Button } from '@librechat/client';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export const ManageFiles = () => {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label id="manage-files-label">{localize('com_sidepanel_manage_files')}</Label>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-labelledby="manage-files-label"
|
||||
>
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
{open && <MyFilesModal open={open} onOpenChange={setOpen} triggerRef={triggerRef} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -72,12 +72,12 @@ export default function GroupSidePanel({
|
|||
)}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Sticky header: filter and toggles stay put while the list scrolls */}
|
||||
<div className="shrink-0 space-y-2 px-3 pb-2 pt-2 text-text-primary">{children}</div>
|
||||
<div className="shrink-0 space-y-2 px-3 pb-2 text-text-primary">{children}</div>
|
||||
<PanelContent
|
||||
ref={containerRef}
|
||||
isLoading={!!groupsQuery.isLoading}
|
||||
skeleton={<PromptGroupSkeleton />}
|
||||
className="scrollbar-gutter-stable flex flex-col gap-2 overflow-x-hidden pl-3 pr-1 text-text-primary"
|
||||
className="scrollbar-gutter-stable flex flex-col gap-2 overflow-x-hidden pb-3 pl-3 pr-1 text-text-primary"
|
||||
>
|
||||
<List groups={promptGroups} isChatRoute={isChatRoute} />
|
||||
{/* Appending the next page, so the loaded rows stay put */}
|
||||
|
|
|
|||
|
|
@ -1,233 +1,53 @@
|
|||
import { useEffect, useId, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { ChevronDown, ShieldEllipsis } from 'lucide-react';
|
||||
import { Permissions, SystemRoles, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Label,
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogHeader,
|
||||
OGDialogFooter,
|
||||
OGDialogTitle,
|
||||
OGDialogDescription,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control } from 'react-hook-form';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
import { useUpdatePeoplePickerPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext, useRoleSelector } from '~/hooks';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.VIEW_USERS]: boolean;
|
||||
[Permissions.VIEW_GROUPS]: boolean;
|
||||
[Permissions.VIEW_ROLES]: boolean;
|
||||
};
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
peoplePickerPerm: Permissions.VIEW_USERS | Permissions.VIEW_GROUPS | Permissions.VIEW_ROLES;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({ control, peoplePickerPerm, label }) => (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||
<Label
|
||||
htmlFor={peoplePickerPerm}
|
||||
className="w-auto cursor-pointer select-none break-normal text-sm font-medium text-text-primary"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Controller
|
||||
name={peoplePickerPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={peoplePickerPerm}
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
value={(field.value ?? false).toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.VIEW_USERS, labelKey: 'com_ui_people_picker_allow_view_users' },
|
||||
{ permission: Permissions.VIEW_GROUPS, labelKey: 'com_ui_people_picker_allow_view_groups' },
|
||||
{ permission: Permissions.VIEW_ROLES, labelKey: 'com_ui_people_picker_allow_view_roles' },
|
||||
];
|
||||
|
||||
const PeoplePickerAdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user } = useAuthContext();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const roleLabelId = useId();
|
||||
const roleValueId = useId();
|
||||
const {
|
||||
selectedRole,
|
||||
isSelectedCustomRole,
|
||||
isCustomRoleLoading,
|
||||
isCustomRoleError,
|
||||
defaultValues,
|
||||
roleDropdownItems,
|
||||
} = useRoleSelector(PermissionTypes.PEOPLE_PICKER);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: defaultValues as FormValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError)) {
|
||||
return;
|
||||
}
|
||||
reset(defaultValues as FormValues);
|
||||
}, [isSelectedCustomRole, isCustomRoleLoading, isCustomRoleError, defaultValues, reset]);
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setIsRoleMenuOpen(false);
|
||||
reset(defaultValues as FormValues);
|
||||
}
|
||||
setIsDialogOpen(open);
|
||||
};
|
||||
|
||||
const { mutate, isLoading } = useUpdatePeoplePickerPermissionsMutation({
|
||||
const mutation = useUpdatePeoplePickerPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
handleDialogOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData: {
|
||||
peoplePickerPerm: Permissions.VIEW_USERS | Permissions.VIEW_GROUPS | Permissions.VIEW_ROLES;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
peoplePickerPerm: Permissions.VIEW_USERS,
|
||||
label: localize('com_ui_people_picker_allow_view_users'),
|
||||
},
|
||||
{
|
||||
peoplePickerPerm: Permissions.VIEW_GROUPS,
|
||||
label: localize('com_ui_people_picker_allow_view_groups'),
|
||||
},
|
||||
{
|
||||
peoplePickerPerm: Permissions.VIEW_ROLES,
|
||||
label: localize('com_ui_people_picker_allow_view_roles'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
const trigger = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2 rounded-lg font-medium"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="size-4" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2 rounded-lg font-medium"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="size-4" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-2xl gap-0 overflow-hidden p-0">
|
||||
<OGDialogHeader className="px-5 py-5 pr-14 text-left sm:px-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border-light bg-surface-secondary text-text-secondary">
|
||||
<ShieldEllipsis className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<OGDialogTitle className="text-xl leading-7">
|
||||
{localize('com_ui_admin_settings_section', {
|
||||
section: localize('com_ui_people_picker'),
|
||||
})}
|
||||
</OGDialogTitle>
|
||||
<OGDialogDescription className="sr-only">
|
||||
{localize('com_ui_people_picker_admin_description')}
|
||||
</OGDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4 p-4 sm:p-6">
|
||||
<div className="grid gap-3 rounded-xl border border-border-light bg-surface-secondary p-4 sm:grid-cols-[minmax(0,1fr)_9rem] sm:items-center">
|
||||
<span id={roleLabelId} className="text-sm font-semibold text-text-primary">
|
||||
{localize('com_ui_role_select')}
|
||||
</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="people-picker-role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-labelledby={`${roleLabelId} ${roleValueId}`}
|
||||
className="inline-flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border-medium bg-transparent px-3 text-sm text-text-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
>
|
||||
<span id={roleValueId} className="truncate font-medium">
|
||||
{selectedRole}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="size-4 shrink-0 text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border-light overflow-hidden rounded-xl border border-border-light bg-surface-secondary">
|
||||
{labelControllerData.map(({ peoplePickerPerm, label }) => (
|
||||
<LabelController
|
||||
key={peoplePickerPerm}
|
||||
control={control}
|
||||
peoplePickerPerm={peoplePickerPerm}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OGDialogFooter className="bg-transparent px-4 py-4 sm:px-6">
|
||||
<Button
|
||||
variant="submit"
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isLoading ||
|
||||
(isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError))
|
||||
}
|
||||
className="font-bold"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</OGDialogFooter>
|
||||
</form>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.PEOPLE_PICKER}
|
||||
sectionKey="com_ui_people_picker"
|
||||
descriptionKey="com_ui_people_picker_admin_description"
|
||||
permissions={permissions}
|
||||
menuId="people-picker-role-dropdown"
|
||||
mutation={mutation}
|
||||
trigger={trigger}
|
||||
showAdminWarning={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import {
|
|||
OGDialog,
|
||||
OGDialogTemplate,
|
||||
Button,
|
||||
FieldMessage,
|
||||
Label,
|
||||
Input,
|
||||
Spinner,
|
||||
Textarea,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import { useCreateMemoryMutation } from '~/data-provider';
|
||||
import { getMemoryKeyError, getMemoryValueError, getMemoryApiErrorMessage } from '~/utils/memory';
|
||||
import { useCreateMemoryMutation, useMemoriesQuery } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
|
||||
interface MemoryCreateDialogProps {
|
||||
|
|
@ -34,6 +36,8 @@ export default function MemoryCreateDialog({
|
|||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const { data: memData } = useMemoriesQuery();
|
||||
|
||||
const { mutate: createMemory, isLoading } = useCreateMemoryMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
|
|
@ -43,33 +47,14 @@ export default function MemoryCreateDialog({
|
|||
onOpenChange(false);
|
||||
setKey('');
|
||||
setValue('');
|
||||
setTouched({ key: false, value: false });
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as any;
|
||||
if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
|
||||
// Check for duplicate key error
|
||||
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
|
||||
errorMessage = localize('com_ui_memory_key_exists');
|
||||
}
|
||||
// Check for key validation error (lowercase and underscores only)
|
||||
else if (errorMessage.includes('lowercase letters and underscores')) {
|
||||
errorMessage = localize('com_ui_memory_key_validation');
|
||||
}
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
message: getMemoryApiErrorMessage(error, localize('com_ui_error')),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
|
|
@ -77,17 +62,32 @@ export default function MemoryCreateDialog({
|
|||
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [touched, setTouched] = useState({ key: false, value: false });
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (!open) {
|
||||
setKey('');
|
||||
setValue('');
|
||||
setTouched({ key: false, value: false });
|
||||
}
|
||||
}
|
||||
|
||||
const keyError = getMemoryKeyError({ key, memories: memData?.memories });
|
||||
const valueError = getMemoryValueError(value);
|
||||
const hasErrors = keyError != null || valueError != null;
|
||||
/** Stay quiet on a pristine empty field; validate live once there is something to judge. */
|
||||
const showKeyError = touched.key || key !== '';
|
||||
const showValueError = touched.value || value !== '';
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasCreateAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!key.trim() || !value.trim()) {
|
||||
showToast({
|
||||
message: localize('com_ui_field_required'),
|
||||
status: 'error',
|
||||
});
|
||||
if (keyError || valueError) {
|
||||
setTouched({ key: true, value: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -120,11 +120,19 @@ export default function MemoryCreateDialog({
|
|||
id="memory-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, key: true }))}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_key')}
|
||||
className="w-full"
|
||||
aria-invalid={showKeyError && keyError != null}
|
||||
aria-describedby="memory-key-message"
|
||||
/>
|
||||
<FieldMessage
|
||||
id="memory-key-message"
|
||||
message={showKeyError && keyError ? localize(keyError) : null}
|
||||
hint={localize('com_ui_memory_key_hint')}
|
||||
lines={2}
|
||||
/>
|
||||
<p className="text-xs text-text-secondary">{localize('com_ui_memory_key_hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium text-text-primary">
|
||||
|
|
@ -134,10 +142,17 @@ export default function MemoryCreateDialog({
|
|||
id="memory-value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, value: true }))}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_value')}
|
||||
className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy"
|
||||
rows={4}
|
||||
aria-invalid={showValueError && valueError != null}
|
||||
aria-describedby="memory-value-message"
|
||||
/>
|
||||
<FieldMessage
|
||||
id="memory-value-message"
|
||||
message={showValueError && valueError ? localize(valueError) : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,7 +162,7 @@ export default function MemoryCreateDialog({
|
|||
type="button"
|
||||
variant="submit"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !key.trim() || !value.trim()}
|
||||
disabled={isLoading || hasErrors}
|
||||
aria-label={localize('com_ui_create_memory')}
|
||||
>
|
||||
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_create')}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTemplate,
|
||||
Button,
|
||||
FieldMessage,
|
||||
Label,
|
||||
Input,
|
||||
Spinner,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import { getMemoryKeyError, getMemoryValueError, getMemoryApiErrorMessage } from '~/utils/memory';
|
||||
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import MemoryUsageBadge from './MemoryUsageBadge';
|
||||
|
|
@ -50,41 +52,19 @@ export default function MemoryEditDialog({
|
|||
});
|
||||
|
||||
const { mutate: updateMemory, isLoading } = useUpdateMemoryMutation({
|
||||
onMutate: () => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_saved'),
|
||||
status: 'success',
|
||||
});
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as any;
|
||||
if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
|
||||
// Check for duplicate key error
|
||||
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
|
||||
errorMessage = localize('com_ui_memory_key_exists');
|
||||
}
|
||||
// Check for key validation error (lowercase and underscores only)
|
||||
else if (errorMessage.includes('lowercase letters and underscores')) {
|
||||
errorMessage = localize('com_ui_memory_key_validation');
|
||||
}
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
message: getMemoryApiErrorMessage(error, localize('com_ui_error')),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
|
|
@ -93,25 +73,38 @@ export default function MemoryEditDialog({
|
|||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [originalKey, setOriginalKey] = useState('');
|
||||
const [touched, setTouched] = useState({ key: false, value: false });
|
||||
const [prevMemory, setPrevMemory] = useState<TUserMemory | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (memory !== prevMemory) {
|
||||
setPrevMemory(memory);
|
||||
if (memory) {
|
||||
setKey(memory.key);
|
||||
setValue(memory.value);
|
||||
setOriginalKey(memory.key);
|
||||
setTouched({ key: false, value: false });
|
||||
}
|
||||
}, [memory]);
|
||||
}
|
||||
|
||||
const keyError = getMemoryKeyError({
|
||||
key,
|
||||
memories: memData?.memories,
|
||||
agentId: memory?.agentId,
|
||||
originalKey,
|
||||
});
|
||||
const valueError = getMemoryValueError(value);
|
||||
const hasErrors = keyError != null || valueError != null;
|
||||
/** Stay quiet on a pristine empty field; validate live once there is something to judge. */
|
||||
const showKeyError = hasUpdateAccess && (touched.key || key !== '');
|
||||
const showValueError = hasUpdateAccess && (touched.value || value !== '');
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasUpdateAccess || !memory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!key.trim() || !value.trim()) {
|
||||
showToast({
|
||||
message: localize('com_ui_field_required'),
|
||||
status: 'error',
|
||||
});
|
||||
if (keyError || valueError) {
|
||||
setTouched({ key: true, value: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -189,10 +182,19 @@ export default function MemoryEditDialog({
|
|||
id="memory-key"
|
||||
value={key}
|
||||
onChange={(e) => hasUpdateAccess && setKey(e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, key: true }))}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_key')}
|
||||
className="w-full"
|
||||
disabled={!hasUpdateAccess}
|
||||
aria-invalid={showKeyError && keyError != null}
|
||||
aria-describedby="memory-key-message"
|
||||
/>
|
||||
<FieldMessage
|
||||
id="memory-key-message"
|
||||
message={showKeyError && keyError ? localize(keyError) : null}
|
||||
hint={localize('com_ui_memory_key_hint')}
|
||||
lines={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -205,11 +207,18 @@ export default function MemoryEditDialog({
|
|||
id="memory-value"
|
||||
value={value}
|
||||
onChange={(e) => hasUpdateAccess && setValue(e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, value: true }))}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_value')}
|
||||
className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={4}
|
||||
disabled={!hasUpdateAccess}
|
||||
aria-invalid={showValueError && valueError != null}
|
||||
aria-describedby="memory-value-message"
|
||||
/>
|
||||
<FieldMessage
|
||||
id="memory-value-message"
|
||||
message={showValueError && valueError ? localize(valueError) : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -221,7 +230,7 @@ export default function MemoryEditDialog({
|
|||
variant="submit"
|
||||
onClick={handleSave}
|
||||
aria-label={localize('com_ui_save')}
|
||||
disabled={isLoading || !key.trim() || !value.trim()}
|
||||
disabled={isLoading || hasErrors}
|
||||
>
|
||||
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,47 @@
|
|||
import { memo } from 'react';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { memo, useId } from 'react';
|
||||
import { Label, Switch, TooltipAnchor } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface SkillToggleProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
function SkillToggle({ enabled, onChange, ariaLabel }: SkillToggleProps) {
|
||||
/**
|
||||
* Controls whether the skill is injected into the agent's catalog for the
|
||||
* current user. The label stays fixed while the switch carries the state, so
|
||||
* flipping it cannot resize the surrounding action row.
|
||||
*/
|
||||
function SkillToggle({ enabled, onChange }: SkillToggleProps) {
|
||||
const localize = useLocalize();
|
||||
const switchId = useId();
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-1 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<Switch checked={enabled} onCheckedChange={() => onChange()} aria-label={ariaLabel} />
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_skill_available_hint')}
|
||||
side="top"
|
||||
render={
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md px-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={enabled}
|
||||
onCheckedChange={() => onChange()}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<Label
|
||||
id={labelId}
|
||||
htmlFor={switchId}
|
||||
className="cursor-pointer select-none whitespace-nowrap text-xs font-medium text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_skill_available')}
|
||||
</Label>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Button, TooltipAnchor } from '@librechat/client';
|
||||
import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react';
|
||||
import { Eye, Code, User, Pencil, Calendar, EarthIcon } from 'lucide-react';
|
||||
import type { TSkill } from 'librechat-data-provider';
|
||||
import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks';
|
||||
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
|
||||
|
|
@ -90,63 +90,59 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp
|
|||
aria-label={skill.name}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex flex-col gap-3 pb-1 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<ScrollText className="size-6 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-xl font-bold text-text-primary" title={skill.name}>
|
||||
{skill.name}
|
||||
</h2>
|
||||
{isPublic && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_skill_sr_public')}
|
||||
side="top"
|
||||
render={
|
||||
<EarthIcon
|
||||
className="size-5 shrink-0 text-accent-primary"
|
||||
aria-label={localize('com_ui_skill_sr_public')}
|
||||
/>
|
||||
}
|
||||
<div className="flex flex-col gap-3 pb-1 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-hidden sm:pt-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-xl font-bold text-text-primary" title={skill.name}>
|
||||
{skill.name}
|
||||
</h2>
|
||||
{isPublic && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_skill_sr_public')}
|
||||
side="top"
|
||||
render={
|
||||
<EarthIcon
|
||||
className="size-5 shrink-0 text-accent-primary"
|
||||
aria-label={localize('com_ui_skill_sr_public')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="size-3" aria-hidden="true" />
|
||||
{addedBy}
|
||||
</span>
|
||||
{updatedDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" aria-hidden="true" />
|
||||
{updatedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="size-3" aria-hidden="true" />
|
||||
{addedBy}
|
||||
</span>
|
||||
{updatedDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" aria-hidden="true" />
|
||||
{updatedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<SkillToggle
|
||||
enabled={skillEnabled}
|
||||
onChange={() => toggle(skill)}
|
||||
ariaLabel={localize('com_ui_skill_toggle_active')}
|
||||
/>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:pt-1">
|
||||
<SkillToggle enabled={skillEnabled} onChange={() => toggle(skill)} />
|
||||
<ShareSkill skill={skill} />
|
||||
{permissions.canEdit && onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-9 rounded-md border-border-medium px-3 text-xs font-semibold"
|
||||
>
|
||||
{localize('com_ui_edit')}
|
||||
</Button>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit')}
|
||||
side="bottom"
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 border-border-medium"
|
||||
aria-label={localize('com_ui_edit')}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{permissions.canDelete && onDelete && (
|
||||
<DeleteSkill skillId={skill._id} skillName={skill.name} onDelete={onDelete} />
|
||||
|
|
|
|||
|
|
@ -49,12 +49,12 @@ export default function SkillsSidePanel({ className }: SkillsSidePanelProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden border-r border-border-light',
|
||||
'flex h-full w-full flex-col overflow-hidden border-r border-border-light pt-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FilterSkills
|
||||
className="shrink-0 px-4 pb-2 pt-3"
|
||||
className="shrink-0 px-3 pb-2"
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
|
@ -64,7 +64,7 @@ export default function SkillsSidePanel({ className }: SkillsSidePanelProps) {
|
|||
ref={containerRef}
|
||||
isLoading={listQuery.isLoading}
|
||||
skeleton={<SkillListSkeleton />}
|
||||
className="px-4"
|
||||
className="px-3 pb-3"
|
||||
>
|
||||
<SkillListPanel
|
||||
skills={skills}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ jest.mock('~/store', () => {
|
|||
key: 'mock-customShortcuts',
|
||||
default: {},
|
||||
});
|
||||
const shortcutsEnabledAtom = atom({
|
||||
key: 'mock-shortcutsEnabled',
|
||||
default: true,
|
||||
});
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
|
@ -30,6 +34,7 @@ jest.mock('~/store', () => {
|
|||
atom({ key: `mock-conversationIdByIndex-${counter++}`, default: null }),
|
||||
newChatSwitchToHistory: switchAtom,
|
||||
customShortcuts: customShortcutsAtom,
|
||||
shortcutsEnabled: shortcutsEnabledAtom,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useId, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { ChevronDown, ShieldEllipsis } from 'lucide-react';
|
||||
import { Permissions, SystemRoles } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
Label,
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogFooter,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import type { PermissionTypes } from 'librechat-data-provider';
|
||||
import type { Control } from 'react-hook-form';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { useLocalize, useAuthContext, useRoleSelector } from '~/hooks';
|
||||
|
||||
|
|
@ -37,7 +41,11 @@ export interface AdminSettingsDialogProps {
|
|||
mutation: {
|
||||
mutate: (data: { roleName: string; updates: Record<Permissions, boolean> }) => void;
|
||||
isLoading: boolean;
|
||||
/** When it flips true the dialog closes itself */
|
||||
isSuccess?: boolean;
|
||||
};
|
||||
/** Localization key for the screen-reader description of the dialog */
|
||||
descriptionKey?: TranslationKeys;
|
||||
/** Whether to show the admin access warning when ADMIN role and USE permission is displayed (default: true) */
|
||||
showAdminWarning?: boolean;
|
||||
/** Custom trigger element. If not provided, uses default button with icon and text */
|
||||
|
|
@ -57,29 +65,35 @@ export interface AdminSettingsDialogProps {
|
|||
}
|
||||
|
||||
type LabelControllerProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
permission: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
onConfirm?: (newValue: boolean, onChange: (value: boolean) => void) => void;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
id,
|
||||
control,
|
||||
permission,
|
||||
label,
|
||||
onConfirm,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{label}
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="w-auto cursor-pointer select-none break-normal text-sm font-medium text-text-primary"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Controller
|
||||
name={permission}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
id={id}
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={(val) => {
|
||||
if (val === false && onConfirm) {
|
||||
onConfirm(val, field.onChange);
|
||||
|
|
@ -87,7 +101,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
|
|||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
value={field.value?.toString()}
|
||||
value={(field.value ?? false).toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -101,6 +115,7 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
|||
permissions,
|
||||
menuId,
|
||||
mutation,
|
||||
descriptionKey,
|
||||
showAdminWarning = true,
|
||||
trigger,
|
||||
dialogContentClassName,
|
||||
|
|
@ -110,9 +125,14 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { mutate, isLoading } = mutation;
|
||||
const { mutate, isLoading, isSuccess } = mutation;
|
||||
|
||||
const idPrefix = useId();
|
||||
const roleLabelId = useId();
|
||||
const roleValueId = useId();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [wasSuccessful, setWasSuccessful] = useState(isSuccess);
|
||||
const {
|
||||
selectedRole,
|
||||
isSelectedCustomRole,
|
||||
|
|
@ -125,8 +145,6 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
|||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
|
|
@ -141,10 +159,25 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
|||
reset(defaultValues);
|
||||
}, [isSelectedCustomRole, isCustomRoleLoading, isCustomRoleError, defaultValues, reset]);
|
||||
|
||||
if (isSuccess !== wasSuccessful) {
|
||||
setWasSuccessful(isSuccess);
|
||||
if (isSuccess === true && isDialogOpen) {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setIsRoleMenuOpen(false);
|
||||
reset(defaultValues);
|
||||
}
|
||||
setIsDialogOpen(open);
|
||||
};
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
|
@ -163,82 +196,104 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<OGDialog>
|
||||
<OGDialog open={isDialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<OGDialogTrigger asChild>{trigger ?? defaultTrigger}</OGDialogTrigger>
|
||||
<OGDialogContent
|
||||
className={
|
||||
dialogContentClassName ??
|
||||
'w-11/12 max-w-lg border-border-light bg-surface-dialog text-text-primary'
|
||||
}
|
||||
className={dialogContentClassName ?? 'w-11/12 max-w-2xl gap-0 overflow-hidden p-0'}
|
||||
>
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize(sectionKey) })}
|
||||
</OGDialogTitle>
|
||||
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId={menuId}
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex min-w-[6rem] items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{permissions.map(({ permission, labelKey }) => {
|
||||
const label = localize(labelKey);
|
||||
const needsConfirm =
|
||||
selectedRole === SystemRoles.ADMIN &&
|
||||
confirmPermissions.includes(permission) &&
|
||||
onPermissionConfirm;
|
||||
|
||||
return (
|
||||
<div key={permission}>
|
||||
<LabelController
|
||||
control={control}
|
||||
permission={permission}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
onConfirm={
|
||||
needsConfirm
|
||||
? (newValue, onChange) =>
|
||||
onPermissionConfirm(permission, newValue, onChange)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showAdminWarning &&
|
||||
selectedRole === SystemRoles.ADMIN &&
|
||||
permission === Permissions.USE && (
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-text-destructive">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-link underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<OGDialogHeader className="px-5 py-5 pr-14 text-left sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border-light bg-surface-secondary text-text-secondary">
|
||||
<ShieldEllipsis className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<OGDialogTitle className="text-xl leading-7">
|
||||
{localize('com_ui_admin_settings_section', { section: localize(sectionKey) })}
|
||||
</OGDialogTitle>
|
||||
{descriptionKey && (
|
||||
<OGDialogDescription className="sr-only">
|
||||
{localize(descriptionKey)}
|
||||
</OGDialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
</OGDialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4 p-4 sm:p-6">
|
||||
<div className="grid gap-3 rounded-xl border border-border-light bg-surface-secondary p-4 sm:grid-cols-[minmax(0,1fr)_9rem] sm:items-center">
|
||||
<span id={roleLabelId} className="text-sm font-semibold text-text-primary">
|
||||
{localize('com_ui_role_select')}
|
||||
</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId={menuId}
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-labelledby={`${roleLabelId} ${roleValueId}`}
|
||||
className="inline-flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border-medium bg-transparent px-3 text-sm text-text-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
>
|
||||
<span id={roleValueId} className="truncate font-medium">
|
||||
{selectedRole}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="size-4 shrink-0 text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border-light overflow-hidden rounded-xl border border-border-light bg-surface-secondary">
|
||||
{permissions.map(({ permission, labelKey }) => {
|
||||
const label = localize(labelKey);
|
||||
const needsConfirm =
|
||||
selectedRole === SystemRoles.ADMIN &&
|
||||
confirmPermissions.includes(permission) &&
|
||||
onPermissionConfirm;
|
||||
|
||||
return (
|
||||
<div key={permission}>
|
||||
<LabelController
|
||||
id={`${idPrefix}-${permission}`}
|
||||
control={control}
|
||||
permission={permission}
|
||||
label={label}
|
||||
onConfirm={
|
||||
needsConfirm
|
||||
? (newValue, onChange) =>
|
||||
onPermissionConfirm(permission, newValue, onChange)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showAdminWarning &&
|
||||
selectedRole === SystemRoles.ADMIN &&
|
||||
permission === Permissions.USE && (
|
||||
<div className="whitespace-normal break-words border-t border-border-light px-4 py-3 text-sm text-text-destructive">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>{' '}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-link underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OGDialogFooter className="bg-transparent px-4 py-4 sm:px-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="submit"
|
||||
|
|
@ -247,11 +302,12 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
|||
isLoading ||
|
||||
(isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError))
|
||||
}
|
||||
className="font-bold"
|
||||
aria-label={localize('com_ui_save')}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogFooter>
|
||||
</form>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { Button } from '@librechat/client';
|
||||
export { Button, FieldMessage } from '@librechat/client';
|
||||
export { default as Collapse } from './Collapse';
|
||||
export { default as PanelFooter } from './PanelFooter';
|
||||
export { default as PanelContent } from './PanelContent';
|
||||
|
|
|
|||
56
client/src/hooks/Input/useComposerBindings.spec.tsx
Normal file
56
client/src/hooks/Input/useComposerBindings.spec.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { MutableSnapshot } from 'recoil';
|
||||
import type { ReactNode } from 'react';
|
||||
import { resolveComposerKeyDown } from '~/utils/shortcuts';
|
||||
import useComposerBindings from './useComposerBindings';
|
||||
import store from '~/store';
|
||||
|
||||
function createWrapper(initializeState: (snapshot: MutableSnapshot) => void) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('useComposerBindings', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('disables default and custom submit chords while preserving Enter to send', () => {
|
||||
const { result } = renderHook(() => useComposerBindings(), {
|
||||
wrapper: createWrapper((snapshot) => {
|
||||
snapshot.set(store.shortcutsEnabled, false);
|
||||
snapshot.set(store.customShortcuts, {
|
||||
submitMessage: { mac: 'Alt+Enter', other: 'Alt+Enter' },
|
||||
focusChat: { mac: 'Meta+Shift+Enter', other: 'Control+Shift+Enter' },
|
||||
});
|
||||
}),
|
||||
});
|
||||
const context = {
|
||||
isComposing: false,
|
||||
isSubmitting: false,
|
||||
allowSubmitWhileGenerating: false,
|
||||
hasDuringRunModifier: false,
|
||||
enterToSend: true,
|
||||
...result.current,
|
||||
};
|
||||
|
||||
expect(result.current.shortcutsEnabled).toBe(false);
|
||||
expect(result.current.submitOverride).toBeNull();
|
||||
expect(result.current.yieldedChords).toEqual(new Set());
|
||||
expect(
|
||||
resolveComposerKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true }),
|
||||
context,
|
||||
),
|
||||
).toBe('newline');
|
||||
expect(
|
||||
resolveComposerKeyDown(new KeyboardEvent('keydown', { key: 'Enter', altKey: true }), context),
|
||||
).toBe('newline');
|
||||
expect(resolveComposerKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }), context)).toBe(
|
||||
'submit',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,8 @@ import { bindingHash } from '~/utils/shortcuts';
|
|||
import store from '~/store';
|
||||
|
||||
export type ComposerBindings = {
|
||||
/** Whether user-configurable keyboard shortcuts are globally enabled. */
|
||||
shortcutsEnabled: boolean;
|
||||
/**
|
||||
* Effective `submitMessage` override: `undefined` when unset (default Ctrl/Cmd+Enter applies),
|
||||
* `null` when explicitly unbound, otherwise the rebound chord.
|
||||
|
|
@ -30,20 +32,27 @@ export type ComposerBindings = {
|
|||
* composer keydown handler and the during-run hovercard hints. */
|
||||
export default function useComposerBindings(): ComposerBindings {
|
||||
const customShortcuts = useRecoilValue(store.customShortcuts);
|
||||
const shortcutsEnabled = useRecoilValue(store.shortcutsEnabled);
|
||||
const resolvedBindings = useMemo(
|
||||
() => resolveShortcutBindings(customShortcuts),
|
||||
[customShortcuts],
|
||||
);
|
||||
|
||||
const submitOverride = useMemo(() => {
|
||||
if (!shortcutsEnabled) {
|
||||
return null;
|
||||
}
|
||||
const override = customShortcuts['submitMessage'];
|
||||
if (!override) {
|
||||
return resolvedBindings.get('submitMessage') == null ? null : undefined;
|
||||
}
|
||||
return resolvedBindings.get('submitMessage') ?? null;
|
||||
}, [customShortcuts, resolvedBindings]);
|
||||
}, [customShortcuts, resolvedBindings, shortcutsEnabled]);
|
||||
|
||||
const yieldedChords = useMemo(() => {
|
||||
if (!shortcutsEnabled) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const editingAllowed: ReadonlySet<string> = EDITING_ALLOWED_SHORTCUTS;
|
||||
const hashes = new Set<string>();
|
||||
for (const actionId of editingAllowed) {
|
||||
|
|
@ -56,7 +65,10 @@ export default function useComposerBindings(): ComposerBindings {
|
|||
}
|
||||
}
|
||||
return hashes;
|
||||
}, [resolvedBindings]);
|
||||
}, [resolvedBindings, shortcutsEnabled]);
|
||||
|
||||
return useMemo(() => ({ submitOverride, yieldedChords }), [submitOverride, yieldedChords]);
|
||||
return useMemo(
|
||||
() => ({ shortcutsEnabled, submitOverride, yieldedChords }),
|
||||
[shortcutsEnabled, submitOverride, yieldedChords],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default function useTextarea({
|
|||
const assistantMap = useAssistantsMapContext();
|
||||
const checkHealth = useInteractionHealthCheck();
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const { submitOverride, yieldedChords } = useComposerBindings();
|
||||
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
|
||||
|
||||
const { index, conversation, isSubmitting, setFilesLoading } = useChatContext();
|
||||
const latestMessage = useLatestMessageMeta(index);
|
||||
|
|
@ -184,6 +184,7 @@ export default function useTextarea({
|
|||
isSubmitting,
|
||||
allowSubmitWhileGenerating,
|
||||
hasDuringRunModifier: onDuringRunModifier != null,
|
||||
shortcutsEnabled,
|
||||
enterToSend,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
|
|
@ -216,6 +217,7 @@ export default function useTextarea({
|
|||
isSubmitting,
|
||||
allowSubmitWhileGenerating,
|
||||
onDuringRunModifier,
|
||||
shortcutsEnabled,
|
||||
yieldedChords,
|
||||
checkHealth,
|
||||
enterToSend,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import copy from 'copy-to-clipboard';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { render, act, cleanup } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, act, cleanup, renderHook } from '@testing-library/react';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { MutableSnapshot } from 'recoil';
|
||||
import type { ReactNode } from 'react';
|
||||
import useKeyboardShortcuts, {
|
||||
isOverridden,
|
||||
effectiveBinding,
|
||||
useShortcutHint,
|
||||
getShortcutDisplay,
|
||||
getShortcutAriaKey,
|
||||
useShortcutDisplay,
|
||||
useShortcutAriaKey,
|
||||
} from './useKeyboardShortcuts';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -348,6 +351,59 @@ describe('global shortcut dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('the keyboard shortcuts switch', () => {
|
||||
it('ignores a matched chord and leaves the native event alone when off', () => {
|
||||
window.localStorage.setItem('keyboardShortcutsEnabled', JSON.stringify(false));
|
||||
const { getByTestId } = renderHarness();
|
||||
const before = getByTestId('sidebar').textContent;
|
||||
|
||||
const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true });
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(getByTestId('sidebar').textContent).toBe(before);
|
||||
});
|
||||
|
||||
it('still dispatches while on', () => {
|
||||
window.localStorage.setItem('keyboardShortcutsEnabled', JSON.stringify(true));
|
||||
const { getByTestId } = renderHarness();
|
||||
const before = getByTestId('sidebar').textContent;
|
||||
|
||||
const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true });
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(getByTestId('sidebar').textContent).not.toBe(before);
|
||||
});
|
||||
|
||||
it('defaults to on, so shortcuts work with nothing stored', () => {
|
||||
expect(window.localStorage.getItem('keyboardShortcutsEnabled')).toBeNull();
|
||||
const { getByTestId } = renderHarness();
|
||||
const before = getByTestId('sidebar').textContent;
|
||||
|
||||
const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true });
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(getByTestId('sidebar').textContent).not.toBe(before);
|
||||
});
|
||||
|
||||
it('stops advertising chords through the hint and aria hooks when off', () => {
|
||||
window.localStorage.setItem('keyboardShortcutsEnabled', JSON.stringify(false));
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
display: useShortcutDisplay('newChat'),
|
||||
ariaKey: useShortcutAriaKey('newChat'),
|
||||
hint: useShortcutHint('newChat', 'New chat'),
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => <RecoilRoot>{children}</RecoilRoot>,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.display).toBe('');
|
||||
expect(result.current.ariaKey).toBeUndefined();
|
||||
expect(result.current.hint).toBe('New chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipboard shortcuts', () => {
|
||||
it('copies the last response through the existing message copy button', () => {
|
||||
const firstCopy = jest.fn();
|
||||
|
|
|
|||
|
|
@ -897,20 +897,22 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
|
||||
export function useShortcutDisplay(actionId?: ShortcutActionId): string {
|
||||
const overrides = useRecoilValue(store.customShortcuts);
|
||||
const enabled = useRecoilValue(store.shortcutsEnabled);
|
||||
return useMemo(() => {
|
||||
if (!actionId) return '';
|
||||
if (!actionId || !enabled) return '';
|
||||
const binding = resolveShortcutBindings(overrides).get(actionId) ?? null;
|
||||
return binding ? bindingDisplayString(binding, isMac) : '';
|
||||
}, [actionId, overrides]);
|
||||
}, [actionId, overrides, enabled]);
|
||||
}
|
||||
|
||||
export function useShortcutAriaKey(actionId?: ShortcutActionId): string | undefined {
|
||||
const overrides = useRecoilValue(store.customShortcuts);
|
||||
const enabled = useRecoilValue(store.shortcutsEnabled);
|
||||
return useMemo(() => {
|
||||
if (!actionId) return undefined;
|
||||
if (!actionId || !enabled) return undefined;
|
||||
const binding = resolveShortcutBindings(overrides).get(actionId) ?? null;
|
||||
return binding ? (bindingToString(binding) ?? undefined) : undefined;
|
||||
}, [actionId, overrides]);
|
||||
}, [actionId, overrides, enabled]);
|
||||
}
|
||||
|
||||
export function useShortcutHint(actionId: ShortcutActionId | undefined, label: string): string {
|
||||
|
|
@ -1010,6 +1012,7 @@ export default function useKeyboardShortcuts() {
|
|||
const actions = useShortcutActions();
|
||||
const overrides = useRecoilValue(store.customShortcuts);
|
||||
const shortcutsDialogOpen = useRecoilValue(store.showShortcutsDialog);
|
||||
const shortcutsEnabled = useRecoilValue(store.shortcutsEnabled);
|
||||
|
||||
const actionMap = useMemo(() => new Map(actions.map((action) => [action.id, action])), [actions]);
|
||||
|
||||
|
|
@ -1028,6 +1031,10 @@ export default function useKeyboardShortcuts() {
|
|||
|
||||
const handler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!shortcutsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1079,7 +1086,7 @@ export default function useKeyboardShortcuts() {
|
|||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[actionMap, bindingMap, shortcutsDialogOpen],
|
||||
[actionMap, bindingMap, shortcutsDialogOpen, shortcutsEnabled],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@
|
|||
"com_nav_chat_direction": "Chat direction",
|
||||
"com_nav_chat_direction_selected": "Chat direction: {{direction}}",
|
||||
"com_nav_chat_title_in_tab": "Display chat title in tab",
|
||||
"com_nav_clear_all_chats": "Clear all chats",
|
||||
"com_nav_clear_all_chats": "Delete all chats",
|
||||
"com_nav_clear_cache_confirm_message": "Are you sure you want to clear the cache?",
|
||||
"com_nav_clear_conversation": "Clear conversations",
|
||||
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
|
||||
|
|
@ -494,6 +494,7 @@
|
|||
"com_nav_delete_account_confirm": "Delete account - are you sure?",
|
||||
"com_nav_delete_account_email_placeholder": "Please enter your account email",
|
||||
"com_nav_delete_cache_storage": "Delete TTS cache storage",
|
||||
"com_nav_delete_cache_storage_info": "Text-to-speech audio is stored in your browser so replaying a message does not request it from the provider again. Deleting the cache frees that space; the audio is generated again next time you play it. This button is unavailable when nothing is cached, which is the case if you have never used text-to-speech.",
|
||||
"com_nav_delete_data_info": "All your data will be deleted.",
|
||||
"com_nav_delete_warning": "WARNING: This will permanently delete your account.",
|
||||
"com_nav_during_run_action": "While generating, Enter will",
|
||||
|
|
@ -650,6 +651,7 @@
|
|||
"com_shortcut_copy_last_code": "Copy last code block",
|
||||
"com_shortcut_copy_last_response": "Copy last response",
|
||||
"com_shortcut_delete_conversation": "Delete conversation",
|
||||
"com_shortcut_enable_all_hint": "When off, no shortcut below responds. Your custom keys are kept, and the app stops showing shortcut hints until you turn this back on.",
|
||||
"com_shortcut_edit_aria": "Customize shortcut for {{0}}",
|
||||
"com_shortcut_edit_last_message": "Edit last message",
|
||||
"com_shortcut_focus_chat_input": "Focus chat input",
|
||||
|
|
@ -1788,7 +1790,7 @@
|
|||
"com_ui_retrieved_files": "Searched your files",
|
||||
"com_ui_retry": "Retry",
|
||||
"com_ui_revoke": "Revoke",
|
||||
"com_ui_revoke_info": "Revoke all user provided credentials",
|
||||
"com_ui_revoke_info": "Revoke all provider API keys",
|
||||
"com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?",
|
||||
"com_ui_revoke_key_endpoint": "Revoke Key for {{0}}",
|
||||
"com_ui_revoke_key_error": "Failed to revoke API key. Please try again.",
|
||||
|
|
@ -1867,7 +1869,7 @@
|
|||
"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_clear_chats": "Delete all chats",
|
||||
"com_ui_settings_label_conversation_mode": "Conversation mode",
|
||||
"com_ui_settings_label_credits": "Token balance",
|
||||
"com_ui_settings_label_decibel": "Decibel threshold",
|
||||
|
|
@ -1876,10 +1878,11 @@
|
|||
"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_manage_files": "Manage files",
|
||||
"com_ui_settings_label_language_stt": "Speech-to-text language",
|
||||
"com_ui_settings_label_playback_rate": "Playback rate",
|
||||
"com_ui_settings_label_provider_api_keys": "Provider API keys",
|
||||
"com_ui_settings_label_revoke_keys": "Revoke keys",
|
||||
"com_ui_settings_label_revoke_keys": "Revoke all provider API keys",
|
||||
"com_ui_settings_label_shared_links": "Shared chats",
|
||||
"com_ui_settings_label_voice": "Voice",
|
||||
"com_ui_settings_no_results": "No settings match your search",
|
||||
|
|
@ -1941,6 +1944,8 @@
|
|||
"com_ui_size": "Size",
|
||||
"com_ui_size_sort": "Sort by Size",
|
||||
"com_ui_skill": "Skill",
|
||||
"com_ui_skill_available": "Available to agent",
|
||||
"com_ui_skill_available_hint": "When on, the agent can use this skill in new messages",
|
||||
"com_ui_skill_content": "Skill Content",
|
||||
"com_ui_skill_content_placeholder": "Enter your skill instructions in markdown...",
|
||||
"com_ui_skill_create_error": "Failed to create skill",
|
||||
|
|
@ -1977,7 +1982,6 @@
|
|||
"com_ui_skill_running": "Running {{0}}",
|
||||
"com_ui_skill_sr_public": "Public skill",
|
||||
"com_ui_skill_states_limit": "You've reached the limit of {{0}} active/inactive skill overrides. Remove some overrides to toggle new skills.",
|
||||
"com_ui_skill_toggle_active": "Toggle skill active state",
|
||||
"com_ui_skill_unavailable": "Unavailable skill",
|
||||
"com_ui_skill_update_conflict": "Another edit was saved before yours. Reloading the latest version.",
|
||||
"com_ui_skill_update_error": "Failed to save skill",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ const customShortcuts = atomWithLocalStorage<Record<string, ShortcutOverride>>(
|
|||
{},
|
||||
);
|
||||
|
||||
/** When false, no keyboard shortcut fires and the UI stops advertising them. */
|
||||
const shortcutsEnabled = atomWithLocalStorage<boolean>('keyboardShortcutsEnabled', true);
|
||||
|
||||
const chatBadges = atomWithLocalStorage<Pick<BadgeItem, 'id'>[]>('chatBadges', [
|
||||
// When adding new badges, make sure to add them to useChatBadges.ts as well and add them as last item
|
||||
// DO NOT CHANGE THE ORDER OF THE BADGES ALREADY IN THE ARRAY
|
||||
|
|
@ -98,5 +101,6 @@ export default {
|
|||
showShortcutsDialog,
|
||||
keyboardDeleteTarget,
|
||||
customShortcuts,
|
||||
shortcutsEnabled,
|
||||
chatBadges,
|
||||
};
|
||||
|
|
|
|||
17
client/src/utils/memory.spec.ts
Normal file
17
client/src/utils/memory.spec.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getMemoryApiErrorMessage } from './memory';
|
||||
|
||||
describe('getMemoryApiErrorMessage', () => {
|
||||
it('preserves an actionable server error', () => {
|
||||
const error = Object.assign(new Error('Request failed'), {
|
||||
response: { data: { error: 'Value exceeds the configured character limit.' } },
|
||||
});
|
||||
|
||||
expect(getMemoryApiErrorMessage(error, 'Error')).toBe(
|
||||
'Value exceeds the configured character limit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when the server does not provide an error', () => {
|
||||
expect(getMemoryApiErrorMessage(new Error('Request failed'), 'Error')).toBe('Error');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,25 @@
|
|||
import type { MemoriesResponse, TUserMemory, MemoryArtifact } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
|
||||
type HandleMemoryArtifactParams = {
|
||||
memoryArtifact: MemoryArtifact;
|
||||
currentData: MemoriesResponse;
|
||||
};
|
||||
|
||||
type MemoryKeyErrorParams = {
|
||||
key: string;
|
||||
memories?: TUserMemory[];
|
||||
agentId?: string;
|
||||
originalKey?: string;
|
||||
};
|
||||
|
||||
type MemoryApiError = {
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
|
||||
/** Mirrors the `key` validator on the memory schema in `packages/data-schemas`. */
|
||||
export const MEMORY_KEY_PATTERN = /^[a-z_]+$/;
|
||||
|
||||
/** Usage totals track the shared personal pool only; agent-partition
|
||||
* writes never affect the personal usage badge. */
|
||||
const isPersonal = (agentId?: string) => agentId == null;
|
||||
|
|
@ -12,6 +27,54 @@ const isPersonal = (agentId?: string) => agentId == null;
|
|||
const samePartition = (memory: TUserMemory, agentId?: string) =>
|
||||
(memory.agentId ?? undefined) === (agentId ?? undefined);
|
||||
|
||||
/**
|
||||
* Validates a memory key the same way the server does, plus a partition-aware
|
||||
* duplicate check so the conflict surfaces before the request is sent.
|
||||
* @returns the localization key of the failure, or null when the key is valid
|
||||
*/
|
||||
export function getMemoryKeyError({
|
||||
key,
|
||||
memories,
|
||||
agentId,
|
||||
originalKey,
|
||||
}: MemoryKeyErrorParams): TranslationKeys | null {
|
||||
const trimmed = key.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return 'com_ui_field_required';
|
||||
}
|
||||
|
||||
if (!MEMORY_KEY_PATTERN.test(trimmed)) {
|
||||
return 'com_ui_memory_key_validation';
|
||||
}
|
||||
|
||||
if (trimmed === originalKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isTaken = memories?.some(
|
||||
(memory) => memory.key === trimmed && samePartition(memory, agentId),
|
||||
);
|
||||
|
||||
return isTaken === true ? 'com_ui_memory_key_exists' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the localization key of the failure, or null when the value is valid
|
||||
*/
|
||||
export function getMemoryValueError(value: string): TranslationKeys | null {
|
||||
return value.trim() ? null : 'com_ui_field_required';
|
||||
}
|
||||
|
||||
export function getMemoryApiErrorMessage(error: Error, fallback: string): string {
|
||||
if (!('response' in error)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const message = (error as Error & MemoryApiError).response?.data?.error;
|
||||
return typeof message === 'string' && message.trim() ? message : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to handle memory artifact updates
|
||||
* @param params - Object containing memoryArtifact and currentData
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ describe('resolveComposerKeyDown', () => {
|
|||
isSubmitting: false,
|
||||
allowSubmitWhileGenerating: false,
|
||||
hasDuringRunModifier: false,
|
||||
shortcutsEnabled: true,
|
||||
enterToSend: true,
|
||||
submitOverride: undefined,
|
||||
yieldedChords: new Set<string>(),
|
||||
|
|
@ -375,6 +376,27 @@ describe('resolveComposerKeyDown', () => {
|
|||
).toBe('submit');
|
||||
});
|
||||
|
||||
it('gates during-run shortcut chords while preserving plain Enter behavior', () => {
|
||||
const shortcutsDisabled = {
|
||||
...duringRun,
|
||||
shortcutsEnabled: false,
|
||||
submitOverride: null,
|
||||
};
|
||||
|
||||
expect(resolveComposerKeyDown(keydown({ altKey: true }), shortcutsDisabled)).toBe('newline');
|
||||
expect(
|
||||
resolveComposerKeyDown(keydown({ ctrlKey: true, shiftKey: true }), shortcutsDisabled),
|
||||
).toBe('none');
|
||||
expect(
|
||||
resolveComposerKeyDown(keydown({ metaKey: true, shiftKey: true }), shortcutsDisabled),
|
||||
).toBe('none');
|
||||
expect(resolveComposerKeyDown(keydown({ ctrlKey: true }), shortcutsDisabled)).toBe('newline');
|
||||
expect(resolveComposerKeyDown(keydown(), shortcutsDisabled)).toBe('submit');
|
||||
expect(resolveComposerKeyDown(keydown(), { ...shortcutsDisabled, enterToSend: false })).toBe(
|
||||
'newline',
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing while a run disallows submission', () => {
|
||||
expect(resolveComposerKeyDown(keydown(), { ...idle, isSubmitting: true })).toBe('none');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ export interface ComposerKeyContext {
|
|||
isSubmitting: boolean;
|
||||
allowSubmitWhileGenerating: boolean;
|
||||
hasDuringRunModifier: boolean;
|
||||
shortcutsEnabled: boolean;
|
||||
enterToSend: boolean;
|
||||
submitOverride: ShortcutBinding | null | undefined;
|
||||
/** `bindingHash`es of chords bound to global shortcuts that run while typing. */
|
||||
|
|
@ -276,7 +277,11 @@ export function resolveComposerKeyDown(
|
|||
if (binding != null && ctx.yieldedChords.has(bindingHash(binding))) {
|
||||
return 'none';
|
||||
}
|
||||
const duringRun = ctx.isSubmitting && ctx.allowSubmitWhileGenerating && ctx.hasDuringRunModifier;
|
||||
const duringRun =
|
||||
ctx.shortcutsEnabled &&
|
||||
ctx.isSubmitting &&
|
||||
ctx.allowSubmitWhileGenerating &&
|
||||
ctx.hasDuringRunModifier;
|
||||
if (duringRun && !ctx.isComposing) {
|
||||
if (e.altKey && !bindingsMatch(binding, ctx.submitOverride)) {
|
||||
return 'interrupt';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue