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