🔥🚀 feat: CDN (Firebase) & feat: account section (#1438)

* localization + api-endpoint

* docs: added firebase documentation

* chore: icons

* chore: SettingsTabs

* feat: account pannel; fix: gear icons

* docs: position update

* feat: firebase

* feat: plugin support

* route

* fixed bugs with firebase and moved a lot of files

* chore(DALLE3): using UUID v4

* feat: support for social strategies; moved '/images' path

* fix: data ignored

* gitignore update

* docs: update firebase guide

* refactor: Firebase
- use singleton pattern for firebase initialization, initially on server start
- reorganize imports, move firebase specific files to own service under Files
- rename modules to remove 'avatar' redundancy
- fix imports based on changes

* ci(DALLE/DALLE3): fix tests to use logger and new expected outputs, add firebase tests

* refactor(loadToolWithAuth): pass userId to tool as field

* feat(images/parse): feat: Add URL Image Basename Extraction

Implement a new module to extract the basename of an image from a given URL. This addition includes the  function, which parses the URL and retrieves the basename using the Node.js 'url' and 'path' modules. The function is documented with JSDoc comments for better maintainability and understanding. This feature enhances the application's ability to handle and process image URLs efficiently.

* refactor(addImages): function to use a more specific regular expression for observedImagePath based on the generated image markdown standard across the app

* refactor(DALLE/DALLE3): utilize `getImageBasename` and `this.userId`; fix: pass correct image path to firebase url helper

* fix(addImages): make more general to match any image markdown descriptor

* fix(parse/getImageBasename): test result of this function for an actual image basename

* ci(DALLE3): mock getImageBasename

* refactor(AuthContext): use Recoil atom state for user

* feat: useUploadAvatarMutation, react-query hook for avatar upload

* fix(Toast): stack z-order of Toast over all components (1000)

* refactor(showToast): add optional status field to avoid importing NotificationSeverity on each use of the function

* refactor(routes/avatar): remove unnecessary get route, get userId from req.user.id, require auth on POST request

* chore(uploadAvatar): TODO: remove direct use of Model, `User`

* fix(client): fix Spinner imports

* refactor(Avatar): use react-query hook, Toast, remove unnecessary states, add optimistic UI to upload

* fix(avatar/localStrategy): correctly save local profile picture and cache bust for immediate rendering; fix: firebase init info message (only show once)

* fix: use `includes` instead of `endsWith` for checking manual query of avatar image path in case more queries are appended (as is done in avatar/localStrategy)

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
Marco Beretta 2023-12-30 03:42:19 +01:00 committed by GitHub
parent bd4d23d314
commit f19f5dca8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1855 additions and 172 deletions

View file

@ -34,7 +34,7 @@ const App = () => {
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
<Toast />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[60] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
</DndProvider>
</AssistantsProvider>
</ToastProvider>

View file

@ -27,6 +27,7 @@ export type TShowToast = {
severity?: NotificationSeverity;
showIcon?: boolean;
duration?: number;
status?: 'error' | 'success' | 'warning' | 'info';
};
export type TBaseSettingsProps = {

View file

@ -6,10 +6,10 @@ import { useChatHelpers, useSSE } from '~/hooks';
// import GenerationButtons from './Input/GenerationButtons';
import MessagesView from './Messages/MessagesView';
// import OptionsBar from './Input/OptionsBar';
import { Spinner } from '~/components/svg';
import { ChatContext } from '~/Providers';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
import { Spinner } from '~/components';
import { buildTree } from '~/utils';
import Landing from './Landing';
import Header from './Header';

View file

@ -11,7 +11,7 @@ import {
} from '~/hooks';
import { TooltipProvider, Tooltip } from '~/components/ui';
import { Conversations, Pages } from '../Conversations';
import { Spinner } from '~/components';
import { Spinner } from '~/components/svg';
import SearchBar from './SearchBar';
import NavToggle from './NavToggle';
import NavLinks from './NavLinks';

View file

@ -119,7 +119,7 @@ function NavLinks() {
<Menu.Item as="div">
<NavLink
className="flex w-full cursor-pointer items-center gap-3 rounded-none px-3 py-3 text-sm text-white transition-colors duration-200 hover:bg-gray-700"
svg={() => <GearIcon />}
svg={() => <GearIcon className="icon-md" />}
text={localize('com_nav_settings')}
clickHandler={() => setShowSettings(true)}
/>

View file

@ -1,9 +1,9 @@
import * as Tabs from '@radix-ui/react-tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import { GearIcon, DataIcon } from '~/components/svg';
import { GearIcon, DataIcon, UserIcon } from '~/components/svg';
import { useMediaQuery, useLocalize } from '~/hooks';
import type { TDialogProps } from '~/common';
import { General, Data } from './SettingsTabs';
import { General, Data, Account } from './SettingsTabs';
import { cn } from '~/utils';
export default function Settings({ open, onOpenChange }: TDialogProps) {
@ -39,7 +39,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
>
<Tabs.Trigger
className={cn(
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-gray-500 radix-state-active:bg-gray-800 radix-state-active:text-white',
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
isSmallScreen
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: '',
@ -51,7 +51,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-gray-500 radix-state-active:bg-gray-800 radix-state-active:text-white',
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
isSmallScreen
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: '',
@ -61,9 +61,22 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<DataIcon />
{localize('com_nav_setting_data')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
isSmallScreen
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: '',
)}
value="account"
>
<UserIcon />
{localize('com_nav_setting_account')}
</Tabs.Trigger>
</Tabs.List>
<General />
<Data />
<Account />
</Tabs.Root>
</div>
</DialogContent>

View file

@ -0,0 +1,18 @@
import * as Tabs from '@radix-ui/react-tabs';
import Avatar from './Avatar';
import React from 'react';
function Account() {
return (
<Tabs.Content value="account" role="tabpanel" className="w-full md:min-h-[300px]">
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<Avatar />
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"></div>
</Tabs.Content>
);
}
export default React.memo(Account);

View file

@ -0,0 +1,145 @@
import { FileImage } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
import type { TUser } from 'librechat-data-provider';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import { useUploadAvatarMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import store from '~/store';
const sizeLimit = 2 * 1024 * 1024; // 2MB
function Avatar() {
const setUser = useSetRecoilState(store.user);
const [input, setinput] = useState<File | null>(null);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const localize = useLocalize();
const { showToast } = useToastContext();
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') });
setDialogOpen(false);
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
},
onError: (error) => {
console.error('Error:', error);
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
},
});
useEffect(() => {
if (input) {
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(input);
} else {
setPreviewUrl(null);
}
}, [input]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
if (file && file.size <= sizeLimit) {
setinput(file);
setDialogOpen(true);
} else {
showToast({
message: localize('com_ui_upload_invalid'),
status: 'error',
});
}
};
const handleUpload = () => {
if (!input) {
console.error('No file selected');
return;
}
const formData = new FormData();
formData.append('input', input, input.name);
formData.append('manual', 'true');
uploadAvatar(formData);
};
return (
<>
<div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span>
<label
htmlFor={'file-upload-avatar'}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-slate-200 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-800 dark:hover:text-green-500"
>
<FileImage className="mr-1 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_change_picture')}</span>
<input
id={'file-upload-avatar'}
value=""
type="file"
className={cn('hidden')}
accept=".png, .jpg"
onChange={handleFileChange}
/>
</label>
</div>
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>
<DialogContent
className={cn('shadow-2xl dark:bg-gray-900 dark:text-white md:h-[350px] md:w-[450px] ')}
style={{ borderRadius: '12px' }}
>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
{localize('com_ui_preview')}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center">
{previewUrl && (
<img
src={previewUrl}
alt="Preview"
className="mb-2 rounded-full"
style={{
maxWidth: '100%',
maxHeight: '150px',
width: '150px',
height: '150px',
objectFit: 'cover',
}}
/>
)}
<button
className={cn(
'mt-4 rounded px-4 py-2 text-white hover:bg-green-600 hover:text-gray-200',
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
)}
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? (
<div className="flex h-6">
<Spinner className="icon-sm m-auto" />
</div>
) : (
localize('com_ui_upload')
)}
</button>
</div>
</DialogContent>
</Dialog>
</>
);
}
export default Avatar;

View file

@ -5,7 +5,7 @@ import {
} from 'librechat-data-provider/react-query';
import React, { useState, useCallback, useRef } from 'react';
import { useOnClickOutside } from '~/hooks';
import DangerButton from './DangerButton';
import DangerButton from '../DangerButton';
export const RevokeKeysButton = ({
showText = true,

View file

@ -12,7 +12,7 @@ import {
} from '~/hooks';
import type { TDangerButtonProps } from '~/common';
import AutoScrollSwitch from './AutoScrollSwitch';
import DangerButton from './DangerButton';
import DangerButton from '../DangerButton';
import store from '~/store';
import { Dropdown } from '~/components/ui';

View file

@ -1,4 +1,5 @@
export { default as General } from './General';
export { ClearChatsButton } from './General';
export { default as Data } from './Data';
export { RevokeKeysButton } from './Data';
export { default as General } from './General/General';
export { ClearChatsButton } from './General/General';
export { default as Data } from './Data/Data';
export { RevokeKeysButton } from './Data/Data';
export { default as Account } from './Account/Account';

View file

@ -1,14 +1,18 @@
import React from 'react';
export default function GearIcon() {
interface GearIconProps {
className?: string;
}
const GearIcon: React.FC<GearIconProps> = ({ className = '' }) => {
return (
<svg
width="18"
height="18"
className={className}
width="17"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md"
>
<path
d="M11.6439 3C10.9352 3 10.2794 3.37508 9.92002 3.98596L9.49644 4.70605C8.96184 5.61487 7.98938 6.17632 6.93501 6.18489L6.09967 6.19168C5.39096 6.19744 4.73823 6.57783 4.38386 7.19161L4.02776 7.80841C3.67339 8.42219 3.67032 9.17767 4.01969 9.7943L4.43151 10.5212C4.95127 11.4386 4.95127 12.5615 4.43151 13.4788L4.01969 14.2057C3.67032 14.8224 3.67339 15.5778 4.02776 16.1916L4.38386 16.8084C4.73823 17.4222 5.39096 17.8026 6.09966 17.8083L6.93502 17.8151C7.98939 17.8237 8.96185 18.3851 9.49645 19.294L9.92002 20.014C10.2794 20.6249 10.9352 21 11.6439 21H12.3561C13.0648 21 13.7206 20.6249 14.08 20.014L14.5035 19.294C15.0381 18.3851 16.0106 17.8237 17.065 17.8151L17.9004 17.8083C18.6091 17.8026 19.2618 17.4222 19.6162 16.8084L19.9723 16.1916C20.3267 15.5778 20.3298 14.8224 19.9804 14.2057L19.5686 13.4788C19.0488 12.5615 19.0488 11.4386 19.5686 10.5212L19.9804 9.7943C20.3298 9.17767 20.3267 8.42219 19.9723 7.80841L19.6162 7.19161C19.2618 6.57783 18.6091 6.19744 17.9004 6.19168L17.065 6.18489C16.0106 6.17632 15.0382 5.61487 14.5036 4.70605L14.08 3.98596C13.7206 3.37508 13.0648 3 12.3561 3H11.6439Z"
@ -19,4 +23,6 @@ export default function GearIcon() {
<circle cx="12" cy="12" r="2.5" stroke="currentColor" strokeWidth="2"></circle>
</svg>
);
}
};
export default GearIcon;

View file

@ -1,20 +1,17 @@
import React from 'react';
export default function UserIcon() {
return (
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);

View file

@ -40,3 +40,4 @@ export { default as GeminiIcon } from './GeminiIcon';
export { default as GoogleMinimalIcon } from './GoogleMinimalIcon';
export { default as AnthropicMinimalIcon } from './AnthropicMinimalIcon';
export { default as SendMessageIcon } from './SendMessageIcon';
export { default as UserIcon } from './UserIcon';

View file

@ -12,6 +12,8 @@ import type {
PresetDeleteResponse,
LogoutOptions,
TPreset,
UploadAvatarOptions,
AvatarUploadResponse,
} from 'librechat-data-provider';
import { dataService, MutationKeys } from 'librechat-data-provider';
@ -99,3 +101,18 @@ export const useLogoutUserMutation = (
},
});
};
/* Avatar upload */
export const useUploadAvatarMutation = (
options?: UploadAvatarOptions,
): UseMutationResult<
AvatarUploadResponse, // response data
unknown, // error
FormData, // request
unknown // context
> => {
return useMutation([MutationKeys.avatarUpload], {
mutationFn: (variables: FormData) => dataService.uploadAvatar(variables),
...(options || {}),
});
};

View file

@ -7,6 +7,7 @@ import {
createContext,
useContext,
} from 'react';
import { useRecoilState } from 'recoil';
import { TUser, TLoginResponse, setTokenHeader, TLoginUser } from 'librechat-data-provider';
import {
useGetUserQuery,
@ -17,6 +18,7 @@ import { useNavigate } from 'react-router-dom';
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
import { useLogoutUserMutation } from '~/data-provider';
import useTimeout from './useTimeout';
import store from '~/store';
const AuthContext = createContext<TAuthContext | undefined>(undefined);
@ -27,11 +29,13 @@ const AuthContextProvider = ({
authConfig?: TAuthConfig;
children: ReactNode;
}) => {
const navigate = useNavigate();
const [user, setUser] = useState<TUser | undefined>(undefined);
const [user, setUser] = useRecoilState(store.user);
const [token, setToken] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const navigate = useNavigate();
const setUserContext = useCallback(
(userContext: TUserContext) => {
const { token, isAuthenticated, user, redirect } = userContext;
@ -46,7 +50,7 @@ const AuthContextProvider = ({
navigate(redirect, { replace: true });
}
},
[navigate],
[navigate, setUser],
);
const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) });

View file

@ -25,6 +25,7 @@ export default function useToast(showDelay = 100) {
severity = NotificationSeverity.SUCCESS,
showIcon = true,
duration = 3000, // default duration for the toast to be visible
status,
}: TShowToast) => {
// Clear existing timeouts
if (showTimerRef.current !== null) {
@ -36,7 +37,12 @@ export default function useToast(showDelay = 100) {
// Timeout to show the toast
showTimerRef.current = window.setTimeout(() => {
setToast({ open: true, message, severity, showIcon });
setToast({
open: true,
message,
severity: (status as NotificationSeverity) ?? severity,
showIcon,
});
// Hides the toast after the specified duration
hideTimerRef.current = window.setTimeout(() => {
setToast((prevToast) => ({ ...prevToast, open: false }));

View file

@ -33,7 +33,8 @@ export default {
com_ui_enter: 'Enter',
com_ui_submit: 'Submit',
com_ui_upload_success: 'Successfully uploaded file',
com_ui_upload_invalid: 'Invalid file for upload',
com_ui_upload_error: 'There was an error uploading your file',
com_ui_upload_invalid: 'Invalid file for upload. Must be an image not exceeding 2 MB',
com_ui_cancel: 'Cancel',
com_ui_save: 'Save',
com_ui_copy_to_clipboard: 'Copy to clipboard',
@ -51,6 +52,9 @@ export default {
com_ui_delete: 'Delete',
com_ui_delete_conversation: 'Delete chat?',
com_ui_delete_conversation_confirm: 'This will delete',
com_ui_preview: 'Preview',
com_ui_upload: 'Upload',
com_ui_connect: 'Connect',
com_auth_error_login:
'Unable to login with the information provided. Please check your credentials and try again.',
com_auth_error_login_rl:
@ -253,6 +257,8 @@ export default {
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
com_nav_welcome_message: 'How can I help you today?',
com_nav_auto_scroll: 'Auto-scroll to Newest on Open',
com_nav_profile_picture: 'Profile Picture',
com_nav_change_picture: 'Change picture',
com_nav_plugin_store: 'Plugin store',
com_nav_plugin_search: 'Search plugins',
com_nav_plugin_auth_error:
@ -286,6 +292,7 @@ export default {
com_nav_search_placeholder: 'Search messages',
com_nav_setting_general: 'General',
com_nav_setting_data: 'Data controls',
com_nav_setting_account: 'Account',
com_nav_language: 'Language',
com_nav_lang_auto: 'Auto detect',
com_nav_lang_english: 'English',

View file

@ -53,6 +53,9 @@ export default {
com_ui_delete: 'Elimina',
com_ui_delete_conversation: 'Eliminare la chat?',
com_ui_delete_conversation_confirm: 'Questo eliminerà',
com_ui_preview: 'Anteprima',
com_ui_upload: 'Carica',
com_ui_connect: 'Connetti',
com_auth_error_login:
'Impossibile accedere con le informazioni fornite. Per favore controlla le tue credenziali e riprova.',
com_auth_error_login_rl:
@ -263,7 +266,9 @@ export default {
'Assicurati di fare clic su "Crea e continua" per dare almeno il ruolo "Vertex AI User". Infine, crea una chiave JSON da importare qui.',
com_nav_welcome_message: 'Come posso aiutarti oggi?',
com_nav_auto_scroll: 'Scorri automaticamente al Più recente all\'apertura',
com_nav_plugin_store: 'Negozio plugin',
com_nav_profile_picture: 'Immagine del profilo',
com_nav_change_picture: 'Cambia immagine',
com_nav_plugin_store: 'Negozio dei plugin',
com_nav_plugin_search: 'Cerca plugin',
com_nav_plugin_auth_error:
'Si è verificato un errore durante il tentativo di autenticare questo plugin. Per favore riprova.',

View file

@ -1,9 +1,9 @@
import { atom } from 'recoil';
import { TPlugin } from 'librechat-data-provider';
import type { TUser, TPlugin } from 'librechat-data-provider';
const user = atom({
const user = atom<TUser | undefined>({
key: 'user',
default: null,
default: undefined,
});
const availableTools = atom<TPlugin[]>({