Przeglądaj źródła

feat: custom app icon (#7196)

Co-authored-by: crazywoola <427733928@qq.com>
Hash Brown 9 miesięcy temu
rodzic
commit
fbf31b5d52
65 zmienionych plików z 1068 dodań i 352 usunięć
  1. 4 0
      api/controllers/console/app/app.py
  2. 2 0
      api/controllers/console/app/site.py
  3. 1 0
      api/controllers/console/app/workflow.py
  4. 3 0
      api/controllers/web/site.py
  5. 1 0
      api/events/event_handlers/create_site_record_when_app_created.py
  6. 9 1
      api/fields/app_fields.py
  7. 3 1
      api/fields/installed_app_fields.py
  8. 13 0
      api/libs/helper.py
  9. 39 0
      api/migrations/versions/2024_08_15_1001-a6be81136580_app_and_site_icon_type.py
  10. 6 0
      api/models/model.py
  11. 13 2
      api/services/app_dsl_service.py
  12. 2 0
      api/services/app_service.py
  13. 3 0
      api/services/workflow/workflow_converter.py
  14. 1 0
      api/services/workflow_service.py
  15. 4 1
      web/.env.example
  16. 11 2
      web/app/(commonLayout)/apps/AppCard.tsx
  17. 1 1
      web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx
  18. 23 4
      web/app/components/app-sidebar/app-info.tsx
  19. 21 13
      web/app/components/app/create-app-modal/index.tsx
  20. 36 13
      web/app/components/app/duplicate-modal/index.tsx
  21. 35 20
      web/app/components/app/overview/settings/index.tsx
  22. 28 12
      web/app/components/app/switch-app-modal/index.tsx
  23. 97 0
      web/app/components/base/app-icon-picker/Uploader.tsx
  24. 43 0
      web/app/components/base/app-icon-picker/hooks.tsx
  25. 139 0
      web/app/components/base/app-icon-picker/index.tsx
  26. 12 0
      web/app/components/base/app-icon-picker/style.module.css
  27. 98 0
      web/app/components/base/app-icon-picker/utils.ts
  28. 28 18
      web/app/components/base/app-icon/index.tsx
  29. 1 1
      web/app/components/base/app-icon/style.module.css
  30. 9 1
      web/app/components/base/chat/chat-with-history/hooks.tsx
  31. 2 0
      web/app/components/base/chat/chat-with-history/sidebar/index.tsx
  32. 171 0
      web/app/components/base/emoji-picker/Inner.tsx
  33. 37 173
      web/app/components/base/emoji-picker/index.tsx
  34. 7 1
      web/app/components/explore/app-card/index.tsx
  35. 4 0
      web/app/components/explore/app-list/index.tsx
  36. 36 15
      web/app/components/explore/create-app-modal/index.tsx
  37. 7 2
      web/app/components/explore/sidebar/app-nav-item/index.tsx
  38. 3 1
      web/app/components/explore/sidebar/index.tsx
  39. 7 1
      web/app/components/share/text-generation/index.tsx
  40. 2 0
      web/config/index.ts
  41. 28 7
      web/hooks/use-app-favicon.ts
  42. 1 1
      web/i18n/de-DE/app.ts
  43. 3 1
      web/i18n/en-US/app.ts
  44. 1 1
      web/i18n/es-ES/app.ts
  45. 1 1
      web/i18n/fa-IR/app.ts
  46. 1 1
      web/i18n/fr-FR/app.ts
  47. 1 1
      web/i18n/hi-IN/app.ts
  48. 1 1
      web/i18n/it-IT/app.ts
  49. 1 1
      web/i18n/ja-JP/app.ts
  50. 1 1
      web/i18n/ko-KR/app.ts
  51. 1 1
      web/i18n/pl-PL/app.ts
  52. 1 1
      web/i18n/pt-BR/app.ts
  53. 1 1
      web/i18n/ro-RO/app.ts
  54. 1 1
      web/i18n/tr-TR/app.ts
  55. 1 1
      web/i18n/uk-UA/app.ts
  56. 1 1
      web/i18n/vi-VN/app.ts
  57. 3 1
      web/i18n/zh-Hans/app.ts
  58. 1 1
      web/i18n/zh-Hant/app.ts
  59. 3 1
      web/models/datasets.ts
  60. 3 1
      web/models/explore.ts
  61. 3 0
      web/models/share.ts
  62. 1 0
      web/package.json
  63. 11 11
      web/service/apps.ts
  64. 15 4
      web/types/app.ts
  65. 21 28
      web/yarn.lock

+ 4 - 0
api/controllers/console/app/app.py

@@ -61,6 +61,7 @@ class AppListApi(Resource):
         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 @@ class AppImportApi(Resource):
         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 @@ class AppApi(Resource):
         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 @@ class AppCopyApi(Resource):
         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()

+ 2 - 0
api/controllers/console/app/site.py

@@ -16,6 +16,7 @@ from models.model import Site
 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 @@ class AppSite(Resource):
 
         for attr_name in [
             'title',
+            'icon_type',
             'icon',
             'icon_background',
             'description',

+ 1 - 0
api/controllers/console/app/workflow.py

@@ -459,6 +459,7 @@ class ConvertToWorkflowApi(Resource):
         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()

+ 3 - 0
api/controllers/web/site.py

@@ -6,6 +6,7 @@ from configs import dify_config
 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,

+ 1 - 0
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,

+ 9 - 1
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 @@ app_partial_fields = {
     "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 @@ site_fields = {
     "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 @@ app_detail_fields_with_site = {
     "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),

+ 3 - 1
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 = {

+ 13 - 0
api/libs/helper.py

@@ -16,6 +16,7 @@ from flask import Response, current_app, stream_with_context
 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())

+ 39 - 0
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 ###

+ 6 - 0
api/models/model.py

@@ -51,6 +51,10 @@ class AppMode(Enum):
         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)

+ 13 - 2
api/services/app_dsl_service.py

@@ -82,6 +82,7 @@ class AppDslService:
         # 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 @@ class AppDslService:
                 account=account,
                 name=name,
                 description=description,
+                icon_type=icon_type,
                 icon=icon,
                 icon_background=icon_background
             )
@@ -107,6 +109,7 @@ class AppDslService:
                 account=account,
                 name=name,
                 description=description,
+                icon_type=icon_type,
                 icon=icon,
                 icon_background=icon_background
             )
@@ -165,8 +168,8 @@ class AppDslService:
             "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 @@ class AppDslService:
                                                   account: Account,
                                                   name: str,
                                                   description: str,
+                                                  icon_type: str,
                                                   icon: str,
                                                   icon_background: str) -> App:
         """
@@ -218,6 +222,7 @@ class AppDslService:
         :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 @@ class AppDslService:
             account=account,
             name=name,
             description=description,
+            icon_type=icon_type,
             icon=icon,
             icon_background=icon_background
         )
@@ -307,6 +313,7 @@ class AppDslService:
                                                       account: Account,
                                                       name: str,
                                                       description: str,
+                                                      icon_type: str,
                                                       icon: str,
                                                       icon_background: str) -> App:
         """
@@ -331,6 +338,7 @@ class AppDslService:
             account=account,
             name=name,
             description=description,
+            icon_type=icon_type,
             icon=icon,
             icon_background=icon_background
         )
@@ -358,6 +366,7 @@ class AppDslService:
                     account: Account,
                     name: str,
                     description: str,
+                    icon_type: str,
                     icon: str,
                     icon_background: str) -> App:
         """
@@ -368,6 +377,7 @@ class AppDslService:
         :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 @@ class AppDslService:
             mode=app_mode.value,
             name=name,
             description=description,
+            icon_type=icon_type,
             icon=icon,
             icon_background=icon_background,
             enable_site=True,

+ 2 - 0
api/services/app_service.py

@@ -119,6 +119,7 @@ class AppService:
         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 @@ class AppService:
         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)

+ 3 - 0
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 @@ class WorkflowConverter:
         :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 @@ class WorkflowConverter:
         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

+ 1 - 0
api/services/workflow_service.py

@@ -302,6 +302,7 @@ class WorkflowService:
             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'),
         )

+ 4 - 1
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
+NEXT_TELEMETRY_DISABLED=1
+
+# Disable Upload Image as WebApp icon default is false
+NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON=false

+ 11 - 2
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) => {
           <div className='relative shrink-0'>
             <AppIcon
               size="large"
+              iconType={app.icon_type}
               icon={app.icon}
               background={app.icon_background}
+              imageUrl={app.icon_url}
             />
             <span className='absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
               {app.mode === 'advanced-chat' && (
@@ -360,9 +365,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
       {showEditModal && (
         <EditAppModal
           isEditModal
+          appName={app.name}
+          appIconType={app.icon_type}
           appIcon={app.icon}
           appIconBackground={app.icon_background}
-          appName={app.name}
+          appIconUrl={app.icon_url}
           appDescription={app.description}
           show={showEditModal}
           onConfirm={onEdit}
@@ -372,8 +379,10 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
       {showDuplicateModal && (
         <DuplicateAppModal
           appName={app.name}
+          icon_type={app.icon_type}
           icon={app.icon}
           icon_background={app.icon_background}
+          icon_url={app.icon_url}
           show={showDuplicateModal}
           onConfirm={onCopy}
           onHide={() => setShowDuplicateModal(false)}

+ 1 - 1
web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx

@@ -60,7 +60,7 @@ const LikedItem = ({
   return (
     <Link className={classNames(s.itemWrapper, 'px-2', isMobile && 'justify-center')} href={`/app/${detail?.id}/overview`}>
       <div className={classNames(s.iconWrapper, 'mr-0')}>
-        <AppIcon size='tiny' icon={detail?.icon} background={detail?.icon_background} />
+        <AppIcon size='tiny' iconType={detail.icon_type} icon={detail.icon} background={detail.icon_background} imageUrl={detail.icon_url} />
         {type === 'app' && (
           <span className='absolute bottom-[-2px] right-[-2px] w-3.5 h-3.5 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
             {detail.mode === 'advanced-chat' && (

+ 23 - 4
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) => {
         >
           <div className={cn('flex p-1 rounded-lg', open && 'bg-gray-100', isCurrentWorkspaceEditor && 'hover:bg-gray-100 cursor-pointer')}>
             <div className='relative shrink-0 mr-2'>
-              <AppIcon size={expand ? 'large' : 'small'} icon={appDetail.icon} background={appDetail.icon_background} />
+              <AppIcon
+                size={expand ? 'large' : 'small'}
+                iconType={appDetail.icon_type}
+                icon={appDetail.icon}
+                background={appDetail.icon_background}
+                imageUrl={appDetail.icon_url}
+              />
               <span className={cn(
                 'absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm',
                 !expand && '!w-3.5 !h-3.5 !bottom-[-2px] !right-[-2px]',
@@ -257,7 +266,13 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
             {/* header */}
             <div className={cn('flex pl-4 pt-3 pr-3', !appDetail.description && 'pb-2')}>
               <div className='relative shrink-0 mr-2'>
-                <AppIcon size="large" icon={appDetail.icon} background={appDetail.icon_background} />
+                <AppIcon
+                  size="large"
+                  iconType={appDetail.icon_type}
+                  icon={appDetail.icon}
+                  background={appDetail.icon_background}
+                  imageUrl={appDetail.icon_url}
+                />
                 <span className='absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
                   {appDetail.mode === 'advanced-chat' && (
                     <ChatBot className='w-3 h-3 text-[#1570EF]' />
@@ -402,9 +417,11 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
         {showEditModal && (
           <CreateAppModal
             isEditModal
+            appName={appDetail.name}
+            appIconType={appDetail.icon_type}
             appIcon={appDetail.icon}
             appIconBackground={appDetail.icon_background}
-            appName={appDetail.name}
+            appIconUrl={appDetail.icon_url}
             appDescription={appDetail.description}
             show={showEditModal}
             onConfirm={onEdit}
@@ -414,8 +431,10 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
         {showDuplicateModal && (
           <DuplicateAppModal
             appName={appDetail.name}
+            icon_type={appDetail.icon_type}
             icon={appDetail.icon}
             icon_background={appDetail.icon_background}
+            icon_url={appDetail.icon_url}
             show={showDuplicateModal}
             onConfirm={onCopy}
             onHide={() => setShowDuplicateModal(false)}

+ 21 - 13
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<AppMode>('chat')
   const [showChatBotType, setShowChatBotType] = useState<boolean>(true)
-  const [emoji, setEmoji] = useState({ icon: '🤖', icon_background: '#FFEAD5' })
-  const [showEmojiPicker, setShowEmojiPicker] = useState(false)
+  const [appIcon, setAppIcon] = useState<AppIconSelection>({ 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 (
     <Modal
@@ -269,7 +271,14 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
       <div className='pt-2 px-8'>
         <div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.newApp.captionName')}</div>
         <div className='flex items-center justify-between space-x-2'>
-          <AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
+          <AppIcon
+            iconType={appIcon.type}
+            icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
+            background={appIcon.type === 'emoji' ? appIcon.background : undefined}
+            imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
+            size='large' className='cursor-pointer'
+            onClick={() => { setShowAppIconPicker(true) }}
+          />
           <input
             value={name}
             onChange={e => 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'
           />
         </div>
-        {showEmojiPicker && <EmojiPicker
-          onSelect={(icon, icon_background) => {
-            setEmoji({ icon, icon_background })
-            setShowEmojiPicker(false)
+        {showAppIconPicker && <AppIconPicker
+          onSelect={(payload) => {
+            setAppIcon(payload)
+            setShowAppIconPicker(false)
           }}
           onClose={() => {
-            setEmoji({ icon: '🤖', icon_background: '#FFEAD5' })
-            setShowEmojiPicker(false)
+            setShowAppIconPicker(false)
           }}
         />}
       </div>

+ 36 - 13
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<void>
   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 = ({
         <div className={s.content}>
           <div className={s.subTitle}>{t('explore.appCustomize.subTitle')}</div>
           <div className='flex items-center justify-between space-x-2'>
-            <AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
+            <AppIcon
+              size='large'
+              onClick={() => { 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}
+            />
             <input
               value={name}
               onChange={e => setName(e.target.value)}
@@ -79,14 +100,16 @@ const DuplicateAppModal = ({
           <Button className='w-24' onClick={onHide}>{t('common.operation.cancel')}</Button>
         </div>
       </Modal>
-      {showEmojiPicker && <EmojiPicker
-        onSelect={(icon, icon_background) => {
-          setEmoji({ icon, icon_background })
-          setShowEmojiPicker(false)
+      {showAppIconPicker && <AppIconPicker
+        onSelect={(payload) => {
+          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)
         }}
       />}
     </>

+ 35 - 20
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<ISettingsModalProps> = ({
 }) => {
   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<ISettingsModalProps> = ({
   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<AppIconSelection>(
+    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<ISettingsModalProps> = ({
       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<ISettingsModalProps> = ({
       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<ISettingsModalProps> = ({
         <div className={`mt-6 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.webName`)}</div>
         <div className='flex mt-2'>
           <AppIcon size='large'
-            onClick={() => { 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}
           />
           <input className={`flex-grow rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
             value={inputInfo.title}
@@ -250,14 +263,16 @@ const SettingsModal: FC<ISettingsModalProps> = ({
           <Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
           <Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
         </div>
-        {showEmojiPicker && <EmojiPicker
-          onSelect={(icon, icon_background) => {
-            setEmoji({ icon, icon_background })
-            setShowEmojiPicker(false)
+        {showAppIconPicker && <AppIconPicker
+          onSelect={(payload) => {
+            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)
           }}
         />}
       </Modal >

+ 28 - 12
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<boolean>(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
         <div className='pb-4'>
           <div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.switchLabel')}</div>
           <div className='flex items-center justify-between space-x-2'>
-            <AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
+            <AppIcon
+              size='large'
+              onClick={() => { 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}
+            />
             <input
               value={name}
               onChange={e => 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'
             />
           </div>
-          {showEmojiPicker && <EmojiPicker
-            onSelect={(icon, icon_background) => {
-              setEmoji({ icon, icon_background })
-              setShowEmojiPicker(false)
+          {showAppIconPicker && <AppIconPicker
+            onSelect={(payload) => {
+              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)
             }}
           />}
         </div>

+ 97 - 0
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<UploaderProps> = ({
+  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<HTMLInputElement>) => {
+    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<HTMLInputElement>()
+
+  return (
+    <div className={classNames(className, 'w-full px-3 py-1.5')}>
+      <div
+        className={classNames(
+          isDragActive && 'border-primary-600',
+          'relative aspect-square bg-gray-50 border-[1.5px] border-gray-200 border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
+        onDragEnter={handleDragEnter}
+        onDragOver={handleDragOver}
+        onDragLeave={handleDragLeave}
+        onDrop={handleDrop}
+      >
+        {
+          !inputImage
+            ? <>
+              <ImagePlus className="w-[30px] h-[30px] mb-3 pointer-events-none" />
+              <div className="text-sm font-medium mb-[2px]">
+                <span className="pointer-events-none">Drop your image here, or&nbsp;</span>
+                <button className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>browse</button>
+                <input
+                  ref={inputRef} type="file" className="hidden"
+                  onClick={e => ((e.target as HTMLInputElement).value = '')}
+                  accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
+                  onChange={handleLocalFileInput}
+                />
+              </div>
+              <div className="text-xs pointer-events-none">Supports PNG, JPG, JPEG, WEBP and GIF</div>
+            </>
+            : <Cropper
+              image={inputImage.url}
+              crop={crop}
+              zoom={zoom}
+              aspect={1}
+              onCropChange={setCrop}
+              onCropComplete={onCropComplete}
+              onZoomChange={setZoom}
+            />
+        }
+      </div>
+    </div>
+  )
+}
+
+export default Uploader

+ 43 - 0
web/app/components/base/app-icon-picker/hooks.tsx

@@ -0,0 +1,43 @@
+import { useCallback, useState } from 'react'
+
+export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
+  const [isDragActive, setIsDragActive] = useState(false)
+
+  const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragActive(true)
+  }, [])
+
+  const handleDragOver = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+  }, [])
+
+  const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragActive(false)
+  }, [])
+
+  const handleDrop = useCallback((e: React.DragEvent<T>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragActive(false)
+
+    const file = e.dataTransfer.files[0]
+
+    if (!file)
+      return
+
+    setImageFn(file)
+  }, [setImageFn])
+
+  return {
+    handleDragEnter,
+    handleDragOver,
+    handleDragLeave,
+    handleDrop,
+    isDragActive,
+  }
+}

+ 139 - 0
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<AppIconPickerProps> = ({
+  onSelect,
+  onClose,
+  className,
+}) => {
+  const { t } = useTranslation()
+
+  const tabs = [
+    { key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
+    { key: 'image', label: t('app.iconPicker.image'), icon: <ImagePlus /> },
+  ]
+  const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
+
+  const [emoji, setEmoji] = useState<{ emoji: string; background: string }>()
+  const handleSelectEmoji = useCallback((emoji: string, background: string) => {
+    setEmoji({ emoji, background })
+  }, [setEmoji])
+
+  const [uploading, setUploading] = useState<boolean>()
+
+  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 <Modal
+    onClose={() => { }}
+    isShow
+    closable={false}
+    wrapperClassName={className}
+    className={cn(s.container, '!w-[362px] !p-0')}
+  >
+    {!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="p-2 pb-0 w-full">
+      <div className='p-1 flex items-center justify-center gap-2 bg-background-body rounded-xl'>
+        {tabs.map(tab => (
+          <button
+            key={tab.key}
+            className={`
+                        p-2 flex-1 flex justify-center items-center h-8 rounded-xl text-sm shrink-0 font-medium
+                        ${activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active shadow-md'}
+                      `}
+            onClick={() => setActiveTab(tab.key as AppIconType)}
+          >
+            {tab.icon} &nbsp; {tab.label}
+          </button>
+        ))}
+      </div>
+    </div>}
+
+    <Divider className='m-0' />
+
+    <EmojiPickerInner className={activeTab === 'emoji' ? 'block' : 'hidden'} onSelect={handleSelectEmoji} />
+    <Uploader className={activeTab === 'image' ? 'block' : 'hidden'} onImageCropped={handleImageCropped} />
+
+    <Divider className='m-0' />
+    <div className='w-full flex items-center justify-center p-3 gap-2'>
+      <Button className='w-full' onClick={() => onClose?.()}>
+        {t('app.iconPicker.cancel')}
+      </Button>
+
+      <Button variant="primary" className='w-full' disabled={uploading} loading={uploading} onClick={handleSelect}>
+        {t('app.iconPicker.ok')}
+      </Button>
+    </div>
+  </Modal>
+}
+
+export default AppIconPicker

+ 12 - 0
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;
+}

+ 98 - 0
web/app/components/base/app-icon-picker/utils.ts

@@ -0,0 +1,98 @@
+export const createImage = (url: string) =>
+  new Promise<HTMLImageElement>((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<Blob> {
+  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')
+  })
+}

+ 28 - 18
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<AppIconProps> = ({
   size = 'medium',
   rounded = false,
+  iconType,
   icon,
   background,
+  imageUrl,
   className,
   innerIcon,
   onClick,
 }) => {
-  return (
-    <span
-      className={classNames(
-        style.appIcon,
-        size !== 'medium' && style[size],
-        rounded && style.rounded,
-        className ?? '',
-      )}
-      style={{
-        background,
-      }}
-      onClick={onClick}
-    >
-      {innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />)}
-    </span>
+  const wrapperClassName = classNames(
+    style.appIcon,
+    size !== 'medium' && style[size],
+    rounded && style.rounded,
+    className ?? '',
+    'overflow-hidden',
   )
+
+  const isValidImageIcon = iconType === 'image' && imageUrl
+
+  return <span
+    className={wrapperClassName}
+    style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
+    onClick={onClick}
+  >
+    {isValidImageIcon
+      ? <img src={imageUrl} className="w-full h-full" alt="app icon" />
+      : (innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />))
+    }
+  </span>
 }
 
 export default AppIcon

+ 1 - 1
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 {

+ 9 - 1
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,

+ 2 - 0
web/app/components/base/chat/chat-with-history/sidebar/index.tsx

@@ -67,8 +67,10 @@ const Sidebar = () => {
             <AppIcon
               className='mr-3'
               size='small'
+              iconType={appData?.site.icon_type}
               icon={appData?.site.icon}
               background={appData?.site.icon_background}
+              imageUrl={appData?.site.icon_url}
             />
             <div className='py-1 text-base font-semibold text-gray-800'>
               {appData?.site.title}

+ 171 - 0
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>, 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<IEmojiPickerInnerProps> = ({
+  onSelect,
+  className,
+}) => {
+  const { categories } = data as EmojiMartData
+  const [selectedEmoji, setSelectedEmoji] = useState('')
+  const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0])
+
+  const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
+  const [isSearching, setIsSearching] = useState(false)
+
+  React.useEffect(() => {
+    if (selectedEmoji && selectedBackground)
+      onSelect?.(selectedEmoji, selectedBackground)
+  }, [onSelect, selectedEmoji, selectedBackground])
+
+  return <div className={cn(className)}>
+    <div className='flex flex-col items-center w-full px-3'>
+      <div className="relative w-full">
+        <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
+          <MagnifyingGlassIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
+        </div>
+        <input
+          type="search"
+          id="search"
+          className='block w-full h-10 px-3 pl-10 text-sm font-normal bg-gray-100 rounded-lg'
+          placeholder="Search emojis..."
+          onChange={async (e: ChangeEvent<HTMLInputElement>) => {
+            if (e.target.value === '') {
+              setIsSearching(false)
+            }
+            else {
+              setIsSearching(true)
+              const emojis = await searchEmoji(e.target.value)
+              setSearchedEmojis(emojis)
+            }
+          }}
+        />
+      </div>
+    </div>
+    <Divider className='m-0 mb-3' />
+
+    <div className="w-full max-h-[200px] overflow-x-hidden overflow-y-auto px-3">
+      {isSearching && <>
+        <div key={'category-search'} className='flex flex-col'>
+          <p className='font-medium uppercase text-xs text-[#101828] mb-1'>Search</p>
+          <div className='w-full h-full grid grid-cols-8 gap-1'>
+            {searchedEmojis.map((emoji: string, index: number) => {
+              return <div
+                key={`emoji-search-${index}`}
+                className='inline-flex w-10 h-10 rounded-lg items-center justify-center'
+                onClick={() => {
+                  setSelectedEmoji(emoji)
+                }}
+              >
+                <div className='cursor-pointer w-8 h-8 p-1 flex items-center justify-center rounded-lg hover:ring-1 ring-offset-1 ring-gray-300'>
+                  <em-emoji id={emoji} />
+                </div>
+              </div>
+            })}
+          </div>
+        </div>
+      </>}
+
+      {categories.map((category, index: number) => {
+        return <div key={`category-${index}`} className='flex flex-col'>
+          <p className='font-medium uppercase text-xs text-[#101828] mb-1'>{category.id}</p>
+          <div className='w-full h-full grid grid-cols-8 gap-1'>
+            {category.emojis.map((emoji, index: number) => {
+              return <div
+                key={`emoji-${index}`}
+                className='inline-flex w-10 h-10 rounded-lg items-center justify-center'
+                onClick={() => {
+                  setSelectedEmoji(emoji)
+                }}
+              >
+                <div className='cursor-pointer w-8 h-8 p-1 flex items-center justify-center rounded-lg hover:ring-1 ring-offset-1 ring-gray-300'>
+                  <em-emoji id={emoji} />
+                </div>
+              </div>
+            })}
+
+          </div>
+        </div>
+      })}
+    </div>
+
+    {/* Color Select */}
+    <div className={cn('p-3 pb-0', selectedEmoji === '' ? 'opacity-25' : '')}>
+      <p className='font-medium uppercase text-xs text-[#101828] mb-2'>Choose Style</p>
+      <div className='w-full h-full grid grid-cols-8 gap-1'>
+        {backgroundColors.map((color) => {
+          return <div
+            key={color}
+            className={
+              cn(
+                'cursor-pointer',
+                'hover:ring-1 ring-offset-1',
+                'inline-flex w-10 h-10 rounded-lg items-center justify-center',
+                color === selectedBackground ? 'ring-1 ring-gray-300' : '',
+              )}
+            onClick={() => {
+              setSelectedBackground(color)
+            }}
+          >
+            <div className={cn(
+              'w-8 h-8 p-1 flex items-center justify-center rounded-lg',
+            )
+            } style={{ background: color }}>
+              {selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
+            </div>
+          </div>
+        })}
+      </div>
+    </div>
+  </div>
+}
+export default EmojiPickerInner

+ 37 - 173
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>,
-      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<IEmojiPickerProps> = ({
   className,
 }) => {
   const { t } = useTranslation()
-  const { categories } = data as EmojiMartData
   const [selectedEmoji, setSelectedEmoji] = useState('')
-  const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0])
+  const [selectedBackground, setSelectedBackground] = useState<string>()
 
-  const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
-  const [isSearching, setIsSearching] = useState(false)
+  const handleSelectEmoji = useCallback((emoji: string, background: string) => {
+    setSelectedEmoji(emoji)
+    setSelectedBackground(background)
+  }, [setSelectedEmoji, setSelectedBackground])
 
-  return isModal ? <Modal
-    onClose={() => { }}
-    isShow
-    closable={false}
-    wrapperClassName={className}
-    className={cn(s.container, '!w-[362px] !p-0')}
-  >
-    <div className='flex flex-col items-center w-full p-3'>
-      <div className="relative w-full">
-        <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
-          <MagnifyingGlassIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
-        </div>
-        <input
-          type="search"
-          id="search"
-          className='block w-full h-10 px-3 pl-10 text-sm font-normal bg-gray-100 rounded-lg'
-          placeholder="Search emojis..."
-          onChange={async (e: ChangeEvent<HTMLInputElement>) => {
-            if (e.target.value === '') {
-              setIsSearching(false)
-            }
-            else {
-              setIsSearching(true)
-              const emojis = await searchEmoji(e.target.value)
-              setSearchedEmojis(emojis)
-            }
-          }}
-        />
-      </div>
-    </div>
-    <Divider className='m-0 mb-3' />
-
-    <div className="w-full max-h-[200px] overflow-x-hidden overflow-y-auto px-3">
-      {isSearching && <>
-        <div key={'category-search'} className='flex flex-col'>
-          <p className='font-medium uppercase text-xs text-[#101828] mb-1'>Search</p>
-          <div className='w-full h-full grid grid-cols-8 gap-1'>
-            {searchedEmojis.map((emoji: string, index: number) => {
-              return <div
-                key={`emoji-search-${index}`}
-                className='inline-flex w-10 h-10 rounded-lg items-center justify-center'
-                onClick={() => {
-                  setSelectedEmoji(emoji)
-                }}
-              >
-                <div className='cursor-pointer w-8 h-8 p-1 flex items-center justify-center rounded-lg hover:ring-1 ring-offset-1 ring-gray-300'>
-                  <em-emoji id={emoji} />
-                </div>
-              </div>
-            })}
-          </div>
-        </div>
-      </>}
-
-      {categories.map((category, index: number) => {
-        return <div key={`category-${index}`} className='flex flex-col'>
-          <p className='font-medium uppercase text-xs text-[#101828] mb-1'>{category.id}</p>
-          <div className='w-full h-full grid grid-cols-8 gap-1'>
-            {category.emojis.map((emoji, index: number) => {
-              return <div
-                key={`emoji-${index}`}
-                className='inline-flex w-10 h-10 rounded-lg items-center justify-center'
-                onClick={() => {
-                  setSelectedEmoji(emoji)
-                }}
-              >
-                <div className='cursor-pointer w-8 h-8 p-1 flex items-center justify-center rounded-lg hover:ring-1 ring-offset-1 ring-gray-300'>
-                  <em-emoji id={emoji} />
-                </div>
-              </div>
-            })}
-
-          </div>
-        </div>
-      })}
-    </div>
-
-    {/* Color Select */}
-    <div className={cn('p-3 ', selectedEmoji === '' ? 'opacity-25' : '')}>
-      <p className='font-medium uppercase text-xs text-[#101828] mb-2'>Choose Style</p>
-      <div className='w-full h-full grid grid-cols-8 gap-1'>
-        {backgroundColors.map((color) => {
-          return <div
-            key={color}
-            className={
-              cn(
-                'cursor-pointer',
-                'hover:ring-1 ring-offset-1',
-                'inline-flex w-10 h-10 rounded-lg items-center justify-center',
-                color === selectedBackground ? 'ring-1 ring-gray-300' : '',
-              )}
-            onClick={() => {
-              setSelectedBackground(color)
-            }}
-          >
-            <div className={cn(
-              'w-8 h-8 p-1 flex items-center justify-center rounded-lg',
-            )
-            } style={{ background: color }}>
-              {selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
-            </div>
-          </div>
-        })}
-      </div>
-    </div>
-    <Divider className='m-0' />
-    <div className='w-full flex items-center justify-center p-3 gap-2'>
-      <Button className='w-full' onClick={() => {
-        onClose && onClose()
-      }}>
-        {t('app.emoji.cancel')}
-      </Button>
-      <Button
-        disabled={selectedEmoji === ''}
-        variant="primary"
-        className='w-full'
-        onClick={() => {
-          onSelect && onSelect(selectedEmoji, selectedBackground)
+  return isModal
+    ? <Modal
+      onClose={() => { }}
+      isShow
+      closable={false}
+      wrapperClassName={className}
+      className={cn(s.container, '!w-[362px] !p-0')}
+    >
+      <EmojiPickerInner
+        className="pt-3"
+        onSelect={handleSelectEmoji} />
+      <Divider className='m-0' />
+      <div className='w-full flex items-center justify-center p-3 gap-2'>
+        <Button className='w-full' onClick={() => {
+          onClose && onClose()
         }}>
-        {t('app.emoji.ok')}
-      </Button>
-    </div>
-  </Modal> : <>
-  </>
+          {t('app.iconPicker.cancel')}
+        </Button>
+        <Button
+          disabled={selectedEmoji === '' || !selectedBackground}
+          variant="primary"
+          className='w-full'
+          onClick={() => {
+            onSelect && onSelect(selectedEmoji, selectedBackground!)
+          }}>
+          {t('app.iconPicker.ok')}
+        </Button>
+      </div>
+    </Modal>
+    : <></>
 }
 export default EmojiPicker

+ 7 - 1
web/app/components/explore/app-card/index.tsx

@@ -26,7 +26,13 @@ const AppCard = ({
     <div className={cn('group flex col-span-1 bg-white border-2 border-solid border-transparent rounded-lg shadow-sm min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg')}>
       <div className='flex pt-[14px] px-[14px] pb-3 h-[66px] items-center gap-3 grow-0 shrink-0'>
         <div className='relative shrink-0'>
-          <AppIcon size='large' icon={app.app.icon} background={app.app.icon_background} />
+          <AppIcon
+            size='large'
+            iconType={app.app.icon_type}
+            icon={app.app.icon}
+            background={app.app.icon_background}
+            imageUrl={app.app.icon_url}
+          />
           <span className='absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
             {appBasicInfo.mode === 'advanced-chat' && (
               <ChatBot className='w-3 h-3 text-[#1570EF]' />

+ 4 - 0
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 = ({
       </div>
       {isShowCreateModal && (
         <CreateAppModal
+          appIconType={currApp?.app.icon_type || 'emoji'}
           appIcon={currApp?.app.icon || ''}
           appIconBackground={currApp?.app.icon_background || ''}
+          appIconUrl={currApp?.app.icon_url}
           appName={currApp?.app.name || ''}
           appDescription={currApp?.app.description || ''}
           show={isShowCreateModal}

+ 36 - 15
web/app/components/explore/create-app-modal/index.tsx

@@ -2,25 +2,29 @@
 import React, { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { RiCloseLine } from '@remixicon/react'
+import AppIconPicker from '../../base/app-icon-picker'
 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 CreateAppModalProps = {
   show: boolean
   isEditModal?: boolean
   appName: string
   appDescription: string
+  appIconType: AppIconType | null
   appIcon: string
-  appIconBackground: string
+  appIconBackground?: string | null
+  appIconUrl?: string | null
   onConfirm: (info: {
     name: string
+    icon_type: AppIconType
     icon: string
-    icon_background: string
+    icon_background?: string
     description: string
   }) => Promise<void>
   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 = ({
           <div className='pt-2'>
             <div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.newApp.captionName')}</div>
             <div className='flex items-center justify-between space-x-2'>
-              <AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
+              <AppIcon
+                size='large'
+                onClick={() => { 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}
+              />
               <input
                 value={name}
                 onChange={e => setName(e.target.value)}
@@ -106,18 +126,19 @@ const CreateAppModal = ({
           <Button className='w-24' onClick={onHide}>{t('common.operation.cancel')}</Button>
         </div>
       </Modal>
-      {showEmojiPicker && <EmojiPicker
-        onSelect={(icon, icon_background) => {
-          setEmoji({ icon, icon_background })
-          setShowEmojiPicker(false)
+      {showAppIconPicker && <AppIconPicker
+        onSelect={(payload) => {
+          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)
         }}
       />}
     </>
-
   )
 }
 

+ 7 - 2
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 && <AppIcon size='tiny' icon={icon} background={icon_background} />}
+      {isMobile && <AppIcon size='tiny' iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />}
       {!isMobile && (
         <>
           <div className='flex items-center space-x-2 w-0 grow'>
-            <AppIcon size='tiny' icon={icon} background={icon_background} />
+            <AppIcon size='tiny' iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
             <div className='overflow-hidden text-ellipsis whitespace-nowrap' title={name}>{name}</div>
           </div>
           <div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>

+ 3 - 1
web/app/components/explore/sidebar/index.tsx

@@ -109,14 +109,16 @@ const SideBar: FC<IExploreSideBarProps> = ({
               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 (
                 <Item
                   key={id}
                   isMobile={isMobile}
                   name={name}
+                  icon_type={icon_type}
                   icon={icon}
                   icon_background={icon_background}
+                  icon_url={icon_url}
                   id={id}
                   isSelected={lastSegment?.toLowerCase() === id}
                   isPinned={is_pinned}

+ 7 - 1
web/app/components/share/text-generation/index.tsx

@@ -411,7 +411,13 @@ const TextGeneration: FC<IMainProps> = ({
     }
   }, [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 = () => {

+ 2 - 0
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'

+ 28 - 7
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,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>'
-            + '<rect width=%22100%25%22 height=%22100%25%22 fill=%22' + encodeURIComponent(icon_background || appDefaultIconBackground) + '%22 rx=%2230%22 ry=%2230%22 />'
-            + '<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>'
-            + (icon ? await searchEmoji(icon) : '🤖')
-            + '</text>'
+    link.href = isValidImageIcon
+      ? icon_url
+      : 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>'
+            + `<rect width=%22100%25%22 height=%22100%25%22 fill=%22${encodeURIComponent(icon_background || appDefaultIconBackground)}%22 rx=%2230%22 ry=%2230%22 />`
+            + `<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${
+              icon ? await searchEmoji(icon) : '🤖'
+            }</text>`
             + '</svg>'
 
     link.rel = 'shortcut icon'

+ 1 - 1
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',
   },

+ 3 - 1
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 ',

+ 1 - 1
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',
   },

+ 1 - 1
web/i18n/fa-IR/app.ts

@@ -71,7 +71,7 @@ const translation = {
   editAppTitle: 'ویرایش اطلاعات برنامه',
   editDone: 'اطلاعات برنامه به‌روزرسانی شد',
   editFailed: 'به‌روزرسانی اطلاعات برنامه ناموفق بود',
-  emoji: {
+  iconPicker: {
     ok: 'باشه',
     cancel: 'لغو',
   },

+ 1 - 1
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',
   },

+ 1 - 1
web/i18n/hi-IN/app.ts

@@ -67,7 +67,7 @@ const translation = {
   editAppTitle: 'ऐप जानकारी संपादित करें',
   editDone: 'ऐप जानकारी अपडेट की गई',
   editFailed: 'ऐप जानकारी अपडेट करने में विफल',
-  emoji: {
+  iconPicker: {
     ok: 'ठीक है',
     cancel: 'रद्द करें',
   },

+ 1 - 1
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',
   },

+ 1 - 1
web/i18n/ja-JP/app.ts

@@ -72,7 +72,7 @@ const translation = {
   editAppTitle: 'アプリ情報を編集する',
   editDone: 'アプリ情報が更新されました',
   editFailed: 'アプリ情報の更新に失敗しました',
-  emoji: {
+  iconPicker: {
     ok: 'OK',
     cancel: 'キャンセル',
   },

+ 1 - 1
web/i18n/ko-KR/app.ts

@@ -63,7 +63,7 @@ const translation = {
   editAppTitle: '앱 정보 편집하기',
   editDone: '앱 정보가 업데이트되었습니다',
   editFailed: '앱 정보 업데이트 실패',
-  emoji: {
+  iconPicker: {
     ok: '확인',
     cancel: '취소',
   },

+ 1 - 1
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',
   },

+ 1 - 1
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',
   },

+ 1 - 1
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ă',
   },

+ 1 - 1
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',
   },

+ 1 - 1
web/i18n/uk-UA/app.ts

@@ -67,7 +67,7 @@ const translation = {
   editAppTitle: 'Редагувати інформацію про додаток',
   editDone: 'Інформація про додаток оновлена',
   editFailed: 'Не вдалося оновити інформацію про додаток',
-  emoji: {
+  iconPicker: {
     ok: 'OK',
     cancel: 'Скасувати',
   },

+ 1 - 1
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',
   },

+ 3 - 1
web/i18n/zh-Hans/app.ts

@@ -70,9 +70,11 @@ const translation = {
   editAppTitle: '编辑应用信息',
   editDone: '应用信息已更新',
   editFailed: '更新应用信息失败',
-  emoji: {
+  iconPicker: {
     ok: '确认',
     cancel: '取消',
+    emoji: '表情符号',
+    image: '图片',
   },
   switch: '迁移为工作流编排',
   switchTipStart: '将为您创建一个使用工作流编排的新应用。新应用将',

+ 1 - 1
web/i18n/zh-Hant/app.ts

@@ -66,7 +66,7 @@ const translation = {
   editAppTitle: '編輯應用資訊',
   editDone: '應用資訊已更新',
   editFailed: '更新應用資訊失敗',
-  emoji: {
+  iconPicker: {
     ok: '確認',
     cancel: '取消',
   },

+ 3 - 1
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 = {

+ 3 - 1
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
 }

+ 3 - 0
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

+ 1 - 0
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",

+ 11 - 11
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<AppListResponse, { url: string; params?: Record<string, any> }> = ({ url, params }) => {
@@ -17,32 +17,32 @@ export const fetchAppTemplates: Fetcher<AppTemplatesResponse, { url: string }> =
   return get<AppTemplatesResponse>(url)
 }
 
-export const createApp: Fetcher<AppDetailResponse, { name: string; icon: string; icon_background: string; mode: AppMode; description?: string; config?: ModelConfig }> = ({ name, icon, icon_background, mode, description, config }) => {
-  return post<AppDetailResponse>('apps', { body: { name, icon, icon_background, mode, description, model_config: config } })
+export const createApp: Fetcher<AppDetailResponse, { name: string; icon_type?: AppIconType; icon?: string; icon_background?: string; mode: AppMode; description?: string; config?: ModelConfig }> = ({ name, icon_type, icon, icon_background, mode, description, config }) => {
+  return post<AppDetailResponse>('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } })
 }
 
-export const updateAppInfo: Fetcher<AppDetailResponse, { appID: string; name: string; icon: string; icon_background: string; description: string }> = ({ appID, name, icon, icon_background, description }) => {
-  return put<AppDetailResponse>(`apps/${appID}`, { body: { name, icon, icon_background, description } })
+export const updateAppInfo: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string; description: string }> = ({ appID, name, icon_type, icon, icon_background, description }) => {
+  return put<AppDetailResponse>(`apps/${appID}`, { body: { name, icon_type, icon, icon_background, description } })
 }
 
-export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon: string; icon_background: string; mode: AppMode; description?: string }> = ({ appID, name, icon, icon_background, mode, description }) => {
-  return post<AppDetailResponse>(`apps/${appID}/copy`, { body: { name, icon, icon_background, mode, description } })
+export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null; mode: AppMode; description?: string }> = ({ appID, name, icon_type, icon, icon_background, mode, description }) => {
+  return post<AppDetailResponse>(`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<AppDetailResponse, { data: string; name?: string; description?: string; icon?: string; icon_background?: string }> = ({ data, name, description, icon, icon_background }) => {
-  return post<AppDetailResponse>('apps/import', { body: { data, name, description, icon, icon_background } })
+export const importApp: Fetcher<AppDetailResponse, { data: string; name?: string; description?: string; icon_type?: AppIconType; icon?: string; icon_background?: string }> = ({ data, name, description, icon_type, icon, icon_background }) => {
+  return post<AppDetailResponse>('apps/import', { body: { data, name, description, icon_type, icon, icon_background } })
 }
 
 export const importAppFromUrl: Fetcher<AppDetailResponse, { url: string; name?: string; description?: string; icon?: string; icon_background?: string }> = ({ url, name, description, icon, icon_background }) => {
   return post<AppDetailResponse>('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<CommonResponse, string> = (appID) => {

+ 15 - 4
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

+ 21 - 28
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"