diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 0c854c640c..0900bffb8a 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -1,6 +1,6 @@ from flask_restful import fields # type: ignore -from libs.helper import TimestampField +from libs.helper import AvatarUrlField, TimestampField simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String} @@ -8,6 +8,7 @@ account_fields = { "id": fields.String, "name": fields.String, "avatar": fields.String, + "avatar_url": AvatarUrlField, "email": fields.String, "is_password_set": fields.Boolean, "interface_language": fields.String, @@ -22,6 +23,7 @@ account_with_role_fields = { "id": fields.String, "name": fields.String, "avatar": fields.String, + "avatar_url": AvatarUrlField, "email": fields.String, "last_login_at": TimestampField, "last_active_at": TimestampField, diff --git a/api/libs/helper.py b/api/libs/helper.py index eaa4efdb71..4f14f010f4 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -41,6 +41,18 @@ class AppIconUrlField(fields.Raw): return None +class AvatarUrlField(fields.Raw): + def output(self, key, obj): + if obj is None: + return None + + from models.account import Account + + if isinstance(obj, Account) and obj.avatar is not None: + return file_helpers.get_signed_file_url(obj.avatar) + return None + + class TimestampField(fields.Raw): def format(self, value) -> int: return int(value.timestamp()) diff --git a/web/app/account/account-page/AvatarWithEdit.tsx b/web/app/account/account-page/AvatarWithEdit.tsx new file mode 100644 index 0000000000..97f6ba8da6 --- /dev/null +++ b/web/app/account/account-page/AvatarWithEdit.tsx @@ -0,0 +1,122 @@ +'use client' + +import type { Area } from 'react-easy-crop' +import React, { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { RiPencilLine } from '@remixicon/react' +import { updateUserProfile } from '@/service/common' +import { ToastContext } from '@/app/components/base/toast' +import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput' +import Modal from '@/app/components/base/modal' +import Divider from '@/app/components/base/divider' +import Button from '@/app/components/base/button' +import Avatar, { type AvatarProps } from '@/app/components/base/avatar' +import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' +import type { ImageFile } from '@/types/app' +import getCroppedImg from '@/app/components/base/app-icon-picker/utils' +import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' + +type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string } +type AvatarWithEditProps = AvatarProps & { onSave?: () => void } + +const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + + const [inputImageInfo, setInputImageInfo] = useState() + const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false) + const [uploading, setUploading] = useState(false) + + const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => { + setInputImageInfo( + isCropped + ? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! } + : { file: fileOrTempUrl as File }, + ) + }, [setInputImageInfo]) + + const handleSaveAvatar = useCallback(async (uploadedFileId: string) => { + try { + await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } }) + notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) + setIsShowAvatarPicker(false) + onSave?.() + } + catch (e) { + notify({ type: 'error', message: (e as Error).message }) + } + }, [notify, onSave, t]) + + const { handleLocalFileUpload } = useLocalFileUploader({ + limit: 3, + disabled: false, + onUpload: (imageFile: ImageFile) => { + if (imageFile.progress === 100) { + setUploading(false) + setInputImageInfo(undefined) + handleSaveAvatar(imageFile.fileId) + } + + // Error + if (imageFile.progress === -1) + setUploading(false) + }, + }) + + const handleSelect = useCallback(async () => { + if (!inputImageInfo) + return + setUploading(true) + if ('file' in inputImageInfo) { + handleLocalFileUpload(inputImageInfo.file) + return + } + const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName) + const file = new File([blob], inputImageInfo.fileName, { type: blob.type }) + handleLocalFileUpload(file) + }, [handleLocalFileUpload, inputImageInfo]) + + if (DISABLE_UPLOAD_IMAGE_AS_ICON) + return + + return ( + <> +
+
+ +
{ setIsShowAvatarPicker(true) }} + className="absolute inset-0 bg-black bg-opacity-50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer flex items-center justify-center" + > + + + +
+
+
+ + setIsShowAvatarPicker(false)} + > + + + +
+ + + +
+
+ + ) +} + +export default AvatarWithEdit diff --git a/web/app/account/account-page/index.tsx b/web/app/account/account-page/index.tsx index 4435019561..16d826a7c2 100644 --- a/web/app/account/account-page/index.tsx +++ b/web/app/account/account-page/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import DeleteAccount from '../delete-account' import s from './index.module.css' +import AvatarWithEdit from './AvatarWithEdit' import Collapse from '@/app/components/header/account-setting/collapse' import type { IItem } from '@/app/components/header/account-setting/collapse' import Modal from '@/app/components/base/modal' @@ -13,7 +14,6 @@ import { updateUserProfile } from '@/service/common' import { useAppContext } from '@/context/app-context' import { ToastContext } from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' -import Avatar from '@/app/components/base/avatar' import { IS_CE_EDITION } from '@/config' import Input from '@/app/components/base/input' @@ -133,7 +133,7 @@ export default function AccountPage() {

{t('common.account.myAccount')}

- +

{userProfile.name}

{userProfile.email}

diff --git a/web/app/account/avatar.tsx b/web/app/account/avatar.tsx index 8fdecc07bf..47e8e75747 100644 --- a/web/app/account/avatar.tsx +++ b/web/app/account/avatar.tsx @@ -45,7 +45,7 @@ export default function AppSelector() { ${open && 'bg-components-panel-bg-blur'} `} > - +
{userProfile.name}
{userProfile.email}
- + diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx index 1144c323d1..119db34b16 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx @@ -149,7 +149,7 @@ const ChatItem: FC = ({ suggestedQuestions={suggestedQuestions} onSend={doSend} showPromptLog - questionIcon={} + questionIcon={} allToolIcons={allToolIcons} hideLogModal noSpacing diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index 2cbfe91f16..48e1e55de4 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -175,7 +175,7 @@ const DebugWithSingleModel = forwardRef} + questionIcon={} allToolIcons={allToolIcons} onAnnotationEdited={handleAnnotationEdited} onAnnotationAdded={handleAnnotationAdded} diff --git a/web/app/components/base/app-icon-picker/ImageInput.tsx b/web/app/components/base/app-icon-picker/ImageInput.tsx index f26f5a1fcb..0111b3c68d 100644 --- a/web/app/components/base/app-icon-picker/ImageInput.tsx +++ b/web/app/components/base/app-icon-picker/ImageInput.tsx @@ -2,8 +2,7 @@ import type { ChangeEvent, FC } from 'react' import { createRef, useEffect, useState } from 'react' -import type { Area } from 'react-easy-crop' -import Cropper from 'react-easy-crop' +import Cropper, { type Area, type CropperProps } from 'react-easy-crop' import classNames from 'classnames' import { ImagePlus } from '../icons/src/vender/line/images' @@ -18,11 +17,13 @@ export type OnImageInput = { type UploaderProps = { className?: string + cropShape?: CropperProps['cropShape'] onImageInput?: OnImageInput } const ImageInput: FC = ({ className, + cropShape, onImageInput, }) => { const [inputImage, setInputImage] = useState<{ file: File; url: string }>() @@ -78,6 +79,7 @@ const ImageInput: FC = ({ crop={crop} zoom={zoom} aspect={1} + cropShape={cropShape} onCropChange={setCrop} onCropComplete={onCropComplete} onZoomChange={setZoom} diff --git a/web/app/components/base/avatar/index.tsx b/web/app/components/base/avatar/index.tsx index fd7fb58687..af406555bf 100644 --- a/web/app/components/base/avatar/index.tsx +++ b/web/app/components/base/avatar/index.tsx @@ -2,9 +2,9 @@ import { useState } from 'react' import cn from '@/utils/classnames' -type AvatarProps = { +export type AvatarProps = { name: string - avatar?: string + avatar: string | null size?: number className?: string textClassName?: string diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx index f70e41d46f..1668421772 100644 --- a/web/app/components/datasets/settings/permission-selector/index.tsx +++ b/web/app/components/datasets/settings/permission-selector/index.tsx @@ -74,7 +74,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, > {permission === 'only_me' && (
- +
{t('datasetSettings.form.permissionsOnlyMe')}
{!disabled && }
@@ -106,7 +106,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, setOpen(false) }}>
- +
{t('datasetSettings.form.permissionsOnlyMe')}
{permission === 'only_me' && }
@@ -149,7 +149,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, {showMe && (
- +
{userProfile.name} @@ -162,7 +162,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, )} {filteredMemberList.map(member => (
selectMember(member)}> - +
{member.name}
{member.email}
diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index e92b16fd67..d9065f3141 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -68,7 +68,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { ${open && 'bg-gray-200'} `} > - + {!isMobile && <> {userProfile.name} @@ -92,7 +92,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { >
- +
{userProfile.name}
{userProfile.email}
diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 808da454d1..de3fef9b95 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -95,7 +95,7 @@ const MembersPage = () => { accounts.map(account => (
- +
{account.name} diff --git a/web/models/common.ts b/web/models/common.ts index dc2b1120b9..16acbc5337 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -22,6 +22,7 @@ export type UserProfileResponse = { name: string email: string avatar: string + avatar_url: string | null is_password_set: boolean interface_language?: string interface_theme?: string @@ -62,7 +63,7 @@ export type TenantInfoResponse = { trial_end_reason: null | 'trial_exceeded' | 'using_custom' } -export type Member = Pick & { +export type Member = Pick & { avatar: string status: 'pending' | 'active' | 'banned' | 'closed' role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator'