mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-06 18:48:50 +01:00
👤 feat: Agent Avatar Removal and Decouple upload/reset from Agent Updates (#10527)
* ✨ feat: Enhance agent avatar management with upload and reset functionality * ✨ feat: Refactor AvatarMenu to use DropdownPopup for improved UI and functionality * ✨ feat: Improve avatar upload handling in AgentPanel to suppress misleading "no changes" toast * ✨ feat: Refactor toast message handling and payload composition in AgentPanel for improved clarity and functionality * ✨ feat: Enhance agent avatar functionality with upload, reset, and validation improvements * ✨ feat: Refactor agent avatar upload handling and enhance related components for improved functionality and user experience * feat(agents): tighten ACL, harden GETs/search, and sanitize action metadata stop persisting refreshed S3 URLs on GET; compute per-response only enforce ACL EDIT on revert route; remove legacy admin/author/collab checks sanitize action metadata before persisting during duplication (api_key, oauth_client_id, oauth_client_secret) escape user search input, cap length (100), and use Set for public flag mapping add explicit req.file guard in avatar upload; fix empty catch lint; remove unused imports * feat: Remove outdated avatar-related translation keys * feat: Improve error logging for avatar updates and streamline file input handling * feat(agents): implement caching for S3 avatar refresh in agent list responses * fix: replace unconventional 'void e' with explicit comment to clarify intentionally ignored error Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(agents): enhance avatar handling and improve search functionality * fix: clarify intentionally ignored error in agent list handler --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c0cb48256e
commit
8907bd5d7c
17 changed files with 931 additions and 398 deletions
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { FormProvider, useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import AgentAvatar from '../AgentAvatar';
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
useToastContext: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetFileConfig: () => ({
|
||||
data: { avatarSizeLimit: 1024 * 1024 },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('../Images', () => ({
|
||||
AgentAvatarRender: () => <div data-testid="avatar-render" />,
|
||||
NoImage: () => <div data-testid="no-avatar" />,
|
||||
AvatarMenu: ({ onReset }: { onReset: () => void }) => (
|
||||
<button type="button" data-testid="reset-avatar" onClick={onReset}>
|
||||
Reset
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultFormValues: AgentForm = {
|
||||
agent: undefined,
|
||||
id: 'agent_123',
|
||||
name: 'Agent',
|
||||
description: null,
|
||||
instructions: null,
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
tools: [],
|
||||
provider: 'openai',
|
||||
agent_ids: [],
|
||||
edges: [],
|
||||
end_after_tools: false,
|
||||
hide_sequential_outputs: false,
|
||||
recursion_limit: undefined,
|
||||
category: 'general',
|
||||
support_contact: undefined,
|
||||
artifacts: '',
|
||||
execute_code: false,
|
||||
file_search: false,
|
||||
web_search: false,
|
||||
avatar_file: null,
|
||||
avatar_preview: '',
|
||||
avatar_action: null,
|
||||
};
|
||||
|
||||
describe('AgentAvatar reset menu', () => {
|
||||
it('clears preview and file state when reset is triggered', () => {
|
||||
let methodsRef: UseFormReturn<AgentForm>;
|
||||
const Wrapper = () => {
|
||||
methodsRef = useForm<AgentForm>({
|
||||
defaultValues: {
|
||||
...defaultFormValues,
|
||||
avatar_preview: 'data:image/png;base64,abc',
|
||||
avatar_file: new File(['avatar'], 'avatar.png', { type: 'image/png' }),
|
||||
avatar_action: 'upload',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methodsRef}>
|
||||
<AgentAvatar
|
||||
avatar={{
|
||||
filepath: 'https://example.com/current.png',
|
||||
source: 's3',
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<Wrapper />);
|
||||
fireEvent.click(getByTestId('reset-avatar'));
|
||||
|
||||
expect(methodsRef.getValues('avatar_preview')).toBe('');
|
||||
expect(methodsRef.getValues('avatar_file')).toBeNull();
|
||||
expect(methodsRef.getValues('avatar_action')).toBe('reset');
|
||||
});
|
||||
});
|
||||
|
|
@ -157,7 +157,7 @@ jest.mock('../DuplicateAgent', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/components', () => ({
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
|
|
@ -225,6 +225,7 @@ describe('AgentFooter', () => {
|
|||
updateMutation: mockUpdateMutation,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
setCurrentAgentId: mockSetCurrentAgentId,
|
||||
isAvatarUploading: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -275,14 +276,14 @@ describe('AgentFooter', () => {
|
|||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('duplicate-button')).toBeInTheDocument();
|
||||
expect(document.querySelector('.spinner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles loading states for createMutation', () => {
|
||||
const { unmount } = render(
|
||||
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
|
||||
);
|
||||
expect(document.querySelector('.spinner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
// Find the submit button (the one with aria-busy attribute)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
|
@ -294,9 +295,18 @@ describe('AgentFooter', () => {
|
|||
|
||||
test('handles loading states for updateMutation', () => {
|
||||
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
|
||||
expect(document.querySelector('.spinner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles loading state when avatar upload is in progress', () => {
|
||||
render(<AgentFooter {...defaultProps} isAvatarUploading={true} />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const submitButton = buttons.find((button) => button.getAttribute('type') === 'submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(submitButton).toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { Constants, type Agent } from 'librechat-data-provider';
|
||||
import type { FieldNamesMarkedBoolean } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
composeAgentUpdatePayload,
|
||||
persistAvatarChanges,
|
||||
isAvatarUploadOnlyDirty,
|
||||
} from '../AgentPanel';
|
||||
|
||||
const createForm = (): AgentForm => ({
|
||||
agent: undefined,
|
||||
id: 'agent_123',
|
||||
name: 'Agent',
|
||||
description: null,
|
||||
instructions: null,
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
tools: [],
|
||||
provider: 'openai',
|
||||
agent_ids: [],
|
||||
edges: [],
|
||||
end_after_tools: false,
|
||||
hide_sequential_outputs: false,
|
||||
recursion_limit: undefined,
|
||||
category: 'general',
|
||||
support_contact: undefined,
|
||||
artifacts: '',
|
||||
execute_code: false,
|
||||
file_search: false,
|
||||
web_search: false,
|
||||
avatar_file: null,
|
||||
avatar_preview: '',
|
||||
avatar_action: null,
|
||||
});
|
||||
|
||||
describe('composeAgentUpdatePayload', () => {
|
||||
it('includes avatar: null when resetting a persistent agent', () => {
|
||||
const form = createForm();
|
||||
form.avatar_action = 'reset';
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
|
||||
|
||||
expect(payload.avatar).toBeNull();
|
||||
});
|
||||
|
||||
it('omits avatar when resetting an ephemeral agent', () => {
|
||||
const form = createForm();
|
||||
form.avatar_action = 'reset';
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, Constants.EPHEMERAL_AGENT_ID);
|
||||
|
||||
expect(payload.avatar).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never adds avatar during upload actions', () => {
|
||||
const form = createForm();
|
||||
form.avatar_action = 'upload';
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
|
||||
|
||||
expect(payload.avatar).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistAvatarChanges', () => {
|
||||
it('returns false for ephemeral agents', async () => {
|
||||
const uploadAvatar = jest.fn();
|
||||
const result = await persistAvatarChanges({
|
||||
agentId: Constants.EPHEMERAL_AGENT_ID,
|
||||
avatarActionState: 'upload',
|
||||
avatarFile: new File(['avatar'], 'avatar.png', { type: 'image/png' }),
|
||||
uploadAvatar,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(uploadAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when no upload is pending', async () => {
|
||||
const uploadAvatar = jest.fn();
|
||||
const result = await persistAvatarChanges({
|
||||
agentId: 'agent_123',
|
||||
avatarActionState: null,
|
||||
avatarFile: null,
|
||||
uploadAvatar,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(uploadAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads avatar when all prerequisites are met', async () => {
|
||||
const uploadAvatar = jest.fn().mockResolvedValue({} as Agent);
|
||||
const file = new File(['avatar'], 'avatar.png', { type: 'image/png' });
|
||||
|
||||
const result = await persistAvatarChanges({
|
||||
agentId: 'agent_123',
|
||||
avatarActionState: 'upload',
|
||||
avatarFile: file,
|
||||
uploadAvatar,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(uploadAvatar).toHaveBeenCalledTimes(1);
|
||||
const callArgs = uploadAvatar.mock.calls[0][0];
|
||||
expect(callArgs.agent_id).toBe('agent_123');
|
||||
expect(callArgs.formData).toBeInstanceOf(FormData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvatarUploadOnlyDirty', () => {
|
||||
it('detects avatar-only dirty state', () => {
|
||||
const dirtyFields = {
|
||||
avatar_action: true,
|
||||
avatar_preview: true,
|
||||
} as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores agent field when checking dirty state', () => {
|
||||
const dirtyFields = {
|
||||
agent: { value: true } as any,
|
||||
avatar_file: true,
|
||||
} as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when other fields are dirty', () => {
|
||||
const dirtyFields = {
|
||||
name: true,
|
||||
} as FieldNamesMarkedBoolean<AgentForm>;
|
||||
|
||||
expect(isAvatarUploadOnlyDirty(dirtyFields)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue