LibreChat/client/src/components/SidePanel/Agents/AgentAvatar.tsx
Marco Beretta 8907bd5d7c
👤 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>
2025-11-17 17:04:01 -05:00

102 lines
3.5 KiB
TypeScript

import { useEffect, useCallback } from 'react';
import { useToastContext } from '@librechat/client';
import { useFormContext, useWatch } from 'react-hook-form';
import { mergeFileConfig, fileConfig as defaultFileConfig } from 'librechat-data-provider';
import type { AgentAvatar } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
import { useGetFileConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
function Avatar({ avatar }: { avatar: AgentAvatar | null }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { control, setValue } = useFormContext<AgentForm>();
const avatarPreview = useWatch({ control, name: 'avatar_preview' }) ?? '';
const avatarAction = useWatch({ control, name: 'avatar_action' });
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
// Derive whether agent has a remote avatar from the avatar prop
const hasRemoteAvatar = Boolean(avatar?.filepath);
useEffect(() => {
if (avatarAction) {
return;
}
if (avatar?.filepath && avatarPreview !== avatar.filepath) {
setValue('avatar_preview', avatar.filepath);
}
if (!avatar?.filepath && avatarPreview !== '') {
setValue('avatar_preview', '');
}
}, [avatar?.filepath, avatarAction, avatarPreview, setValue]);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const sizeLimit = fileConfig.avatarSizeLimit ?? 0;
if (!file) {
return;
}
if (sizeLimit && file.size > sizeLimit) {
const limitInMb = sizeLimit / (1024 * 1024);
const displayLimit = Number.isInteger(limitInMb)
? limitInMb
: parseFloat(limitInMb.toFixed(1));
showToast({
message: localize('com_ui_upload_invalid_var', { 0: displayLimit }),
status: 'error',
});
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setValue('avatar_file', file, { shouldDirty: true });
setValue('avatar_preview', (reader.result as string) ?? '', { shouldDirty: true });
setValue('avatar_action', 'upload', { shouldDirty: true });
};
reader.readAsDataURL(file);
},
[fileConfig.avatarSizeLimit, localize, setValue, showToast],
);
const handleReset = useCallback(() => {
const remoteAvatarExists = Boolean(avatar?.filepath);
setValue('avatar_preview', '', { shouldDirty: true });
setValue('avatar_file', null, { shouldDirty: true });
setValue('avatar_action', remoteAvatarExists ? 'reset' : null, { shouldDirty: true });
}, [avatar?.filepath, setValue]);
const hasIcon = Boolean(avatarPreview) || hasRemoteAvatar;
const canReset = hasIcon;
return (
<>
<div className="flex w-full items-center justify-center gap-4">
<AvatarMenu
trigger={
<button
type="button"
className="f h-20 w-20 outline-none ring-offset-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_ui_upload_agent_avatar_label')}
>
{avatarPreview ? <AgentAvatarRender url={avatarPreview} /> : <NoImage />}
</button>
}
handleFileChange={handleFileChange}
onReset={handleReset}
canReset={canReset}
/>
</div>
</>
);
}
export default Avatar;