diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 2f304b970c6050..8651597fd700a3 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -61,6 +61,7 @@ def post(self): parser.add_argument('name', type=str, required=True, location='json') parser.add_argument('description', type=str, location='json') parser.add_argument('mode', type=str, choices=ALLOW_CREATE_APP_MODES, location='json') + parser.add_argument('icon_type', type=str, location='json') parser.add_argument('icon', type=str, location='json') parser.add_argument('icon_background', type=str, location='json') args = parser.parse_args() @@ -94,6 +95,7 @@ def post(self): parser.add_argument('data', type=str, required=True, nullable=False, location='json') parser.add_argument('name', type=str, location='json') parser.add_argument('description', type=str, location='json') + parser.add_argument('icon_type', type=str, location='json') parser.add_argument('icon', type=str, location='json') parser.add_argument('icon_background', type=str, location='json') args = parser.parse_args() @@ -167,6 +169,7 @@ def put(self, app_model): parser = reqparse.RequestParser() parser.add_argument('name', type=str, required=True, nullable=False, location='json') parser.add_argument('description', type=str, location='json') + parser.add_argument('icon_type', type=str, location='json') parser.add_argument('icon', type=str, location='json') parser.add_argument('icon_background', type=str, location='json') parser.add_argument('max_active_requests', type=int, location='json') @@ -208,6 +211,7 @@ def post(self, app_model): parser = reqparse.RequestParser() parser.add_argument('name', type=str, location='json') parser.add_argument('description', type=str, location='json') + parser.add_argument('icon_type', type=str, location='json') parser.add_argument('icon', type=str, location='json') parser.add_argument('icon_background', type=str, location='json') args = parser.parse_args() diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index 6aa9f0b475f161..7db58c048abcb0 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -16,6 +16,7 @@ def parse_app_site_args(): parser = reqparse.RequestParser() parser.add_argument('title', type=str, required=False, location='json') + parser.add_argument('icon_type', type=str, required=False, location='json') parser.add_argument('icon', type=str, required=False, location='json') parser.add_argument('icon_background', type=str, required=False, location='json') parser.add_argument('description', type=str, required=False, location='json') @@ -53,6 +54,7 @@ def post(self, app_model): for attr_name in [ 'title', + 'icon_type', 'icon', 'icon_background', 'description', diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 6eb97b6c817916..a2052b9764e9ff 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -459,6 +459,7 @@ def post(self, app_model: App): if request.data: parser = reqparse.RequestParser() parser.add_argument('name', type=str, required=False, nullable=True, location='json') + parser.add_argument('icon_type', type=str, required=False, nullable=True, location='json') parser.add_argument('icon', type=str, required=False, nullable=True, location='json') parser.add_argument('icon_background', type=str, required=False, nullable=True, location='json') args = parser.parse_args() diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 99ec86e935e333..0f4a7cabe5d900 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -6,6 +6,7 @@ from controllers.web import api from controllers.web.wraps import WebApiResource from extensions.ext_database import db +from libs.helper import AppIconUrlField from models.account import TenantStatus from models.model import Site from services.feature_service import FeatureService @@ -28,8 +29,10 @@ class AppSiteApi(WebApiResource): 'title': fields.String, 'chat_color_theme': fields.String, 'chat_color_theme_inverted': fields.Boolean, + 'icon_type': fields.String, 'icon': fields.String, 'icon_background': fields.String, + 'icon_url': AppIconUrlField, 'description': fields.String, 'copyright': fields.String, 'privacy_policy': fields.String, diff --git a/api/events/event_handlers/create_site_record_when_app_created.py b/api/events/event_handlers/create_site_record_when_app_created.py index abaf0e41ec30e6..ab07c5d366c342 100644 --- a/api/events/event_handlers/create_site_record_when_app_created.py +++ b/api/events/event_handlers/create_site_record_when_app_created.py @@ -11,6 +11,7 @@ def handle(sender, **kwargs): site = Site( app_id=app.id, title=app.name, + icon_type=app.icon_type, icon=app.icon, icon_background=app.icon_background, default_language=account.interface_language, diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 7036d58e4ab3a2..26ed686783a0ca 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -1,14 +1,16 @@ from flask_restful import fields -from libs.helper import TimestampField +from libs.helper import AppIconUrlField, TimestampField app_detail_kernel_fields = { "id": fields.String, "name": fields.String, "description": fields.String, "mode": fields.String(attribute="mode_compatible_with_agent"), + "icon_type": fields.String, "icon": fields.String, "icon_background": fields.String, + "icon_url": AppIconUrlField, } related_app_list = { @@ -71,8 +73,10 @@ "max_active_requests": fields.Raw(), "description": fields.String(attribute="desc_or_prompt"), "mode": fields.String(attribute="mode_compatible_with_agent"), + "icon_type": fields.String, "icon": fields.String, "icon_background": fields.String, + "icon_url": AppIconUrlField, "model_config": fields.Nested(model_config_partial_fields, attribute="app_model_config", allow_null=True), "created_at": TimestampField, "tags": fields.List(fields.Nested(tag_fields)), @@ -104,8 +108,10 @@ "access_token": fields.String(attribute="code"), "code": fields.String, "title": fields.String, + "icon_type": fields.String, "icon": fields.String, "icon_background": fields.String, + "icon_url": AppIconUrlField, "description": fields.String, "default_language": fields.String, "chat_color_theme": fields.String, @@ -125,8 +131,10 @@ "name": fields.String, "description": fields.String, "mode": fields.String(attribute="mode_compatible_with_agent"), + "icon_type": fields.String, "icon": fields.String, "icon_background": fields.String, + "icon_url": AppIconUrlField, "enable_site": fields.Boolean, "enable_api": fields.Boolean, "model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True), diff --git a/api/fields/installed_app_fields.py b/api/fields/installed_app_fields.py index b87cc653240a71..9afc1b1a4ad79d 100644 --- a/api/fields/installed_app_fields.py +++ b/api/fields/installed_app_fields.py @@ -1,13 +1,15 @@ from flask_restful import fields -from libs.helper import TimestampField +from libs.helper import AppIconUrlField, TimestampField app_fields = { "id": fields.String, "name": fields.String, "mode": fields.String, + "icon_type": fields.String, "icon": fields.String, "icon_background": fields.String, + "icon_url": AppIconUrlField, } installed_app_fields = { diff --git a/api/libs/helper.py b/api/libs/helper.py index 6b584ddcc2abf5..af0c2dace1a5dd 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -16,6 +16,7 @@ from flask_restful import fields from core.app.features.rate_limiting.rate_limit import RateLimitGenerator +from core.file.upload_file_parser import UploadFileParser from extensions.ext_redis import redis_client from models.account import Account @@ -24,6 +25,18 @@ def run(script): return subprocess.getstatusoutput("source /root/.bashrc && " + script) +class AppIconUrlField(fields.Raw): + def output(self, key, obj): + if obj is None: + return None + + from models.model import IconType + + if obj.icon_type == IconType.IMAGE.value: + return UploadFileParser.get_signed_temp_image_url(obj.icon) + return None + + class TimestampField(fields.Raw): def format(self, value) -> int: return int(value.timestamp()) diff --git a/api/migrations/versions/2024_08_15_1001-a6be81136580_app_and_site_icon_type.py b/api/migrations/versions/2024_08_15_1001-a6be81136580_app_and_site_icon_type.py new file mode 100644 index 00000000000000..d814666eefd2f2 --- /dev/null +++ b/api/migrations/versions/2024_08_15_1001-a6be81136580_app_and_site_icon_type.py @@ -0,0 +1,39 @@ +"""app and site icon type + +Revision ID: a6be81136580 +Revises: 8782057ff0dc +Create Date: 2024-08-15 10:01:24.697888 + +""" +import sqlalchemy as sa +from alembic import op + +import models as models + +# revision identifiers, used by Alembic. +revision = 'a6be81136580' +down_revision = '8782057ff0dc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('apps', schema=None) as batch_op: + batch_op.add_column(sa.Column('icon_type', sa.String(length=255), nullable=True)) + + with op.batch_alter_table('sites', schema=None) as batch_op: + batch_op.add_column(sa.Column('icon_type', sa.String(length=255), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('sites', schema=None) as batch_op: + batch_op.drop_column('icon_type') + + with op.batch_alter_table('apps', schema=None) as batch_op: + batch_op.drop_column('icon_type') + + # ### end Alembic commands ### diff --git a/api/models/model.py b/api/models/model.py index 5426d3bc83e020..94cfa527a7d792 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -51,6 +51,10 @@ def value_of(cls, value: str) -> 'AppMode': raise ValueError(f'invalid mode value {value}') +class IconType(Enum): + IMAGE = "image" + EMOJI = "emoji" + class App(db.Model): __tablename__ = 'apps' __table_args__ = ( @@ -63,6 +67,7 @@ class App(db.Model): name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text, nullable=False, server_default=db.text("''::character varying")) mode = db.Column(db.String(255), nullable=False) + icon_type = db.Column(db.String(255), nullable=True) icon = db.Column(db.String(255)) icon_background = db.Column(db.String(255)) app_model_config_id = db.Column(StringUUID, nullable=True) @@ -1087,6 +1092,7 @@ class Site(db.Model): id = db.Column(StringUUID, server_default=db.text('uuid_generate_v4()')) app_id = db.Column(StringUUID, nullable=False) title = db.Column(db.String(255), nullable=False) + icon_type = db.Column(db.String(255), nullable=True) icon = db.Column(db.String(255)) icon_background = db.Column(db.String(255)) description = db.Column(db.Text) diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index bfb160b3e476d9..737def3366fdba 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -82,6 +82,7 @@ def import_and_create_new_app(cls, tenant_id: str, data: str, args: dict, accoun # get app basic info name = args.get("name") if args.get("name") else app_data.get('name') description = args.get("description") if args.get("description") else app_data.get('description', '') + icon_type = args.get("icon_type") if args.get("icon_type") else app_data.get('icon_type') icon = args.get("icon") if args.get("icon") else app_data.get('icon') icon_background = args.get("icon_background") if args.get("icon_background") \ else app_data.get('icon_background') @@ -96,6 +97,7 @@ def import_and_create_new_app(cls, tenant_id: str, data: str, args: dict, accoun account=account, name=name, description=description, + icon_type=icon_type, icon=icon, icon_background=icon_background ) @@ -107,6 +109,7 @@ def import_and_create_new_app(cls, tenant_id: str, data: str, args: dict, accoun account=account, name=name, description=description, + icon_type=icon_type, icon=icon, icon_background=icon_background ) @@ -165,8 +168,8 @@ def export_dsl(cls, app_model: App, include_secret:bool = False) -> str: "app": { "name": app_model.name, "mode": app_model.mode, - "icon": app_model.icon, - "icon_background": app_model.icon_background, + "icon": '🤖' if app_model.icon_type == 'image' else app_model.icon, + "icon_background": '#FFEAD5' if app_model.icon_type == 'image' else app_model.icon_background, "description": app_model.description } } @@ -207,6 +210,7 @@ def _import_and_create_new_workflow_based_app(cls, account: Account, name: str, description: str, + icon_type: str, icon: str, icon_background: str) -> App: """ @@ -218,6 +222,7 @@ def _import_and_create_new_workflow_based_app(cls, :param account: Account instance :param name: app name :param description: app description + :param icon_type: app icon type, "emoji" or "image" :param icon: app icon :param icon_background: app icon background """ @@ -231,6 +236,7 @@ def _import_and_create_new_workflow_based_app(cls, account=account, name=name, description=description, + icon_type=icon_type, icon=icon, icon_background=icon_background ) @@ -307,6 +313,7 @@ def _import_and_create_new_model_config_based_app(cls, account: Account, name: str, description: str, + icon_type: str, icon: str, icon_background: str) -> App: """ @@ -331,6 +338,7 @@ def _import_and_create_new_model_config_based_app(cls, account=account, name=name, description=description, + icon_type=icon_type, icon=icon, icon_background=icon_background ) @@ -358,6 +366,7 @@ def _create_app(cls, account: Account, name: str, description: str, + icon_type: str, icon: str, icon_background: str) -> App: """ @@ -368,6 +377,7 @@ def _create_app(cls, :param account: Account instance :param name: app name :param description: app description + :param icon_type: app icon type, "emoji" or "image" :param icon: app icon :param icon_background: app icon background """ @@ -376,6 +386,7 @@ def _create_app(cls, mode=app_mode.value, name=name, description=description, + icon_type=icon_type, icon=icon, icon_background=icon_background, enable_site=True, diff --git a/api/services/app_service.py b/api/services/app_service.py index e433bb59bbe994..f33ef9b0013580 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -119,6 +119,7 @@ def create_app(self, tenant_id: str, args: dict, account: Account) -> App: app.name = args['name'] app.description = args.get('description', '') app.mode = args['mode'] + app.icon_type = args.get('icon_type', 'emoji') app.icon = args['icon'] app.icon_background = args['icon_background'] app.tenant_id = tenant_id @@ -210,6 +211,7 @@ def update_app(self, app: App, args: dict) -> App: app.name = args.get('name') app.description = args.get('description', '') app.max_active_requests = args.get('max_active_requests') + app.icon_type = args.get('icon_type', 'emoji') app.icon = args.get('icon') app.icon_background = args.get('icon_background') app.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) diff --git a/api/services/workflow/workflow_converter.py b/api/services/workflow/workflow_converter.py index f993608293cc8c..610330eda55727 100644 --- a/api/services/workflow/workflow_converter.py +++ b/api/services/workflow/workflow_converter.py @@ -35,6 +35,7 @@ class WorkflowConverter: def convert_to_workflow(self, app_model: App, account: Account, name: str, + icon_type: str, icon: str, icon_background: str) -> App: """ @@ -50,6 +51,7 @@ def convert_to_workflow(self, app_model: App, :param account: Account :param name: new app name :param icon: new app icon + :param icon_type: new app icon type :param icon_background: new app icon background :return: new App instance """ @@ -66,6 +68,7 @@ def convert_to_workflow(self, app_model: App, new_app.name = name if name else app_model.name + '(workflow)' new_app.mode = AppMode.ADVANCED_CHAT.value \ if app_model.mode == AppMode.CHAT.value else AppMode.WORKFLOW.value + new_app.icon_type = icon_type if icon_type else app_model.icon_type new_app.icon = icon if icon else app_model.icon new_app.icon_background = icon_background if icon_background else app_model.icon_background new_app.enable_site = app_model.enable_site diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 2defb4cd6a7088..c593b66f363dc7 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -302,6 +302,7 @@ def convert_to_workflow(self, app_model: App, account: Account, args: dict) -> A app_model=app_model, account=account, name=args.get('name'), + icon_type=args.get('icon_type'), icon=args.get('icon'), icon_background=args.get('icon_background'), ) diff --git a/web/.env.example b/web/.env.example index 439092c20e0a0e..3045cef2f9d258 100644 --- a/web/.env.example +++ b/web/.env.example @@ -15,4 +15,7 @@ NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api NEXT_PUBLIC_SENTRY_DSN= # Disable Next.js Telemetry (https://nextjs.org/telemetry) -NEXT_TELEMETRY_DISABLED=1 \ No newline at end of file +NEXT_TELEMETRY_DISABLED=1 + +# Disable Upload Image as WebApp icon default is false +NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON=false diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index 1387099a627e1c..bc7308a711a06b 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -75,6 +75,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, + icon_type, icon, icon_background, description, @@ -83,6 +84,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { await updateAppInfo({ appID: app.id, name, + icon_type, icon, icon_background, description, @@ -101,11 +103,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { } }, [app.id, mutateApps, notify, onRefresh, t]) - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon, icon_background }) => { + const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { try { const newApp = await copyApp({ appID: app.id, name, + icon_type, icon, icon_background, mode: app.mode, @@ -258,8 +261,10 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.mode === 'advanced-chat' && ( @@ -360,9 +365,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { {showEditModal && ( { {showDuplicateModal && ( setShowDuplicateModal(false)} diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx index 11893ec9de4e08..7cd083d11b2d42 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx @@ -60,7 +60,7 @@ const LikedItem = ({ return (
- + {type === 'app' && ( {detail.mode === 'advanced-chat' && ( diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index 698846cae5144b..c5bc3bb210b3a8 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -59,6 +59,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => { const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, + icon_type, icon, icon_background, description, @@ -69,6 +70,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => { const app = await updateAppInfo({ appID: appDetail.id, name, + icon_type, icon, icon_background, description, @@ -86,13 +88,14 @@ const AppInfo = ({ expand }: IAppInfoProps) => { } }, [appDetail, mutateApps, notify, setAppDetail, t]) - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon, icon_background }) => { + const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { if (!appDetail) return try { const newApp = await copyApp({ appID: appDetail.id, name, + icon_type, icon, icon_background, mode: appDetail.mode, @@ -194,7 +197,13 @@ const AppInfo = ({ expand }: IAppInfoProps) => { >
- + { {/* header */}
- + {appDetail.mode === 'advanced-chat' && ( @@ -402,9 +417,11 @@ const AppInfo = ({ expand }: IAppInfoProps) => { {showEditModal && ( { {showDuplicateModal && ( setShowDuplicateModal(false)} diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index c4cedbb3543b9f..4061ec652f3a23 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -8,6 +8,8 @@ import { } from '@remixicon/react' import { useRouter } from 'next/navigation' import { useContext, useContextSelector } from 'use-context-selector' +import AppIconPicker from '../../base/app-icon-picker' +import type { AppIconSelection } from '../../base/app-icon-picker' import s from './style.module.css' import cn from '@/utils/classnames' import AppsContext, { useAppContext } from '@/context/app-context' @@ -18,7 +20,6 @@ import { createApp } from '@/service/apps' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import AppIcon from '@/app/components/base/app-icon' -import EmojiPicker from '@/app/components/base/emoji-picker' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { AiText, ChatBot, CuteRobote } from '@/app/components/base/icons/src/vender/solid/communication' import { Route } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel' @@ -40,8 +41,8 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => { const [appMode, setAppMode] = useState('chat') const [showChatBotType, setShowChatBotType] = useState(true) - const [emoji, setEmoji] = useState({ icon: '🤖', icon_background: '#FFEAD5' }) - const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [appIcon, setAppIcon] = useState({ type: 'emoji', icon: '🤖', background: '#FFEAD5' }) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [name, setName] = useState('') const [description, setDescription] = useState('') @@ -66,8 +67,9 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => { const app = await createApp({ name, description, - icon: emoji.icon, - icon_background: emoji.icon_background, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, mode: appMode, }) notify({ type: 'success', message: t('app.newApp.appCreated') }) @@ -81,7 +83,7 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => { notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) } isCreatingRef.current = false - }, [name, notify, t, appMode, emoji.icon, emoji.icon_background, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor]) + }, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor]) return ( {
{t('app.newApp.captionName')}
- { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} /> + { setShowAppIconPicker(true) }} + /> setName(e.target.value)} @@ -277,14 +286,13 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => { className='grow h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs' />
- {showEmojiPicker && { - setEmoji({ icon, icon_background }) - setShowEmojiPicker(false) + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) }} onClose={() => { - setEmoji({ icon: '🤖', icon_background: '#FFEAD5' }) - setShowEmojiPicker(false) + setShowAppIconPicker(false) }} />}
diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index 6595972de615da..59a53101dbed2d 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -1,32 +1,39 @@ 'use client' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' +import AppIconPicker from '../../base/app-icon-picker' import s from './style.module.css' import cn from '@/utils/classnames' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Toast from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' -import EmojiPicker from '@/app/components/base/emoji-picker' import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' +import type { AppIconType } from '@/types/app' + export type DuplicateAppModalProps = { appName: string + icon_type: AppIconType | null icon: string - icon_background: string + icon_background?: string | null + icon_url?: string | null show: boolean onConfirm: (info: { name: string + icon_type: AppIconType icon: string - icon_background: string + icon_background?: string | null }) => Promise onHide: () => void } const DuplicateAppModal = ({ appName, + icon_type, icon, icon_background, + icon_url, show = false, onConfirm, onHide, @@ -35,8 +42,12 @@ const DuplicateAppModal = ({ const [name, setName] = React.useState(appName) - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const [emoji, setEmoji] = useState({ icon, icon_background }) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + const [appIcon, setAppIcon] = useState( + icon_type === 'image' + ? { type: 'image' as const, url: icon_url, fileId: icon } + : { type: 'emoji' as const, icon, background: icon_background }, + ) const { plan, enableBilling } = useProviderContext() const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) @@ -48,7 +59,9 @@ const DuplicateAppModal = ({ } onConfirm({ name, - ...emoji, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, }) onHide() } @@ -65,7 +78,15 @@ const DuplicateAppModal = ({
{t('explore.appCustomize.subTitle')}
- { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} /> + { setShowAppIconPicker(true) }} + className='cursor-pointer' + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + /> setName(e.target.value)} @@ -79,14 +100,16 @@ const DuplicateAppModal = ({
- {showEmojiPicker && { - setEmoji({ icon, icon_background }) - setShowEmojiPicker(false) + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) }} onClose={() => { - setEmoji({ icon, icon_background }) - setShowEmojiPicker(false) + setAppIcon(icon_type === 'image' + ? { type: 'image', url: icon_url!, fileId: icon } + : { type: 'emoji', icon, background: icon_background! }) + setShowAppIconPicker(false) }} />} diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 88d5c2d9094585..8da9b9864f5046 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -10,11 +10,11 @@ import Button from '@/app/components/base/button' import AppIcon from '@/app/components/base/app-icon' import { SimpleSelect } from '@/app/components/base/select' import type { AppDetailResponse } from '@/models/app' -import type { Language } from '@/types/app' -import EmojiPicker from '@/app/components/base/emoji-picker' +import type { AppIconType, Language } from '@/types/app' import { useToastContext } from '@/app/components/base/toast' - import { languages } from '@/i18n/language' +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import AppIconPicker from '@/app/components/base/app-icon-picker' export type ISettingsModalProps = { isChat: boolean @@ -35,8 +35,9 @@ export type ConfigParams = { copyright: string privacy_policy: string custom_disclaimer: string + icon_type: AppIconType icon: string - icon_background: string + icon_background?: string show_workflow_steps: boolean } @@ -51,9 +52,12 @@ const SettingsModal: FC = ({ }) => { const { notify } = useToastContext() const [isShowMore, setIsShowMore] = useState(false) - const { icon, icon_background } = appInfo const { title, + icon_type, + icon, + icon_background, + icon_url, description, chat_color_theme, chat_color_theme_inverted, @@ -76,9 +80,13 @@ const SettingsModal: FC = ({ const [language, setLanguage] = useState(default_language) const [saveLoading, setSaveLoading] = useState(false) const { t } = useTranslation() - // Emoji Picker - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const [emoji, setEmoji] = useState({ icon, icon_background }) + + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + const [appIcon, setAppIcon] = useState( + icon_type === 'image' + ? { type: 'image', url: icon_url!, fileId: icon } + : { type: 'emoji', icon, background: icon_background! }, + ) useEffect(() => { setInputInfo({ @@ -92,7 +100,9 @@ const SettingsModal: FC = ({ show_workflow_steps, }) setLanguage(default_language) - setEmoji({ icon, icon_background }) + setAppIcon(icon_type === 'image' + ? { type: 'image', url: icon_url!, fileId: icon } + : { type: 'emoji', icon, background: icon_background! }) }, [appInfo]) const onHide = () => { @@ -135,8 +145,9 @@ const SettingsModal: FC = ({ copyright: inputInfo.copyright, privacy_policy: inputInfo.privacyPolicy, custom_disclaimer: inputInfo.customDisclaimer, - icon: emoji.icon, - icon_background: emoji.icon_background, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, show_workflow_steps: inputInfo.show_workflow_steps, } await onSave?.(params) @@ -167,10 +178,12 @@ const SettingsModal: FC = ({
{t(`${prefixSettings}.webName`)}
{ setShowEmojiPicker(true) }} + onClick={() => { setShowAppIconPicker(true) }} className='cursor-pointer !mr-3 self-center' - icon={emoji.icon} - background={emoji.icon_background} + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} /> = ({
- {showEmojiPicker && { - setEmoji({ icon, icon_background }) - setShowEmojiPicker(false) + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) }} onClose={() => { - setEmoji({ icon: appInfo.site.icon, icon_background: appInfo.site.icon_background }) - setShowEmojiPicker(false) + setAppIcon(icon_type === 'image' + ? { type: 'image', url: icon_url!, fileId: icon } + : { type: 'emoji', icon, background: icon_background! }) + setShowAppIconPicker(false) }} />} diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 79f4811e433f0a..82f57e1f5a107c 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' import { RiCloseLine } from '@remixicon/react' +import AppIconPicker from '../../base/app-icon-picker' import s from './style.module.css' import cn from '@/utils/classnames' import Button from '@/app/components/base/button' @@ -15,7 +16,6 @@ import { deleteApp, switchApp } from '@/service/apps' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' -import EmojiPicker from '@/app/components/base/emoji-picker' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { getRedirection } from '@/utils/app-redirection' import type { App } from '@/types/app' @@ -41,8 +41,13 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo const { plan, enableBilling } = useProviderContext() const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) - const [emoji, setEmoji] = useState({ icon: appDetail.icon, icon_background: appDetail.icon_background }) - const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + const [appIcon, setAppIcon] = useState( + appDetail.icon_type === 'image' + ? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon } + : { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background }, + ) + const [name, setName] = useState(`${appDetail.name}(copy)`) const [removeOriginal, setRemoveOriginal] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) @@ -52,8 +57,9 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo const { new_app_id: newAppID } = await switchApp({ appID: appDetail.id, name, - icon: emoji.icon, - icon_background: emoji.icon_background, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, }) if (onSuccess) onSuccess() @@ -106,7 +112,15 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
{t('app.switchLabel')}
- { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} /> + { setShowAppIconPicker(true) }} + className='cursor-pointer' + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + /> setName(e.target.value)} @@ -114,14 +128,16 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo className='grow h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs' />
- {showEmojiPicker && { - setEmoji({ icon, icon_background }) - setShowEmojiPicker(false) + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) }} onClose={() => { - setEmoji({ icon: appDetail.icon, icon_background: appDetail.icon_background }) - setShowEmojiPicker(false) + setAppIcon(appDetail.icon_type === 'image' + ? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon } + : { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background }) + setShowAppIconPicker(false) }} />}
diff --git a/web/app/components/base/app-icon-picker/Uploader.tsx b/web/app/components/base/app-icon-picker/Uploader.tsx new file mode 100644 index 00000000000000..4ddaa404475e6d --- /dev/null +++ b/web/app/components/base/app-icon-picker/Uploader.tsx @@ -0,0 +1,97 @@ +'use client' + +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 classNames from 'classnames' + +import { ImagePlus } from '../icons/src/vender/line/images' +import { useDraggableUploader } from './hooks' +import { ALLOW_FILE_EXTENSIONS } from '@/types/app' + +type UploaderProps = { + className?: string + onImageCropped?: (tempUrl: string, croppedAreaPixels: Area, fileName: string) => void +} + +const Uploader: FC = ({ + className, + onImageCropped, +}) => { + const [inputImage, setInputImage] = useState<{ file: File; url: string }>() + useEffect(() => { + return () => { + if (inputImage) + URL.revokeObjectURL(inputImage.url) + } + }, [inputImage]) + + const [crop, setCrop] = useState({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + + const onCropComplete = async (_: Area, croppedAreaPixels: Area) => { + if (!inputImage) + return + onImageCropped?.(inputImage.url, croppedAreaPixels, inputImage.file.name) + } + + const handleLocalFileInput = (e: ChangeEvent) => { + const file = e.target.files?.[0] + if (file) + setInputImage({ file, url: URL.createObjectURL(file) }) + } + + const { + isDragActive, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + } = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) })) + + const inputRef = createRef() + + return ( +
+
+ { + !inputImage + ? <> + +
+ Drop your image here, or  + + ((e.target as HTMLInputElement).value = '')} + accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')} + onChange={handleLocalFileInput} + /> +
+
Supports PNG, JPG, JPEG, WEBP and GIF
+ + : + } +
+
+ ) +} + +export default Uploader diff --git a/web/app/components/base/app-icon-picker/hooks.tsx b/web/app/components/base/app-icon-picker/hooks.tsx new file mode 100644 index 00000000000000..b3f67c0dcae930 --- /dev/null +++ b/web/app/components/base/app-icon-picker/hooks.tsx @@ -0,0 +1,43 @@ +import { useCallback, useState } from 'react' + +export const useDraggableUploader = (setImageFn: (file: File) => void) => { + const [isDragActive, setIsDragActive] = useState(false) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(true) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + }, []) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + + const file = e.dataTransfer.files[0] + + if (!file) + return + + setImageFn(file) + }, [setImageFn]) + + return { + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + isDragActive, + } +} diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx new file mode 100644 index 00000000000000..825481547501a8 --- /dev/null +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -0,0 +1,139 @@ +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { Area } from 'react-easy-crop' +import Modal from '../modal' +import Divider from '../divider' +import Button from '../button' +import { ImagePlus } from '../icons/src/vender/line/images' +import { useLocalFileUploader } from '../image-uploader/hooks' +import EmojiPickerInner from '../emoji-picker/Inner' +import Uploader from './Uploader' +import s from './style.module.css' +import getCroppedImg from './utils' +import type { AppIconType, ImageFile } from '@/types/app' +import cn from '@/utils/classnames' +import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' +export type AppIconEmojiSelection = { + type: 'emoji' + icon: string + background: string +} + +export type AppIconImageSelection = { + type: 'image' + fileId: string + url: string +} + +export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection + +type AppIconPickerProps = { + onSelect?: (payload: AppIconSelection) => void + onClose?: () => void + className?: string +} + +const AppIconPicker: FC = ({ + onSelect, + onClose, + className, +}) => { + const { t } = useTranslation() + + const tabs = [ + { key: 'emoji', label: t('app.iconPicker.emoji'), icon: 🤖 }, + { key: 'image', label: t('app.iconPicker.image'), icon: }, + ] + const [activeTab, setActiveTab] = useState('emoji') + + const [emoji, setEmoji] = useState<{ emoji: string; background: string }>() + const handleSelectEmoji = useCallback((emoji: string, background: string) => { + setEmoji({ emoji, background }) + }, [setEmoji]) + + const [uploading, setUploading] = useState() + + const { handleLocalFileUpload } = useLocalFileUploader({ + limit: 3, + disabled: false, + onUpload: (imageFile: ImageFile) => { + if (imageFile.fileId) { + setUploading(false) + onSelect?.({ + type: 'image', + fileId: imageFile.fileId, + url: imageFile.url, + }) + } + }, + }) + + const [imageCropInfo, setImageCropInfo] = useState<{ tempUrl: string; croppedAreaPixels: Area; fileName: string }>() + const handleImageCropped = async (tempUrl: string, croppedAreaPixels: Area, fileName: string) => { + setImageCropInfo({ tempUrl, croppedAreaPixels, fileName }) + } + + const handleSelect = async () => { + if (activeTab === 'emoji') { + if (emoji) { + onSelect?.({ + type: 'emoji', + icon: emoji.emoji, + background: emoji.background, + }) + } + } + else { + if (!imageCropInfo) + return + setUploading(true) + const blob = await getCroppedImg(imageCropInfo.tempUrl, imageCropInfo.croppedAreaPixels) + const file = new File([blob], imageCropInfo.fileName, { type: blob.type }) + handleLocalFileUpload(file) + } + } + + return { }} + isShow + closable={false} + wrapperClassName={className} + className={cn(s.container, '!w-[362px] !p-0')} + > + {!DISABLE_UPLOAD_IMAGE_AS_ICON &&
+
+ {tabs.map(tab => ( + + ))} +
+
} + + + + + + + +
+ + + +
+
+} + +export default AppIconPicker diff --git a/web/app/components/base/app-icon-picker/style.module.css b/web/app/components/base/app-icon-picker/style.module.css new file mode 100644 index 00000000000000..5facb3560a04d3 --- /dev/null +++ b/web/app/components/base/app-icon-picker/style.module.css @@ -0,0 +1,12 @@ +.container { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 362px; + max-height: 552px; + + border: 0.5px solid #EAECF0; + box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03); + border-radius: 12px; + background: #fff; +} diff --git a/web/app/components/base/app-icon-picker/utils.ts b/web/app/components/base/app-icon-picker/utils.ts new file mode 100644 index 00000000000000..0c90e96febda83 --- /dev/null +++ b/web/app/components/base/app-icon-picker/utils.ts @@ -0,0 +1,98 @@ +export const createImage = (url: string) => + new Promise((resolve, reject) => { + const image = new Image() + image.addEventListener('load', () => resolve(image)) + image.addEventListener('error', error => reject(error)) + image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox + image.src = url + }) + +export function getRadianAngle(degreeValue: number) { + return (degreeValue * Math.PI) / 180 +} + +/** + * Returns the new bounding area of a rotated rectangle. + */ +export function rotateSize(width: number, height: number, rotation: number) { + const rotRad = getRadianAngle(rotation) + + return { + width: + Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: + Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + } +} + +/** + * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop + */ +export default async function getCroppedImg( + imageSrc: string, + pixelCrop: { x: number; y: number; width: number; height: number }, + rotation = 0, + flip = { horizontal: false, vertical: false }, +): Promise { + const image = await createImage(imageSrc) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) + throw new Error('Could not create a canvas context') + + const rotRad = getRadianAngle(rotation) + + // calculate bounding box of the rotated image + const { width: bBoxWidth, height: bBoxHeight } = rotateSize( + image.width, + image.height, + rotation, + ) + + // set canvas size to match the bounding box + canvas.width = bBoxWidth + canvas.height = bBoxHeight + + // translate canvas context to a central location to allow rotating and flipping around the center + ctx.translate(bBoxWidth / 2, bBoxHeight / 2) + ctx.rotate(rotRad) + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1) + ctx.translate(-image.width / 2, -image.height / 2) + + // draw rotated image + ctx.drawImage(image, 0, 0) + + const croppedCanvas = document.createElement('canvas') + + const croppedCtx = croppedCanvas.getContext('2d') + + if (!croppedCtx) + throw new Error('Could not create a canvas context') + + // Set the size of the cropped canvas + croppedCanvas.width = pixelCrop.width + croppedCanvas.height = pixelCrop.height + + // Draw the cropped image onto the new canvas + croppedCtx.drawImage( + canvas, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height, + ) + + return new Promise((resolve, reject) => { + croppedCanvas.toBlob((file) => { + if (file) + resolve(file) + else + reject(new Error('Could not create a blob')) + }, 'image/jpeg') + }) +} diff --git a/web/app/components/base/app-icon/index.tsx b/web/app/components/base/app-icon/index.tsx index 9d8cf28beda7a1..5e7378c08734aa 100644 --- a/web/app/components/base/app-icon/index.tsx +++ b/web/app/components/base/app-icon/index.tsx @@ -1,17 +1,21 @@ -import type { FC } from 'react' +'use client' -import data from '@emoji-mart/data' +import type { FC } from 'react' import { init } from 'emoji-mart' +import data from '@emoji-mart/data' import style from './style.module.css' import classNames from '@/utils/classnames' +import type { AppIconType } from '@/types/app' init({ data }) export type AppIconProps = { size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' rounded?: boolean + iconType?: AppIconType | null icon?: string - background?: string + background?: string | null + imageUrl?: string | null className?: string innerIcon?: React.ReactNode onClick?: () => void @@ -20,28 +24,34 @@ export type AppIconProps = { const AppIcon: FC = ({ size = 'medium', rounded = false, + iconType, icon, background, + imageUrl, className, innerIcon, onClick, }) => { - return ( - - {innerIcon || ((icon && icon !== '') ? : )} - + const wrapperClassName = classNames( + style.appIcon, + size !== 'medium' && style[size], + rounded && style.rounded, + className ?? '', + 'overflow-hidden', ) + + const isValidImageIcon = iconType === 'image' && imageUrl + + return + {isValidImageIcon + ? app icon + : (innerIcon || ((icon && icon !== '') ? : )) + } + } export default AppIcon diff --git a/web/app/components/base/app-icon/style.module.css b/web/app/components/base/app-icon/style.module.css index 20f76d40995daa..06a2478d41e380 100644 --- a/web/app/components/base/app-icon/style.module.css +++ b/web/app/components/base/app-icon/style.module.css @@ -1,5 +1,5 @@ .appIcon { - @apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0; + @apply flex items-center justify-center relative w-9 h-9 text-lg rounded-lg grow-0 shrink-0; } .appIcon.large { diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 3fa301d268744d..624cc53a183609 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -43,7 +43,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) - useAppFavicon(!installedAppInfo, appInfo?.site.icon, appInfo?.site.icon_background) + useAppFavicon({ + enable: !installedAppInfo, + icon_type: appInfo?.site.icon_type, + icon: appInfo?.site.icon, + icon_background: appInfo?.site.icon_background, + icon_url: appInfo?.site.icon_url, + }) const appData = useMemo(() => { if (isInstalledApp) { @@ -52,8 +58,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { app_id: id, site: { title: app.name, + icon_type: app.icon_type, icon: app.icon, icon_background: app.icon_background, + icon_url: app.icon_url, prompt_public: false, copyright: '', show_workflow_steps: true, diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index dd145c36b9c119..17a2751f111928 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -67,8 +67,10 @@ const Sidebar = () => {
{appData?.site.title} diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx new file mode 100644 index 00000000000000..36c146a2a0c979 --- /dev/null +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -0,0 +1,171 @@ +'use client' +import type { ChangeEvent, FC } from 'react' +import React, { useState } from 'react' +import data from '@emoji-mart/data' +import type { EmojiMartData } from '@emoji-mart/data' +import { init } from 'emoji-mart' +import { + MagnifyingGlassIcon, +} from '@heroicons/react/24/outline' +import cn from '@/utils/classnames' +import Divider from '@/app/components/base/divider' +import { searchEmoji } from '@/utils/emoji' + +declare global { + namespace JSX { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface IntrinsicElements { + 'em-emoji': React.DetailedHTMLProps< React.HTMLAttributes, HTMLElement > + } + } +} + +init({ data }) + +const backgroundColors = [ + '#FFEAD5', + '#E4FBCC', + '#D3F8DF', + '#E0F2FE', + + '#E0EAFF', + '#EFF1F5', + '#FBE8FF', + '#FCE7F6', + + '#FEF7C3', + '#E6F4D7', + '#D5F5F6', + '#D1E9FF', + + '#D1E0FF', + '#D5D9EB', + '#ECE9FE', + '#FFE4E8', +] + +type IEmojiPickerInnerProps = { + emoji?: string + background?: string + onSelect?: (emoji: string, background: string) => void + className?: string +} + +const EmojiPickerInner: FC = ({ + onSelect, + className, +}) => { + const { categories } = data as EmojiMartData + const [selectedEmoji, setSelectedEmoji] = useState('') + const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0]) + + const [searchedEmojis, setSearchedEmojis] = useState([]) + const [isSearching, setIsSearching] = useState(false) + + React.useEffect(() => { + if (selectedEmoji && selectedBackground) + onSelect?.(selectedEmoji, selectedBackground) + }, [onSelect, selectedEmoji, selectedBackground]) + + return
+
+
+
+
+ ) => { + if (e.target.value === '') { + setIsSearching(false) + } + else { + setIsSearching(true) + const emojis = await searchEmoji(e.target.value) + setSearchedEmojis(emojis) + } + }} + /> +
+
+ + +
+ {isSearching && <> +
+

Search

+
+ {searchedEmojis.map((emoji: string, index: number) => { + return
{ + setSelectedEmoji(emoji) + }} + > +
+ +
+
+ })} +
+
+ } + + {categories.map((category, index: number) => { + return
+

{category.id}

+
+ {category.emojis.map((emoji, index: number) => { + return
{ + setSelectedEmoji(emoji) + }} + > +
+ +
+
+ })} + +
+
+ })} +
+ + {/* Color Select */} +
+

Choose Style

+
+ {backgroundColors.map((color) => { + return
{ + setSelectedBackground(color) + }} + > +
+ {selectedEmoji !== '' && } +
+
+ })} +
+
+
+} +export default EmojiPickerInner diff --git a/web/app/components/base/emoji-picker/index.tsx b/web/app/components/base/emoji-picker/index.tsx index 8840f47950c75d..3add14879aa5bb 100644 --- a/web/app/components/base/emoji-picker/index.tsx +++ b/web/app/components/base/emoji-picker/index.tsx @@ -1,56 +1,13 @@ -/* eslint-disable multiline-ternary */ 'use client' -import type { ChangeEvent, FC } from 'react' -import React, { useState } from 'react' -import data from '@emoji-mart/data' -import type { EmojiMartData } from '@emoji-mart/data' -import { init } from 'emoji-mart' -import { - MagnifyingGlassIcon, -} from '@heroicons/react/24/outline' +import type { FC } from 'react' +import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import s from './style.module.css' +import EmojiPickerInner from './Inner' import cn from '@/utils/classnames' import Divider from '@/app/components/base/divider' import Button from '@/app/components/base/button' import Modal from '@/app/components/base/modal' -import { searchEmoji } from '@/utils/emoji' - -declare global { - namespace JSX { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface IntrinsicElements { - 'em-emoji': React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLElement - > - } - } -} - -init({ data }) - -const backgroundColors = [ - '#FFEAD5', - '#E4FBCC', - '#D3F8DF', - '#E0F2FE', - - '#E0EAFF', - '#EFF1F5', - '#FBE8FF', - '#FCE7F6', - - '#FEF7C3', - '#E6F4D7', - '#D5F5F6', - '#D1E9FF', - - '#D1E0FF', - '#D5D9EB', - '#ECE9FE', - '#FFE4E8', -] type IEmojiPickerProps = { isModal?: boolean @@ -66,136 +23,43 @@ const EmojiPicker: FC = ({ className, }) => { const { t } = useTranslation() - const { categories } = data as EmojiMartData const [selectedEmoji, setSelectedEmoji] = useState('') - const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0]) + const [selectedBackground, setSelectedBackground] = useState() - const [searchedEmojis, setSearchedEmojis] = useState([]) - const [isSearching, setIsSearching] = useState(false) + const handleSelectEmoji = useCallback((emoji: string, background: string) => { + setSelectedEmoji(emoji) + setSelectedBackground(background) + }, [setSelectedEmoji, setSelectedBackground]) - return isModal ? { }} - isShow - closable={false} - wrapperClassName={className} - className={cn(s.container, '!w-[362px] !p-0')} - > -
-
-
-
- ) => { - if (e.target.value === '') { - setIsSearching(false) - } - else { - setIsSearching(true) - const emojis = await searchEmoji(e.target.value) - setSearchedEmojis(emojis) - } - }} - /> -
-
- - -
- {isSearching && <> -
-

Search

-
- {searchedEmojis.map((emoji: string, index: number) => { - return
{ - setSelectedEmoji(emoji) - }} - > -
- -
-
- })} -
-
- } - - {categories.map((category, index: number) => { - return
-

{category.id}

-
- {category.emojis.map((emoji, index: number) => { - return
{ - setSelectedEmoji(emoji) - }} - > -
- -
-
- })} - -
-
- })} -
- - {/* Color Select */} -
-

Choose Style

-
- {backgroundColors.map((color) => { - return
{ - setSelectedBackground(color) - }} - > -
- {selectedEmoji !== '' && } -
-
- })} -
-
- -
- - -
-
: <> - + {t('app.iconPicker.cancel')} + + +
+ + : <> } export default EmojiPicker diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 51c1ca6ce9a182..3d666fdb1a7dfb 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -26,7 +26,13 @@ const AppCard = ({
- + {appBasicInfo.mode === 'advanced-chat' && ( diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 4f2926677d0d45..2e555090cf3b53 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -118,6 +118,7 @@ const Apps = ({ const [isShowCreateModal, setIsShowCreateModal] = React.useState(false) const onCreate: CreateAppModalProps['onConfirm'] = async ({ name, + icon_type, icon, icon_background, description, @@ -129,6 +130,7 @@ const Apps = ({ const app = await importApp({ data: export_data, name, + icon_type, icon, icon_background, description, @@ -215,8 +217,10 @@ const Apps = ({
{isShowCreateModal && ( Promise onHide: () => void @@ -29,8 +33,10 @@ export type CreateAppModalProps = { const CreateAppModal = ({ show = false, isEditModal = false, - appIcon, + appIconType, + appIcon: _appIcon, appIconBackground, + appIconUrl, appName, appDescription, onConfirm, @@ -39,8 +45,12 @@ const CreateAppModal = ({ const { t } = useTranslation() const [name, setName] = React.useState(appName) - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const [emoji, setEmoji] = useState({ icon: appIcon, icon_background: appIconBackground }) + const [appIcon, setAppIcon] = useState( + () => appIconType === 'image' + ? { type: 'image' as const, fileId: _appIcon, url: appIconUrl } + : { type: 'emoji' as const, icon: _appIcon, background: appIconBackground }, + ) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [description, setDescription] = useState(appDescription || '') const { plan, enableBilling } = useProviderContext() @@ -53,7 +63,9 @@ const CreateAppModal = ({ } onConfirm({ name, - ...emoji, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background! : undefined, description, }) onHide() @@ -80,7 +92,15 @@ const CreateAppModal = ({
{t('app.newApp.captionName')}
- { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} /> + { setShowAppIconPicker(true) }} + className='cursor-pointer' + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + /> setName(e.target.value)} @@ -106,18 +126,19 @@ const CreateAppModal = ({
- {showEmojiPicker && { - setEmoji({ icon, icon_background }) - setShowEmojiPicker(false) + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) }} onClose={() => { - setEmoji({ icon: appIcon, icon_background: appIconBackground }) - setShowEmojiPicker(false) + setAppIcon(appIconType === 'image' + ? { type: 'image' as const, url: appIconUrl, fileId: _appIcon } + : { type: 'emoji' as const, icon: _appIcon, background: appIconBackground }) + setShowAppIconPicker(false) }} />} - ) } diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx index 2a7f3342ab7e2b..73bd93585b6250 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx @@ -7,13 +7,16 @@ import s from './style.module.css' import cn from '@/utils/classnames' import ItemOperation from '@/app/components/explore/item-operation' import AppIcon from '@/app/components/base/app-icon' +import type { AppIconType } from '@/types/app' export type IAppNavItemProps = { isMobile: boolean name: string id: string + icon_type: AppIconType | null icon: string icon_background: string + icon_url: string isSelected: boolean isPinned: boolean togglePin: () => void @@ -25,8 +28,10 @@ export default function AppNavItem({ isMobile, name, id, + icon_type, icon, icon_background, + icon_url, isSelected, isPinned, togglePin, @@ -50,11 +55,11 @@ export default function AppNavItem({ router.push(url) // use Link causes popup item always trigger jump. Can not be solved by e.stopPropagation(). }} > - {isMobile && } + {isMobile && } {!isMobile && ( <>
- +
{name}
e.stopPropagation()}> diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 1a3e767baaa68a..a4a40a00a20e5f 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -109,14 +109,16 @@ const SideBar: FC = ({ height: 'calc(100vh - 250px)', }} > - {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon, icon_background } }) => { + {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }) => { return ( = ({ } }, [siteInfo?.title, canReplaceLogo]) - useAppFavicon(!isInstalledApp, siteInfo?.icon, siteInfo?.icon_background) + useAppFavicon({ + enable: !isInstalledApp, + icon_type: siteInfo?.icon_type, + icon: siteInfo?.icon, + icon_background: siteInfo?.icon_background, + icon_url: siteInfo?.icon_url, + }) const [isShowResSidebar, { setTrue: doShowResSidebar, setFalse: hideResSidebar }] = useBoolean(false) const showResSidebar = () => { diff --git a/web/config/index.ts b/web/config/index.ts index 6b0e551e35c294..bb100473b1451c 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -247,3 +247,5 @@ Thought: {{agent_scratchpad}} export const VAR_REGEX = /\{\{(#[a-zA-Z0-9_-]{1,50}(\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}/gi export const TEXT_GENERATION_TIMEOUT_MS = 60000 + +export const DISABLE_UPLOAD_IMAGE_AS_ICON = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true' diff --git a/web/hooks/use-app-favicon.ts b/web/hooks/use-app-favicon.ts index 8904b884ced2f4..86eadc1b3d0862 100644 --- a/web/hooks/use-app-favicon.ts +++ b/web/hooks/use-app-favicon.ts @@ -1,19 +1,40 @@ import { useAsyncEffect } from 'ahooks' import { appDefaultIconBackground } from '@/config' import { searchEmoji } from '@/utils/emoji' +import type { AppIconType } from '@/types/app' + +type UseAppFaviconOptions = { + enable?: boolean + icon_type?: AppIconType + icon?: string + icon_background?: string + icon_url?: string +} + +export function useAppFavicon(options: UseAppFaviconOptions) { + const { + enable = true, + icon_type = 'emoji', + icon, + icon_background, + icon_url, + } = options -export function useAppFavicon(enable: boolean, icon?: string, icon_background?: string) { useAsyncEffect(async () => { if (!enable) return + + const isValidImageIcon = icon_type === 'image' && icon_url + const link: HTMLLinkElement = document.querySelector('link[rel*="icon"]') || document.createElement('link') - // eslint-disable-next-line prefer-template - link.href = 'data:image/svg+xml,' - + '' - + '' - + (icon ? await searchEmoji(icon) : '🤖') - + '' + link.href = isValidImageIcon + ? icon_url + : 'data:image/svg+xml,' + + `` + + `${ + icon ? await searchEmoji(icon) : '🤖' + }` + '' link.rel = 'shortcut icon' diff --git a/web/i18n/de-DE/app.ts b/web/i18n/de-DE/app.ts index 2101aa82609441..1ebc84d506f2ed 100644 --- a/web/i18n/de-DE/app.ts +++ b/web/i18n/de-DE/app.ts @@ -46,7 +46,7 @@ const translation = { editAppTitle: 'App-Informationen bearbeiten', editDone: 'App-Informationen wurden aktualisiert', editFailed: 'Aktualisierung der App-Informationen fehlgeschlagen', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Abbrechen', }, diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 1c70618a4fa6ce..39b47c8eb4e513 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -71,9 +71,11 @@ const translation = { editAppTitle: 'Edit App Info', editDone: 'App info updated', editFailed: 'Failed to update app info', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Cancel', + emoji: 'Emoji', + image: 'Image', }, switch: 'Switch to Workflow Orchestrate', switchTipStart: 'A new app copy will be created for you, and the new copy will switch to Workflow Orchestrate. The new copy will ', diff --git a/web/i18n/es-ES/app.ts b/web/i18n/es-ES/app.ts index 82359b40b0bd26..c3d7a8362b2142 100644 --- a/web/i18n/es-ES/app.ts +++ b/web/i18n/es-ES/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Editar información de la app', editDone: 'Información de la app actualizada', editFailed: 'Error al actualizar información de la app', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Cancelar', }, diff --git a/web/i18n/fa-IR/app.ts b/web/i18n/fa-IR/app.ts index 8322ff5a14a158..9f2563709e4e80 100644 --- a/web/i18n/fa-IR/app.ts +++ b/web/i18n/fa-IR/app.ts @@ -71,7 +71,7 @@ const translation = { editAppTitle: 'ویرایش اطلاعات برنامه', editDone: 'اطلاعات برنامه به‌روزرسانی شد', editFailed: 'به‌روزرسانی اطلاعات برنامه ناموفق بود', - emoji: { + iconPicker: { ok: 'باشه', cancel: 'لغو', }, diff --git a/web/i18n/fr-FR/app.ts b/web/i18n/fr-FR/app.ts index d89208c4245086..e3268ae1379be1 100644 --- a/web/i18n/fr-FR/app.ts +++ b/web/i18n/fr-FR/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Modifier les informations de l\'application', editDone: 'Informations sur l\'application mises à jour', editFailed: 'Échec de la mise à jour des informations de l\'application', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Annuler', }, diff --git a/web/i18n/hi-IN/app.ts b/web/i18n/hi-IN/app.ts index 29b0451b2cfa2a..e2b441233290d7 100644 --- a/web/i18n/hi-IN/app.ts +++ b/web/i18n/hi-IN/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'ऐप जानकारी संपादित करें', editDone: 'ऐप जानकारी अपडेट की गई', editFailed: 'ऐप जानकारी अपडेट करने में विफल', - emoji: { + iconPicker: { ok: 'ठीक है', cancel: 'रद्द करें', }, diff --git a/web/i18n/it-IT/app.ts b/web/i18n/it-IT/app.ts index c58149ef84a70d..3c23c3ea94d550 100644 --- a/web/i18n/it-IT/app.ts +++ b/web/i18n/it-IT/app.ts @@ -73,7 +73,7 @@ const translation = { editAppTitle: 'Modifica Info App', editDone: 'Info app aggiornata', editFailed: 'Aggiornamento delle info dell\'app fallito', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Annulla', }, diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index 2ca3109bac9f45..ece86a442d55fc 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -72,7 +72,7 @@ const translation = { editAppTitle: 'アプリ情報を編集する', editDone: 'アプリ情報が更新されました', editFailed: 'アプリ情報の更新に失敗しました', - emoji: { + iconPicker: { ok: 'OK', cancel: 'キャンセル', }, diff --git a/web/i18n/ko-KR/app.ts b/web/i18n/ko-KR/app.ts index 0e9a11556a012d..9fbc0095f5fe75 100644 --- a/web/i18n/ko-KR/app.ts +++ b/web/i18n/ko-KR/app.ts @@ -63,7 +63,7 @@ const translation = { editAppTitle: '앱 정보 편집하기', editDone: '앱 정보가 업데이트되었습니다', editFailed: '앱 정보 업데이트 실패', - emoji: { + iconPicker: { ok: '확인', cancel: '취소', }, diff --git a/web/i18n/pl-PL/app.ts b/web/i18n/pl-PL/app.ts index 3a54f3ec3ff797..cf2462d0124728 100644 --- a/web/i18n/pl-PL/app.ts +++ b/web/i18n/pl-PL/app.ts @@ -73,7 +73,7 @@ const translation = { editAppTitle: 'Edytuj informacje o aplikacji', editDone: 'Informacje o aplikacji zaktualizowane', editFailed: 'Nie udało się zaktualizować informacji o aplikacji', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Anuluj', }, diff --git a/web/i18n/pt-BR/app.ts b/web/i18n/pt-BR/app.ts index bd5282e760df8b..6d728bad47c1bf 100644 --- a/web/i18n/pt-BR/app.ts +++ b/web/i18n/pt-BR/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Editar Informações do Aplicativo', editDone: 'Informações do aplicativo atualizadas', editFailed: 'Falha ao atualizar informações do aplicativo', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Cancelar', }, diff --git a/web/i18n/ro-RO/app.ts b/web/i18n/ro-RO/app.ts index 01a158f4c71997..053dd47d841f32 100644 --- a/web/i18n/ro-RO/app.ts +++ b/web/i18n/ro-RO/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Editează Info Aplicație', editDone: 'Informațiile despre aplicație au fost actualizate', editFailed: 'Actualizarea informațiilor despre aplicație a eșuat', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Anulează', }, diff --git a/web/i18n/tr-TR/app.ts b/web/i18n/tr-TR/app.ts index 600b1faecb86ae..336d3567d14cce 100644 --- a/web/i18n/tr-TR/app.ts +++ b/web/i18n/tr-TR/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Uygulama Bilgilerini Düzenle', editDone: 'Uygulama bilgileri güncellendi', editFailed: 'Uygulama bilgileri güncellenemedi', - emoji: { + iconPicker: { ok: 'Tamam', cancel: 'İptal', }, diff --git a/web/i18n/uk-UA/app.ts b/web/i18n/uk-UA/app.ts index 3add9dfe8146c5..fe74f5a262a5a2 100644 --- a/web/i18n/uk-UA/app.ts +++ b/web/i18n/uk-UA/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Редагувати інформацію про додаток', editDone: 'Інформація про додаток оновлена', editFailed: 'Не вдалося оновити інформацію про додаток', - emoji: { + iconPicker: { ok: 'OK', cancel: 'Скасувати', }, diff --git a/web/i18n/vi-VN/app.ts b/web/i18n/vi-VN/app.ts index c8f3167d7c6fd0..86dd4fac6f0ba5 100644 --- a/web/i18n/vi-VN/app.ts +++ b/web/i18n/vi-VN/app.ts @@ -67,7 +67,7 @@ const translation = { editAppTitle: 'Chỉnh sửa thông tin ứng dụng', editDone: 'Thông tin ứng dụng đã được cập nhật', editFailed: 'Không thể cập nhật thông tin ứng dụng', - emoji: { + iconPicker: { ok: 'Đồng ý', cancel: 'Hủy', }, diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index 002284b91b3810..6703e1ca95a8ae 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -70,9 +70,11 @@ const translation = { editAppTitle: '编辑应用信息', editDone: '应用信息已更新', editFailed: '更新应用信息失败', - emoji: { + iconPicker: { ok: '确认', cancel: '取消', + emoji: '表情符号', + image: '图片', }, switch: '迁移为工作流编排', switchTipStart: '将为您创建一个使用工作流编排的新应用。新应用将', diff --git a/web/i18n/zh-Hant/app.ts b/web/i18n/zh-Hant/app.ts index 3c388065e75eda..41230936e27e24 100644 --- a/web/i18n/zh-Hant/app.ts +++ b/web/i18n/zh-Hant/app.ts @@ -66,7 +66,7 @@ const translation = { editAppTitle: '編輯應用資訊', editDone: '應用資訊已更新', editFailed: '更新應用資訊失敗', - emoji: { + iconPicker: { ok: '確認', cancel: '取消', }, diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 3b9b4442c9112a..5731ec7646424c 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,5 +1,5 @@ import type { DataSourceNotionPage } from './common' -import type { AppMode, RetrievalConfig } from '@/types/app' +import type { AppIconType, AppMode, RetrievalConfig } from '@/types/app' import type { Tag } from '@/app/components/base/tag-management/constant' export enum DataSourceType { @@ -425,8 +425,10 @@ export type RelatedApp = { id: string name: string mode: AppMode + icon_type: AppIconType | null icon: string icon_background: string + icon_url: string } export type RelatedAppResponse = { diff --git a/web/models/explore.ts b/web/models/explore.ts index 7a0f45dfe1adbc..78dd2e8675d2e0 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -1,9 +1,11 @@ -import type { AppMode } from '@/types/app' +import type { AppIconType, AppMode } from '@/types/app' export type AppBasicInfo = { id: string mode: AppMode + icon_type: AppIconType | null icon: string icon_background: string + icon_url: string name: string description: string } diff --git a/web/models/share.ts b/web/models/share.ts index 47a87e2fdb67e4..24b355a71b0a45 100644 --- a/web/models/share.ts +++ b/web/models/share.ts @@ -1,4 +1,5 @@ import type { Locale } from '@/i18n' +import type { AppIconType } from '@/types/app' export type ResponseHolder = {} @@ -13,8 +14,10 @@ export type SiteInfo = { title: string chat_color_theme?: string chat_color_theme_inverted?: boolean + icon_type?: AppIconType icon?: string icon_background?: string + icon_url?: string description?: string default_language?: Locale prompt_public?: boolean diff --git a/web/package.json b/web/package.json index 9b8e50885c22c4..f1809177d77406 100644 --- a/web/package.json +++ b/web/package.json @@ -68,6 +68,7 @@ "react": "~18.2.0", "react-18-input-autosize": "^3.0.0", "react-dom": "~18.2.0", + "react-easy-crop": "^5.0.8", "react-error-boundary": "^4.0.2", "react-headless-pagination": "^1.1.4", "react-hook-form": "^7.51.4", diff --git a/web/service/apps.ts b/web/service/apps.ts index 3c12de5c6fbcdf..7049af82cf5f79 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -2,7 +2,7 @@ import type { Fetcher } from 'swr' import { del, get, patch, post, put } from './base' import type { ApikeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app' import type { CommonResponse } from '@/models/common' -import type { AppMode, ModelConfig } from '@/types/app' +import type { AppIconType, AppMode, ModelConfig } from '@/types/app' import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' export const fetchAppList: Fetcher }> = ({ url, params }) => { @@ -17,32 +17,32 @@ export const fetchAppTemplates: Fetcher = return get(url) } -export const createApp: Fetcher = ({ name, icon, icon_background, mode, description, config }) => { - return post('apps', { body: { name, icon, icon_background, mode, description, model_config: config } }) +export const createApp: Fetcher = ({ name, icon_type, icon, icon_background, mode, description, config }) => { + return post('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } }) } -export const updateAppInfo: Fetcher = ({ appID, name, icon, icon_background, description }) => { - return put(`apps/${appID}`, { body: { name, icon, icon_background, description } }) +export const updateAppInfo: Fetcher = ({ appID, name, icon_type, icon, icon_background, description }) => { + return put(`apps/${appID}`, { body: { name, icon_type, icon, icon_background, description } }) } -export const copyApp: Fetcher = ({ appID, name, icon, icon_background, mode, description }) => { - return post(`apps/${appID}/copy`, { body: { name, icon, icon_background, mode, description } }) +export const copyApp: Fetcher = ({ appID, name, icon_type, icon, icon_background, mode, description }) => { + return post(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) } export const exportAppConfig: Fetcher<{ data: string }, { appID: string; include?: boolean }> = ({ appID, include = false }) => { return get<{ data: string }>(`apps/${appID}/export?include_secret=${include}`) } -export const importApp: Fetcher = ({ data, name, description, icon, icon_background }) => { - return post('apps/import', { body: { data, name, description, icon, icon_background } }) +export const importApp: Fetcher = ({ data, name, description, icon_type, icon, icon_background }) => { + return post('apps/import', { body: { data, name, description, icon_type, icon, icon_background } }) } export const importAppFromUrl: Fetcher = ({ url, name, description, icon, icon_background }) => { return post('apps/import/url', { body: { url, name, description, icon, icon_background } }) } -export const switchApp: Fetcher<{ new_app_id: string }, { appID: string; name: string; icon: string; icon_background: string }> = ({ appID, name, icon, icon_background }) => { - return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon, icon_background } }) +export const switchApp: Fetcher<{ new_app_id: string }, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null }> = ({ appID, name, icon_type, icon, icon_background }) => { + return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon_type, icon, icon_background } }) } export const deleteApp: Fetcher = (appID) => { diff --git a/web/types/app.ts b/web/types/app.ts index ebf20f2d686b22..ed3c24234d8c0e 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -291,12 +291,16 @@ export type SiteConfig = { /** Custom Disclaimer */ custom_disclaimer: string + icon_type: AppIconType | null icon: string - icon_background: string + icon_background: string | null + icon_url: string | null show_workflow_steps: boolean } +export type AppIconType = 'image' | 'emoji' + /** * App */ @@ -308,10 +312,17 @@ export type App = { /** Description */ description: string - /** Icon */ + /** + * Icon Type + * @default 'emoji' + */ + icon_type: AppIconType | null + /** Icon, stores file ID if icon_type is 'image' */ icon: string - /** Icon Background */ - icon_background: string + /** Icon Background, only available when icon_type is null or 'emoji' */ + icon_background: string | null + /** Icon URL, only available when icon_type is 'image' */ + icon_url: string | null /** Mode */ mode: AppMode diff --git a/web/yarn.lock b/web/yarn.lock index 16207b6ee07e18..16f672ae34110b 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -7015,6 +7015,11 @@ normalize-range@^0.1.2: resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== +normalize-wheel@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45" + integrity sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" @@ -7588,6 +7593,14 @@ react-dom@~18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-easy-crop@^5.0.8: + version "5.0.8" + resolved "https://registry.yarnpkg.com/react-easy-crop/-/react-easy-crop-5.0.8.tgz#6cf5be061c0ec6dc0c6ee7413974c34e35bf7475" + integrity sha512-KjulxXhR5iM7+ATN2sGCum/IyDxGw7xT0dFoGcqUP+ysaPU5Ka7gnrDa2tUHFHUoMNyPrVZ05QA+uvMgC5ym/g== + dependencies: + normalize-wheel "^1.0.1" + tslib "^2.0.1" + react-error-boundary@^3.1.4: version "3.1.4" resolved "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz" @@ -8363,16 +8376,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8440,14 +8444,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8760,6 +8757,11 @@ tslib@^1.8.1, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.1: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + tslib@^2.1.0, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0: version "2.5.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz" @@ -9216,7 +9218,7 @@ word-wrap@^1.2.3: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9234,15 +9236,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"