Pārlūkot izejas kodu

feat: custom webapp logo (#1766)

zxhlyh 1 gadu atpakaļ
vecāks
revīzija
5bb841935e
40 mainītis faili ar 888 papildinājumiem un 24 dzēšanām
  1. 62 1
      api/controllers/console/workspace/workspace.py
  2. 2 0
      api/controllers/console/wraps.py
  3. 23 0
      api/controllers/files/image_preview.py
  4. 11 0
      api/controllers/web/site.py
  5. 1 1
      api/core/file/upload_file_parser.py
  6. 32 0
      api/migrations/versions/88072f0caa04_add_custom_config_in_tenant.py
  7. 11 0
      api/models/account.py
  8. 6 0
      api/services/account_service.py
  9. 20 2
      api/services/file_service.py
  10. 9 1
      api/services/workspace_service.py
  11. 5 0
      web/app/components/base/icons/assets/vender/line/editor/colors.svg
  12. 5 0
      web/app/components/base/icons/assets/vender/solid/communication/message-dots-circle.svg
  13. 9 0
      web/app/components/base/icons/assets/vender/solid/editor/colors.svg
  14. 39 0
      web/app/components/base/icons/src/vender/line/editor/Colors.json
  15. 16 0
      web/app/components/base/icons/src/vender/line/editor/Colors.tsx
  16. 1 0
      web/app/components/base/icons/src/vender/line/editor/index.ts
  17. 38 0
      web/app/components/base/icons/src/vender/solid/communication/MessageDotsCircle.json
  18. 16 0
      web/app/components/base/icons/src/vender/solid/communication/MessageDotsCircle.tsx
  19. 1 0
      web/app/components/base/icons/src/vender/solid/communication/index.ts
  20. 62 0
      web/app/components/base/icons/src/vender/solid/editor/Colors.json
  21. 16 0
      web/app/components/base/icons/src/vender/solid/editor/Colors.tsx
  22. 1 0
      web/app/components/base/icons/src/vender/solid/editor/index.ts
  23. 3 3
      web/app/components/base/image-uploader/utils.ts
  24. 70 0
      web/app/components/custom/custom-app-header-brand/index.tsx
  25. 3 0
      web/app/components/custom/custom-app-header-brand/style.module.css
  26. 52 0
      web/app/components/custom/custom-page/index.tsx
  27. 234 0
      web/app/components/custom/custom-web-app-brand/index.tsx
  28. 3 0
      web/app/components/custom/custom-web-app-brand/style.module.css
  29. 6 0
      web/app/components/custom/style.module.css
  30. 11 0
      web/app/components/header/account-setting/index.tsx
  31. 4 1
      web/app/components/share/chat/index.tsx
  32. 19 6
      web/app/components/share/chat/welcome/index.tsx
  33. 4 1
      web/app/components/share/chatbot/index.tsx
  34. 19 6
      web/app/components/share/chatbot/welcome/index.tsx
  35. 4 0
      web/i18n/i18next-config.ts
  36. 30 0
      web/i18n/lang/custom.en.ts
  37. 30 0
      web/i18n/lang/custom.zh.ts
  38. 4 0
      web/models/common.ts
  39. 2 2
      web/service/base.ts
  40. 4 0
      web/service/common.ts

+ 62 - 1
api/controllers/console/workspace/workspace.py

@@ -10,12 +10,15 @@ from controllers.console import api
 from controllers.console.admin import admin_required
 from controllers.console.setup import setup_required
 from controllers.console.error import AccountNotLinkTenantError
-from controllers.console.wraps import account_initialization_required
+from controllers.console.wraps import account_initialization_required, cloud_edition_billing_resource_check
+from controllers.console.datasets.error import NoFileUploadedError, TooManyFilesError, FileTooLargeError, UnsupportedFileTypeError
 from libs.helper import TimestampField
 from extensions.ext_database import db
 from models.account import Tenant
+import services
 from services.account_service import TenantService
 from services.workspace_service import WorkspaceService
+from services.file_service import FileService
 
 provider_fields = {
     'provider_name': fields.String,
@@ -34,6 +37,7 @@ tenant_fields = {
     'providers': fields.List(fields.Nested(provider_fields)),
     'in_trial': fields.Boolean,
     'trial_end_reason': fields.String,
+    'custom_config': fields.Raw(attribute='custom_config'),
 }
 
 tenants_fields = {
@@ -130,6 +134,61 @@ class SwitchWorkspaceApi(Resource):
         new_tenant = db.session.query(Tenant).get(args['tenant_id'])  # Get new tenant
 
         return {'result': 'success', 'new_tenant': marshal(WorkspaceService.get_tenant_info(new_tenant), tenant_fields)}
+    
+
+class CustomConfigWorkspaceApi(Resource):
+    @setup_required
+    @login_required
+    @account_initialization_required
+    @cloud_edition_billing_resource_check('workspace_custom')
+    def post(self):
+        parser = reqparse.RequestParser()
+        parser.add_argument('remove_webapp_brand', type=bool, location='json')
+        parser.add_argument('replace_webapp_logo', type=str,  location='json')
+        args = parser.parse_args()
+
+        custom_config_dict = {
+            'remove_webapp_brand': args['remove_webapp_brand'],
+            'replace_webapp_logo': args['replace_webapp_logo'],
+        }
+
+        tenant = db.session.query(Tenant).filter(Tenant.id == current_user.current_tenant_id).one_or_404()
+
+        tenant.custom_config_dict = custom_config_dict
+        db.session.commit()
+
+        return {'result': 'success', 'tenant': marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
+    
+
+class WebappLogoWorkspaceApi(Resource):
+    @setup_required
+    @login_required
+    @account_initialization_required
+    @cloud_edition_billing_resource_check('workspace_custom')
+    def post(self):
+        # get file from request
+        file = request.files['file']
+
+        # check file
+        if 'file' not in request.files:
+            raise NoFileUploadedError()
+
+        if len(request.files) > 1:
+            raise TooManyFilesError()
+
+        extension = file.filename.split('.')[-1]
+        if extension.lower() not in ['svg', 'png']:
+            raise UnsupportedFileTypeError()
+
+        try:
+            upload_file = FileService.upload_file(file, current_user, True)
+
+        except services.errors.file.FileTooLargeError as file_too_large_error:
+            raise FileTooLargeError(file_too_large_error.description)
+        except services.errors.file.UnsupportedFileTypeError:
+            raise UnsupportedFileTypeError()
+        
+        return { 'id': upload_file.id }, 201
 
 
 api.add_resource(TenantListApi, '/workspaces')  # GET for getting all tenants
@@ -137,3 +196,5 @@ api.add_resource(WorkspaceListApi, '/all-workspaces')  # GET for getting all ten
 api.add_resource(TenantApi, '/workspaces/current', endpoint='workspaces_current')  # GET for getting current tenant info
 api.add_resource(TenantApi, '/info', endpoint='info')  # Deprecated
 api.add_resource(SwitchWorkspaceApi, '/workspaces/switch')  # POST for switching tenant
+api.add_resource(CustomConfigWorkspaceApi, '/workspaces/custom-config')
+api.add_resource(WebappLogoWorkspaceApi, '/workspaces/custom-config/webapp-logo/upload')

+ 2 - 0
api/controllers/console/wraps.py

@@ -63,6 +63,8 @@ def cloud_edition_billing_resource_check(resource: str,
                     abort(403, error_msg)
                 elif resource == 'vector_space' and 0 < vector_space['limit'] <= vector_space['size']:
                     abort(403, error_msg)
+                elif resource == 'workspace_custom' and not billing_info['can_replace_logo']:
+                    abort(403, error_msg)
                 elif resource == 'annotation' and 0 < annotation_quota_limit['limit'] <= annotation_quota_limit['size']:
                     abort(403, error_msg)
                 else:

+ 23 - 0
api/controllers/files/image_preview.py

@@ -1,10 +1,12 @@
 from flask import request, Response
 from flask_restful import Resource
+from werkzeug.exceptions import NotFound
 
 import services
 from controllers.files import api
 from libs.exception import BaseHTTPException
 from services.file_service import FileService
+from services.account_service import TenantService
 
 
 class ImagePreviewApi(Resource):
@@ -29,9 +31,30 @@ class ImagePreviewApi(Resource):
             raise UnsupportedFileTypeError()
 
         return Response(generator, mimetype=mimetype)
+    
+
+class WorkspaceWebappLogoApi(Resource):
+    def get(self, workspace_id):
+        workspace_id = str(workspace_id)
+
+        custom_config = TenantService.get_custom_config(workspace_id)
+        webapp_logo_file_id = custom_config.get('replace_webapp_logo') if custom_config is not None else None
+
+        if not webapp_logo_file_id:
+            raise NotFound(f'webapp logo is not found')
+
+        try:
+            generator, mimetype = FileService.get_public_image_preview(
+                webapp_logo_file_id,
+            )
+        except services.errors.file.UnsupportedFileTypeError:
+            raise UnsupportedFileTypeError()
+
+        return Response(generator, mimetype=mimetype)
 
 
 api.add_resource(ImagePreviewApi, '/files/<uuid:file_id>/image-preview')
+api.add_resource(WorkspaceWebappLogoApi, '/files/workspaces/<uuid:workspace_id>/webapp-logo')
 
 
 class UnsupportedFileTypeError(BaseHTTPException):

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

@@ -2,6 +2,7 @@
 import os
 
 from flask_restful import fields, marshal_with
+from flask import current_app
 from werkzeug.exceptions import Forbidden
 
 from controllers.web import api
@@ -43,6 +44,7 @@ class AppSiteApi(WebApiResource):
         'model_config': fields.Nested(model_config_fields, allow_null=True),
         'plan': fields.String,
         'can_replace_logo': fields.Boolean,
+        'custom_config': fields.Raw(attribute='custom_config'),
     }
 
     @marshal_with(app_fields)
@@ -80,6 +82,15 @@ class AppSiteInfo:
         self.plan = tenant.plan
         self.can_replace_logo = can_replace_logo
 
+        if can_replace_logo:
+            base_url = current_app.config.get('FILES_URL')
+            remove_webapp_brand = tenant.custom_config_dict.get('remove_webapp_brand', False)
+            replace_webapp_logo = f'{base_url}/files/workspaces/{tenant.id}/webapp-logo' if tenant.custom_config_dict['replace_webapp_logo'] else None
+            self.custom_config = {
+                'remove_webapp_brand': remove_webapp_brand,
+                'replace_webapp_logo': replace_webapp_logo,
+            }
+
         if app.enable_site and site.prompt_public:
             app_model_config = app.app_model_config
             self.model_config = app_model_config

+ 1 - 1
api/core/file/upload_file_parser.py

@@ -10,7 +10,7 @@ from flask import current_app
 
 from extensions.ext_storage import storage
 
-SUPPORT_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif']
+SUPPORT_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg']
 
 
 class UploadFileParser:

+ 32 - 0
api/migrations/versions/88072f0caa04_add_custom_config_in_tenant.py

@@ -0,0 +1,32 @@
+"""add custom config in tenant
+
+Revision ID: 88072f0caa04
+Revises: fca025d3b60f
+Create Date: 2023-12-14 07:36:50.705362
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '88072f0caa04'
+down_revision = '246ba09cbbdb'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('tenants', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('custom_config', sa.Text(), nullable=True))
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('tenants', schema=None) as batch_op:
+        batch_op.drop_column('custom_config')
+
+    # ### end Alembic commands ###

+ 11 - 0
api/models/account.py

@@ -1,4 +1,6 @@
+import json
 import enum
+from math import e
 from typing import List
 
 from flask_login import UserMixin
@@ -112,6 +114,7 @@ class Tenant(db.Model):
     encrypt_public_key = db.Column(db.Text)
     plan = db.Column(db.String(255), nullable=False, server_default=db.text("'basic'::character varying"))
     status = db.Column(db.String(255), nullable=False, server_default=db.text("'normal'::character varying"))
+    custom_config = db.Column(db.Text)
     created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))
     updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))
 
@@ -121,6 +124,14 @@ class Tenant(db.Model):
             Account.id == TenantAccountJoin.account_id,
             TenantAccountJoin.tenant_id == self.id
         ).all()
+    
+    @property
+    def custom_config_dict(self) -> dict:
+        return json.loads(self.custom_config) if self.custom_config else None
+    
+    @custom_config_dict.setter
+    def custom_config_dict(self, value: dict):
+        self.custom_config = json.dumps(value)
 
 
 class TenantAccountJoinRole(enum.Enum):

+ 6 - 0
api/services/account_service.py

@@ -412,6 +412,12 @@ class TenantService:
         db.session.delete(tenant)
         db.session.commit()
 
+    @staticmethod
+    def get_custom_config(tenant_id: str) -> None:
+        tenant = db.session.query(Tenant).filter(Tenant.id == tenant_id).one_or_404()
+
+        return tenant.custom_config_dict
+
 
 class RegisterService:
 

+ 20 - 2
api/services/file_service.py

@@ -17,8 +17,8 @@ from models.model import UploadFile, EndUser
 from services.errors.file import FileTooLargeError, UnsupportedFileTypeError
 
 ALLOWED_EXTENSIONS = ['txt', 'markdown', 'md', 'pdf', 'html', 'htm', 'xlsx', 'docx', 'csv',
-                      'jpg', 'jpeg', 'png', 'webp', 'gif']
-IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif']
+                      'jpg', 'jpeg', 'png', 'webp', 'gif', 'svg']
+IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg']
 PREVIEW_WORDS_LIMIT = 3000
 
 
@@ -154,3 +154,21 @@ class FileService:
         generator = storage.load(upload_file.key, stream=True)
 
         return generator, upload_file.mime_type
+    
+    @staticmethod
+    def get_public_image_preview(file_id: str) -> str:
+        upload_file = db.session.query(UploadFile) \
+            .filter(UploadFile.id == file_id) \
+            .first()
+
+        if not upload_file:
+            raise NotFound("File not found or signature is invalid")
+
+        # extract text from file
+        extension = upload_file.extension
+        if extension.lower() not in IMAGE_EXTENSIONS:
+            raise UnsupportedFileTypeError()
+
+        generator = storage.load(upload_file.key)
+
+        return generator, upload_file.mime_type

+ 9 - 1
api/services/workspace_service.py

@@ -1,8 +1,11 @@
 from flask_login import current_user
 from extensions.ext_database import db
-from models.account import Tenant, TenantAccountJoin
+from models.account import Tenant, TenantAccountJoin, TenantAccountJoinRole
 from models.provider import Provider
 
+from services.billing_service import BillingService
+from services.account_service import TenantService
+
 
 class WorkspaceService:
     @classmethod
@@ -28,6 +31,11 @@ class WorkspaceService:
         ).first()
         tenant_info['role'] = tenant_account_join.role
 
+        billing_info = BillingService.get_info(tenant_info['id'])
+
+        if billing_info['can_replace_logo'] and TenantService.has_roles(tenant, [TenantAccountJoinRole.OWNER, TenantAccountJoinRole.ADMIN]):
+            tenant_info['custom_config'] = tenant.custom_config_dict
+
         # Get providers
         providers = db.session.query(Provider).filter(
             Provider.tenant_id == tenant.id

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/colors.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="colors">
+<path id="Icon" d="M12 20.4722C13.0615 21.4223 14.4633 22 16 22C19.3137 22 22 19.3137 22 16C22 13.2331 20.1271 10.9036 17.5798 10.2102M6.42018 10.2102C3.87293 10.9036 2 13.2331 2 16C2 19.3137 4.68629 22 8 22C11.3137 22 14 19.3137 14 16C14 15.2195 13.851 14.4738 13.5798 13.7898M18 8C18 11.3137 15.3137 14 12 14C8.68629 14 6 11.3137 6 8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/solid/communication/message-dots-circle.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="message-dots-circle">
+<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.47715 2 2 6.47715 2 12C2 13.3283 2.25952 14.5985 2.73156 15.7608C2.77419 15.8658 2.79872 15.9264 2.81552 15.9711L2.82063 15.9849L2.82 15.9897C2.815 16.0266 2.80672 16.0769 2.79071 16.173L2.19294 19.7596C2.16612 19.9202 2.13611 20.0999 2.12433 20.256C2.11148 20.4261 2.10701 20.6969 2.22973 20.983C2.38144 21.3367 2.6633 21.6186 3.017 21.7703C3.30312 21.893 3.57386 21.8885 3.74404 21.8757C3.90013 21.8639 4.07985 21.8339 4.24049 21.8071L7.82705 21.2093C7.92309 21.1933 7.97339 21.185 8.0103 21.18L8.01505 21.1794L8.02887 21.1845C8.07362 21.2013 8.13423 21.2258 8.23921 21.2684C9.4015 21.7405 10.6717 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM6 12C6 11.1716 6.67157 10.5 7.5 10.5C8.32843 10.5 9 11.1716 9 12C9 12.8284 8.32843 13.5 7.5 13.5C6.67157 13.5 6 12.8284 6 12ZM10.5 12C10.5 11.1716 11.1716 10.5 12 10.5C12.8284 10.5 13.5 11.1716 13.5 12C13.5 12.8284 12.8284 13.5 12 13.5C11.1716 13.5 10.5 12.8284 10.5 12ZM16.5 10.5C15.6716 10.5 15 11.1716 15 12C15 12.8284 15.6716 13.5 16.5 13.5C17.3284 13.5 18 12.8284 18 12C18 11.1716 17.3284 10.5 16.5 10.5Z" fill="black"/>
+</g>
+</svg>

+ 9 - 0
web/app/components/base/icons/assets/vender/solid/editor/colors.svg

@@ -0,0 +1,9 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="colors">
+<g id="Solid">
+<path d="M13.4494 13.2298C12.9854 13.3409 12.5002 13.3999 12 13.3999C10.2804 13.3999 8.72326 12.6997 7.59953 11.5677C6.4872 10.4471 5.8 8.90382 5.8 7.20007C5.8 3.77586 8.57584 1 12 1C15.4241 1 18.2 3.77586 18.2 7.20007C18.2 8.44569 17.8327 9.60551 17.2005 10.5771C16.3665 11.8588 15.0715 12.8131 13.5506 13.2047C13.517 13.2133 13.4833 13.2217 13.4494 13.2298Z" fill="black"/>
+<path d="M15.1476 14.7743C16.6646 14.1431 17.9513 13.0695 18.8465 11.7146C19.0004 11.4817 19.0773 11.3652 19.1762 11.3066C19.2615 11.2561 19.3659 11.2312 19.4648 11.2379C19.5795 11.2457 19.6773 11.3015 19.8728 11.4133C21.7413 12.4817 23 14.4946 23 16.7999C23 20.2241 20.2242 23 16.8 23C15.9123 23 15.0689 22.8139 14.3059 22.4782C14.0549 22.3678 13.9294 22.3126 13.8502 22.2049C13.7822 22.1126 13.7468 21.9922 13.7539 21.8777C13.7622 21.7444 13.8565 21.6018 14.045 21.3167C14.8373 20.1184 15.3234 18.6997 15.3917 17.1723C15.3969 17.0566 15.3996 16.9402 15.4 16.8233L15.4 16.7999C15.4 16.1888 15.333 15.5926 15.2057 15.0185C15.1876 14.9366 15.1682 14.8552 15.1476 14.7743Z" fill="black"/>
+<path d="M4.12723 11.4133C4.32273 11.3015 4.42049 11.2457 4.53516 11.2379C4.63414 11.2312 4.73848 11.2561 4.82382 11.3066C4.92269 11.3652 4.99964 11.4817 5.15355 11.7146C6.62074 13.9352 9.13929 15.4001 12 15.4001C12.4146 15.4001 12.822 15.3694 13.2201 15.31L13.2263 15.3357C13.3398 15.8045 13.4 16.2947 13.4 16.7999L13.4 16.8214C13.3997 16.9056 13.3977 16.9895 13.3941 17.0728C13.2513 20.3704 10.5327 23 7.2 23C3.77584 23 1 20.2241 1 16.7999C1 14.4946 2.25869 12.4817 4.12723 11.4133Z" fill="black"/>
+</g>
+</g>
+</svg>

+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/Colors.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "colors"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon",
+							"d": "M12 20.4722C13.0615 21.4223 14.4633 22 16 22C19.3137 22 22 19.3137 22 16C22 13.2331 20.1271 10.9036 17.5798 10.2102M6.42018 10.2102C3.87293 10.9036 2 13.2331 2 16C2 19.3137 4.68629 22 8 22C11.3137 22 14 19.3137 14 16C14 15.2195 13.851 14.4738 13.5798 13.7898M18 8C18 11.3137 15.3137 14 12 14C8.68629 14 6 11.3137 6 8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8Z",
+							"stroke": "currentColor",
+							"stroke-width": "2",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Colors"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/Colors.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Colors.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Colors'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/editor/index.ts

@@ -1,2 +1,3 @@
 export { default as BezierCurve03 } from './BezierCurve03'
+export { default as Colors } from './Colors'
 export { default as TypeSquare } from './TypeSquare'

+ 38 - 0
web/app/components/base/icons/src/vender/solid/communication/MessageDotsCircle.json

@@ -0,0 +1,38 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "message-dots-circle"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Solid",
+							"fill-rule": "evenodd",
+							"clip-rule": "evenodd",
+							"d": "M12 2C6.47715 2 2 6.47715 2 12C2 13.3283 2.25952 14.5985 2.73156 15.7608C2.77419 15.8658 2.79872 15.9264 2.81552 15.9711L2.82063 15.9849L2.82 15.9897C2.815 16.0266 2.80672 16.0769 2.79071 16.173L2.19294 19.7596C2.16612 19.9202 2.13611 20.0999 2.12433 20.256C2.11148 20.4261 2.10701 20.6969 2.22973 20.983C2.38144 21.3367 2.6633 21.6186 3.017 21.7703C3.30312 21.893 3.57386 21.8885 3.74404 21.8757C3.90013 21.8639 4.07985 21.8339 4.24049 21.8071L7.82705 21.2093C7.92309 21.1933 7.97339 21.185 8.0103 21.18L8.01505 21.1794L8.02887 21.1845C8.07362 21.2013 8.13423 21.2258 8.23921 21.2684C9.4015 21.7405 10.6717 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM6 12C6 11.1716 6.67157 10.5 7.5 10.5C8.32843 10.5 9 11.1716 9 12C9 12.8284 8.32843 13.5 7.5 13.5C6.67157 13.5 6 12.8284 6 12ZM10.5 12C10.5 11.1716 11.1716 10.5 12 10.5C12.8284 10.5 13.5 11.1716 13.5 12C13.5 12.8284 12.8284 13.5 12 13.5C11.1716 13.5 10.5 12.8284 10.5 12ZM16.5 10.5C15.6716 10.5 15 11.1716 15 12C15 12.8284 15.6716 13.5 16.5 13.5C17.3284 13.5 18 12.8284 18 12C18 11.1716 17.3284 10.5 16.5 10.5Z",
+							"fill": "currentColor"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "MessageDotsCircle"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/solid/communication/MessageDotsCircle.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './MessageDotsCircle.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'MessageDotsCircle'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/solid/communication/index.ts

@@ -1 +1,2 @@
+export { default as MessageDotsCircle } from './MessageDotsCircle'
 export { default as MessageFast } from './MessageFast'

+ 62 - 0
web/app/components/base/icons/src/vender/solid/editor/Colors.json

@@ -0,0 +1,62 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "colors"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "g",
+						"attributes": {
+							"id": "Solid"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M13.4494 13.2298C12.9854 13.3409 12.5002 13.3999 12 13.3999C10.2804 13.3999 8.72326 12.6997 7.59953 11.5677C6.4872 10.4471 5.8 8.90382 5.8 7.20007C5.8 3.77586 8.57584 1 12 1C15.4241 1 18.2 3.77586 18.2 7.20007C18.2 8.44569 17.8327 9.60551 17.2005 10.5771C16.3665 11.8588 15.0715 12.8131 13.5506 13.2047C13.517 13.2133 13.4833 13.2217 13.4494 13.2298Z",
+									"fill": "currentColor"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M15.1476 14.7743C16.6646 14.1431 17.9513 13.0695 18.8465 11.7146C19.0004 11.4817 19.0773 11.3652 19.1762 11.3066C19.2615 11.2561 19.3659 11.2312 19.4648 11.2379C19.5795 11.2457 19.6773 11.3015 19.8728 11.4133C21.7413 12.4817 23 14.4946 23 16.7999C23 20.2241 20.2242 23 16.8 23C15.9123 23 15.0689 22.8139 14.3059 22.4782C14.0549 22.3678 13.9294 22.3126 13.8502 22.2049C13.7822 22.1126 13.7468 21.9922 13.7539 21.8777C13.7622 21.7444 13.8565 21.6018 14.045 21.3167C14.8373 20.1184 15.3234 18.6997 15.3917 17.1723C15.3969 17.0566 15.3996 16.9402 15.4 16.8233L15.4 16.7999C15.4 16.1888 15.333 15.5926 15.2057 15.0185C15.1876 14.9366 15.1682 14.8552 15.1476 14.7743Z",
+									"fill": "currentColor"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M4.12723 11.4133C4.32273 11.3015 4.42049 11.2457 4.53516 11.2379C4.63414 11.2312 4.73848 11.2561 4.82382 11.3066C4.92269 11.3652 4.99964 11.4817 5.15355 11.7146C6.62074 13.9352 9.13929 15.4001 12 15.4001C12.4146 15.4001 12.822 15.3694 13.2201 15.31L13.2263 15.3357C13.3398 15.8045 13.4 16.2947 13.4 16.7999L13.4 16.8214C13.3997 16.9056 13.3977 16.9895 13.3941 17.0728C13.2513 20.3704 10.5327 23 7.2 23C3.77584 23 1 20.2241 1 16.7999C1 14.4946 2.25869 12.4817 4.12723 11.4133Z",
+									"fill": "currentColor"
+								},
+								"children": []
+							}
+						]
+					}
+				]
+			}
+		]
+	},
+	"name": "Colors"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/solid/editor/Colors.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Colors.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Colors'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/solid/editor/index.ts

@@ -1,4 +1,5 @@
 export { default as Brush01 } from './Brush01'
 export { default as Citations } from './Citations'
+export { default as Colors } from './Colors'
 export { default as Paragraph } from './Paragraph'
 export { default as TypeSquare } from './TypeSquare'

+ 3 - 3
web/app/components/base/image-uploader/utils.ts

@@ -6,13 +6,13 @@ type ImageUploadParams = {
   onSuccessCallback: (res: { id: string }) => void
   onErrorCallback: () => void
 }
-type ImageUpload = (v: ImageUploadParams, isPublic?: boolean) => void
+type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void
 export const imageUpload: ImageUpload = ({
   file,
   onProgressCallback,
   onSuccessCallback,
   onErrorCallback,
-}, isPublic) => {
+}, isPublic, url) => {
   const formData = new FormData()
   formData.append('file', file)
   const onProgress = (e: ProgressEvent) => {
@@ -26,7 +26,7 @@ export const imageUpload: ImageUpload = ({
     xhr: new XMLHttpRequest(),
     data: formData,
     onprogress: onProgress,
-  }, isPublic)
+  }, isPublic, url)
     .then((res: { id: string }) => {
       onSuccessCallback(res)
     })

+ 70 - 0
web/app/components/custom/custom-app-header-brand/index.tsx

@@ -0,0 +1,70 @@
+import { useTranslation } from 'react-i18next'
+import s from './style.module.css'
+import Button from '@/app/components/base/button'
+import { Grid01 } from '@/app/components/base/icons/src/vender/solid/layout'
+import { Container, Database01 } from '@/app/components/base/icons/src/vender/line/development'
+import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
+import { useProviderContext } from '@/context/provider-context'
+import { Plan } from '@/app/components/billing/type'
+
+const CustomAppHeaderBrand = () => {
+  const { t } = useTranslation()
+  const { plan } = useProviderContext()
+
+  return (
+    <div className='py-3'>
+      <div className='mb-2 text-sm font-medium text-gray-900'>{t('custom.app.title')}</div>
+      <div className='relative mb-4 rounded-xl bg-gray-100 border-[0.5px] border-black/[0.08] shadow-xs'>
+        <div className={`${s.mask} absolute inset-0 rounded-xl`}></div>
+        <div className='flex items-center pl-5 h-14 rounded-t-xl'>
+          <div className='relative flex items-center mr-[199px] w-[120px] h-10 bg-[rgba(217,45,32,0.12)]'>
+            <div className='ml-[1px] mr-[3px] w-[34px] h-[34px] border-8 border-black/[0.16] rounded-full'></div>
+            <div className='text-[13px] font-bold text-black/[0.24]'>YOUR LOGO</div>
+            <div className='absolute top-0 bottom-0 left-0.5 w-[0.5px] bg-[#F97066] opacity-50'></div>
+            <div className='absolute top-0 bottom-0 right-0.5 w-[0.5px] bg-[#F97066] opacity-50'></div>
+            <div className='absolute left-0 right-0 top-0.5 h-[0.5px] bg-[#F97066] opacity-50'></div>
+            <div className='absolute left-0 right-0 bottom-0.5 h-[0.5px] bg-[#F97066] opacity-50'></div>
+          </div>
+          <div className='flex items-center mr-3 px-3 h-7 rounded-xl bg-white shadow-xs'>
+            <Grid01 className='shrink-0 mr-2 w-4 h-4 text-[#155eef]' />
+            <div className='w-12 h-1.5 rounded-[5px] bg-[#155eef] opacity-80'></div>
+          </div>
+          <div className='flex items-center mr-3 px-3 h-7'>
+            <Container className='shrink-0 mr-2 w-4 h-4 text-gray-500' />
+            <div className='w-[50px] h-1.5 rounded-[5px] bg-gray-300'></div>
+          </div>
+          <div className='flex items-center px-3 h-7'>
+            <Database01 className='shrink-0 mr-2 w-4 h-4 text-gray-500' />
+            <div className='w-14 h-1.5 rounded-[5px] bg-gray-300 opacity-80'></div>
+          </div>
+        </div>
+        <div className='h-8 border-t border-t-gray-200 rounded-b-xl'></div>
+      </div>
+      <div className='flex items-center mb-2'>
+        <Button
+          className={`
+            !h-8 !px-3 bg-white !text-[13px] 
+            ${plan.type === Plan.sandbox ? 'opacity-40' : ''}
+          `}
+          disabled={plan.type === Plan.sandbox}
+        >
+          <ImagePlus className='mr-2 w-4 h-4' />
+          {t('custom.upload')}
+        </Button>
+        <div className='mx-2 h-5 w-[1px] bg-black/5'></div>
+        <Button
+          className={`
+            !h-8 !px-3 bg-white !text-[13px] 
+            ${plan.type === Plan.sandbox ? 'opacity-40' : ''}
+          `}
+          disabled={plan.type === Plan.sandbox}
+        >
+          {t('custom.restore')}
+        </Button>
+      </div>
+      <div className='text-xs text-gray-500'>{t('custom.app.changeLogoTip')}</div>
+    </div>
+  )
+}
+
+export default CustomAppHeaderBrand

+ 3 - 0
web/app/components/custom/custom-app-header-brand/style.module.css

@@ -0,0 +1,3 @@
+.mask {
+  background: linear-gradient(95deg, rgba(255, 255, 255, 0.00) 43.9%, rgba(255, 255, 255, 0.80) 95.76%);  ;
+}

+ 52 - 0
web/app/components/custom/custom-page/index.tsx

@@ -0,0 +1,52 @@
+import { useTranslation } from 'react-i18next'
+import CustomWebAppBrand from '../custom-web-app-brand'
+import CustomAppHeaderBrand from '../custom-app-header-brand'
+import s from '../style.module.css'
+import GridMask from '@/app/components/base/grid-mask'
+import UpgradeBtn from '@/app/components/billing/upgrade-btn'
+import { useProviderContext } from '@/context/provider-context'
+import { Plan } from '@/app/components/billing/type'
+import { contactSalesUrl } from '@/app/components/billing/config'
+
+const CustomPage = () => {
+  const { t } = useTranslation()
+  const { plan } = useProviderContext()
+
+  return (
+    <div className='flex flex-col'>
+      {
+        plan.type === Plan.sandbox && (
+          <GridMask canvasClassName='!rounded-xl'>
+            <div className='flex justify-between mb-1 px-6 py-5 h-[88px] shadow-md rounded-xl border-[0.5px] border-gray-200'>
+              <div className={`${s.textGradient} leading-[24px] text-base font-semibold`}>
+                <div>{t('custom.upgradeTip.prefix')}</div>
+                <div>{t('custom.upgradeTip.suffix')}</div>
+              </div>
+              <UpgradeBtn />
+            </div>
+          </GridMask>
+        )
+      }
+      <CustomWebAppBrand />
+      {
+        plan.type === Plan.sandbox && (
+          <>
+            <div className='my-2 h-[0.5px] bg-gray-100'></div>
+            <CustomAppHeaderBrand />
+          </>
+        )
+      }
+      {
+        (plan.type === Plan.professional || plan.type === Plan.team) && (
+          <div className='absolute bottom-0 h-[50px] leading-[50px] text-xs text-gray-500'>
+            {t('custom.customize.prefix')}
+            <a className='text-[#155EEF]' href={contactSalesUrl} target='_blank'>{t('custom.customize.contactUs')}</a>
+            {t('custom.customize.suffix')}
+          </div>
+        )
+      }
+    </div>
+  )
+}
+
+export default CustomPage

+ 234 - 0
web/app/components/custom/custom-web-app-brand/index.tsx

@@ -0,0 +1,234 @@
+import type { ChangeEvent } from 'react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import s from './style.module.css'
+import LogoSite from '@/app/components/base/logo/logo-site'
+import Switch from '@/app/components/base/switch'
+import Button from '@/app/components/base/button'
+import { Loading02 } from '@/app/components/base/icons/src/vender/line/general'
+import { MessageDotsCircle } from '@/app/components/base/icons/src/vender/solid/communication'
+import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
+import { useProviderContext } from '@/context/provider-context'
+import { Plan } from '@/app/components/billing/type'
+import { imageUpload } from '@/app/components/base/image-uploader/utils'
+import type {} from '@/app/components/base/image-uploader/utils'
+import { useToastContext } from '@/app/components/base/toast'
+import {
+  updateCurrentWorkspace,
+} from '@/service/common'
+import { useAppContext } from '@/context/app-context'
+import { API_PREFIX } from '@/config'
+
+const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
+
+const CustomWebAppBrand = () => {
+  const { t } = useTranslation()
+  const { notify } = useToastContext()
+  const { plan } = useProviderContext()
+  const {
+    currentWorkspace,
+    mutateCurrentWorkspace,
+    isCurrentWorkspaceManager,
+  } = useAppContext()
+  const [fileId, setFileId] = useState('')
+  const [uploadProgress, setUploadProgress] = useState(0)
+  const isSandbox = plan.type === Plan.sandbox
+  const uploading = uploadProgress > 0 && uploadProgress < 100
+  const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
+  const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
+
+  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+
+    if (!file)
+      return
+
+    if (file.size > 5 * 1024 * 1024) {
+      notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: 5 }) })
+      return
+    }
+
+    imageUpload({
+      file,
+      onProgressCallback: (progress) => {
+        setUploadProgress(progress)
+      },
+      onSuccessCallback: (res) => {
+        setUploadProgress(100)
+        setFileId(res.id)
+      },
+      onErrorCallback: () => {
+        notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
+        setUploadProgress(-1)
+      },
+    }, false, '/workspaces/custom-config/webapp-logo/upload')
+  }
+
+  const handleApply = async () => {
+    await updateCurrentWorkspace({
+      url: '/workspaces/custom-config',
+      body: {
+        remove_webapp_brand: webappBrandRemoved,
+        replace_webapp_logo: fileId,
+      },
+    })
+    mutateCurrentWorkspace()
+    setFileId('')
+  }
+
+  const handleRestore = async () => {
+    await updateCurrentWorkspace({
+      url: '/workspaces/custom-config',
+      body: {
+        remove_webapp_brand: false,
+        replace_webapp_logo: null,
+      },
+    })
+    mutateCurrentWorkspace()
+  }
+
+  const handleSwitch = async (checked: boolean) => {
+    await updateCurrentWorkspace({
+      url: '/workspaces/custom-config',
+      body: {
+        remove_webapp_brand: checked,
+        replace_webapp_logo: webappLogo,
+      },
+    })
+    mutateCurrentWorkspace()
+  }
+
+  const handleCancel = () => {
+    setFileId('')
+    setUploadProgress(0)
+  }
+
+  return (
+    <div className='py-4'>
+      <div className='mb-2 text-sm font-medium text-gray-900'>{t('custom.webapp.title')}</div>
+      <div className='relative mb-4 pl-4 pb-6 pr-[119px] rounded-xl border-[0.5px] border-black/[0.08] shadow-xs bg-gray-50 overflow-hidden'>
+        <div className={`${s.mask} absolute top-0 left-0 w-full -bottom-2 z-10`}></div>
+        <div className='flex items-center -mt-2 mb-4 p-6 bg-white rounded-xl'>
+          <div className='flex items-center px-4 w-[125px] h-9 rounded-lg bg-primary-600 border-[0.5px] border-primary-700 shadow-xs'>
+            <MessageDotsCircle className='shrink-0 mr-2 w-4 h-4 text-white' />
+            <div className='grow h-2 rounded-sm bg-white opacity-50' />
+          </div>
+        </div>
+        <div className='flex items-center h-5 justify-between'>
+          <div className='w-[369px] h-1.5 rounded-sm bg-gray-200 opacity-80' />
+          {
+            !webappBrandRemoved && (
+              <div className='flex items-center text-[10px] font-medium text-gray-400'>
+                POWERED BY
+                {
+                  webappLogo
+                    ? <img key={webappLogo} src={`${API_PREFIX.slice(0, -12)}/files/workspaces/${currentWorkspace.id}/webapp-logo`} alt='logo' className='ml-2 block w-auto h-5' />
+                    : <LogoSite className='ml-2 !h-5' />
+                }
+              </div>
+            )
+          }
+        </div>
+      </div>
+      <div className='flex items-center justify-between mb-2 px-4 h-14 rounded-xl border-[0.5px] border-gray-200 bg-gray-50 text-sm font-medium text-gray-900'>
+        {t('custom.webapp.removeBrand')}
+        <Switch
+          size='l'
+          defaultValue={webappBrandRemoved}
+          disabled={isSandbox || !isCurrentWorkspaceManager}
+          onChange={handleSwitch}
+        />
+      </div>
+      <div className={`
+        flex items-center justify-between px-4 py-3 rounded-xl border-[0.5px] border-gray-200 bg-gray-50
+        ${webappBrandRemoved && 'opacity-30'}
+      `}>
+        <div>
+          <div className='leading-5 text-sm font-medium text-gray-900'>{t('custom.webapp.changeLogo')}</div>
+          <div className='leading-[18px] text-xs text-gray-500'>{t('custom.webapp.changeLogoTip')}</div>
+        </div>
+        <div className='flex items-center'>
+          {
+            !uploading && (
+              <Button
+                className={`
+                  relative mr-2 !h-8 !px-3 bg-white !text-[13px] 
+                  ${isSandbox ? 'opacity-40' : ''}
+                `}
+                disabled={isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager}
+              >
+                <ImagePlus className='mr-2 w-4 h-4' />
+                {
+                  (webappLogo || fileId)
+                    ? t('custom.change')
+                    : t('custom.upload')
+                }
+                <input
+                  className={`
+                    absolute block inset-0 opacity-0 text-[0] w-full
+                    ${(isSandbox || webappBrandRemoved) ? 'cursor-not-allowed' : 'cursor-pointer'}
+                  `}
+                  onClick={e => (e.target as HTMLInputElement).value = ''}
+                  type='file'
+                  accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
+                  onChange={handleChange}
+                  disabled={isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager}
+                />
+              </Button>
+            )
+          }
+          {
+            uploading && (
+              <Button
+                className='relative mr-2 !h-8 !px-3 bg-white !text-[13px] opacity-40'
+                disabled={true}
+              >
+                <Loading02 className='animate-spin mr-2 w-4 h-4' />
+                {t('custom.uploading')}
+              </Button>
+            )
+          }
+          {
+            fileId && (
+              <>
+                <Button
+                  type='primary'
+                  className='mr-2 !h-8 !px-3 !py-0 !text-[13px]'
+                  onClick={handleApply}
+                  disabled={webappBrandRemoved || !isCurrentWorkspaceManager}
+                >
+                  {t('custom.apply')}
+                </Button>
+                <Button
+                  className='mr-2 !h-8 !px-3 !text-[13px] bg-white'
+                  onClick={handleCancel}
+                  disabled={webappBrandRemoved || !isCurrentWorkspaceManager}
+                >
+                  {t('common.operation.cancel')}
+                </Button>
+              </>
+            )
+          }
+          <div className='mr-2 h-5 w-[1px] bg-black/5'></div>
+          <Button
+            className={`
+              !h-8 !px-3 bg-white !text-[13px] 
+              ${isSandbox ? 'opacity-40' : ''}
+            `}
+            disabled={isSandbox || (!webappLogo && !webappBrandRemoved) || webappBrandRemoved || !isCurrentWorkspaceManager}
+            onClick={handleRestore}
+          >
+            {t('custom.restore')}
+          </Button>
+        </div>
+      </div>
+      {
+        uploadProgress === -1 && (
+          <div className='mt-2 text-xs text-[#D92D20]'>{t('custom.uploadedFail')}</div>
+        )
+      }
+    </div>
+  )
+}
+
+export default CustomWebAppBrand

+ 3 - 0
web/app/components/custom/custom-web-app-brand/style.module.css

@@ -0,0 +1,3 @@
+.mask {
+  background: linear-gradient(273deg, rgba(255, 255, 255, 0.00) 51.75%, rgba(255, 255, 255, 0.80) 115.32%);
+}

+ 6 - 0
web/app/components/custom/style.module.css

@@ -0,0 +1,6 @@
+.textGradient {
+  background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+}

+ 11 - 0
web/app/components/header/account-setting/index.tsx

@@ -14,6 +14,7 @@ import DataSourcePage from './data-source-page'
 import ModelPage from './model-page'
 import s from './index.module.css'
 import BillingPage from '@/app/components/billing/billing-page'
+import CustomPage from '@/app/components/custom/custom-page'
 import Modal from '@/app/components/base/modal'
 import {
   Database03,
@@ -26,8 +27,11 @@ import { User01 as User01Solid, Users01 as Users01Solid } from '@/app/components
 import { Globe01 } from '@/app/components/base/icons/src/vender/line/mapsAndTravel'
 import { AtSign, XClose } from '@/app/components/base/icons/src/vender/line/general'
 import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
+import { Colors } from '@/app/components/base/icons/src/vender/line/editor'
+import { Colors as ColorsSolid } from '@/app/components/base/icons/src/vender/solid/editor'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
 import { useProviderContext } from '@/context/provider-context'
+import { IS_CE_EDITION } from '@/config'
 
 const iconClassName = `
   w-4 h-4 ml-3 mr-2
@@ -96,6 +100,12 @@ export default function AccountSetting({
         icon: <Webhooks className={iconClassName} />,
         activeIcon: <Webhooks className={iconClassName} />,
       },
+      {
+        key: IS_CE_EDITION ? false : 'custom',
+        name: t('custom.custom'),
+        icon: <Colors className={iconClassName} />,
+        activeIcon: <ColorsSolid className={iconClassName} />,
+      },
     ].filter(item => !!item.key) as GroupItem[]
   })()
 
@@ -206,6 +216,7 @@ export default function AccountSetting({
             {activeMenu === 'data-source' && <DataSourcePage />}
             {activeMenu === 'plugin' && <PluginPage />}
             {activeMenu === 'api-based-extension' && <ApiBasedExtensionPage /> }
+            {activeMenu === 'custom' && <CustomPage /> }
           </div>
         </div>
       </div>

+ 4 - 1
web/app/components/share/chat/index.tsx

@@ -71,6 +71,7 @@ const Main: FC<IMainProps> = ({
   const [inited, setInited] = useState<boolean>(false)
   const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
   const [canReplaceLogo, setCanReplaceLogo] = useState<boolean>(false)
+  const [customConfig, setCustomConfig] = useState<any>(null)
   // in mobile, show sidebar by click button
   const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
   // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
@@ -364,10 +365,11 @@ const Main: FC<IMainProps> = ({
     (async () => {
       try {
         const [appData, conversationData, appParams]: any = await fetchInitData()
-        const { app_id: appId, site: siteInfo, plan, can_replace_logo }: any = appData
+        const { app_id: appId, site: siteInfo, plan, can_replace_logo, custom_config }: any = appData
         setAppId(appId)
         setPlan(plan)
         setCanReplaceLogo(can_replace_logo)
+        setCustomConfig(custom_config)
         const tempIsPublicVersion = siteInfo.prompt_public
         setIsPublicVersion(tempIsPublicVersion)
         const prompt_template = ''
@@ -752,6 +754,7 @@ const Main: FC<IMainProps> = ({
             onInputsChange={setCurrInputs}
             plan={plan}
             canReplaceLogo={canReplaceLogo}
+            customConfig={customConfig}
           ></ConfigSence>
 
           {

+ 19 - 6
web/app/components/share/chat/welcome/index.tsx

@@ -27,6 +27,10 @@ export type IWelcomeProps = {
   onInputsChange: (inputs: Record<string, any>) => void
   plan?: string
   canReplaceLogo?: boolean
+  customConfig?: {
+    remove_webapp_brand?: boolean
+    replace_webapp_logo?: string
+  }
 }
 
 const Welcome: FC<IWelcomeProps> = ({
@@ -34,13 +38,12 @@ const Welcome: FC<IWelcomeProps> = ({
   hasSetInputs,
   isPublicVersion,
   siteInfo,
-  plan,
   promptConfig,
   onStartChat,
   canEidtInpus,
   savedInputs,
   onInputsChange,
-  canReplaceLogo,
+  customConfig,
 }) => {
   const { t } = useTranslation()
   const hasVar = promptConfig.prompt_variables.length > 0
@@ -352,10 +355,20 @@ const Welcome: FC<IWelcomeProps> = ({
               </div>
               : <div>
               </div>}
-            {!canReplaceLogo && <a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
-              <span className='uppercase'>{t('share.chat.powerBy')}</span>
-              <FootLogo />
-            </a>}
+            {
+              customConfig?.remove_webapp_brand
+                ? null
+                : (
+                  <a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
+                    <span className='uppercase'>{t('share.chat.powerBy')}</span>
+                    {
+                      customConfig?.replace_webapp_logo
+                        ? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
+                        : <FootLogo />
+                    }
+                  </a>
+                )
+            }
           </div>
         )}
       </div>

+ 4 - 1
web/app/components/share/chatbot/index.tsx

@@ -55,6 +55,7 @@ const Main: FC<IMainProps> = ({
   const [inited, setInited] = useState<boolean>(false)
   const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
   const [canReplaceLogo, setCanReplaceLogo] = useState<boolean>(false)
+  const [customConfig, setCustomConfig] = useState<any>(null)
   // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
   useEffect(() => {
     if (siteInfo?.title) {
@@ -283,10 +284,11 @@ const Main: FC<IMainProps> = ({
     (async () => {
       try {
         const [appData, conversationData, appParams]: any = await fetchInitData()
-        const { app_id: appId, site: siteInfo, plan, can_replace_logo }: any = appData
+        const { app_id: appId, site: siteInfo, plan, can_replace_logo, custom_config }: any = appData
         setAppId(appId)
         setPlan(plan)
         setCanReplaceLogo(can_replace_logo)
+        setCustomConfig(custom_config)
         const tempIsPublicVersion = siteInfo.prompt_public
         setIsPublicVersion(tempIsPublicVersion)
         const prompt_template = ''
@@ -592,6 +594,7 @@ const Main: FC<IMainProps> = ({
             onInputsChange={setCurrInputs}
             plan={plan}
             canReplaceLogo={canReplaceLogo}
+            customConfig={customConfig}
           ></ConfigScene>
           {
             shouldReload && (

+ 19 - 6
web/app/components/share/chatbot/welcome/index.tsx

@@ -27,6 +27,10 @@ export type IWelcomeProps = {
   onInputsChange: (inputs: Record<string, any>) => void
   plan: string
   canReplaceLogo?: boolean
+  customConfig?: {
+    remove_webapp_brand?: boolean
+    replace_webapp_logo?: string
+  }
 }
 
 const Welcome: FC<IWelcomeProps> = ({
@@ -34,13 +38,12 @@ const Welcome: FC<IWelcomeProps> = ({
   hasSetInputs,
   isPublicVersion,
   siteInfo,
-  plan,
   promptConfig,
   onStartChat,
   canEditInputs,
   savedInputs,
   onInputsChange,
-  canReplaceLogo,
+  customConfig,
 }) => {
   const { t } = useTranslation()
   const hasVar = promptConfig.prompt_variables.length > 0
@@ -353,10 +356,20 @@ const Welcome: FC<IWelcomeProps> = ({
               </div>
               : <div>
               </div>}
-            {!canReplaceLogo && <a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
-              <span className='uppercase'>{t('share.chat.powerBy')}</span>
-              <FootLogo />
-            </a>}
+            {
+              customConfig?.remove_webapp_brand
+                ? null
+                : (
+                  <a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
+                    <span className='uppercase'>{t('share.chat.powerBy')}</span>
+                    {
+                      customConfig?.replace_webapp_logo
+                        ? <img src={customConfig?.replace_webapp_logo} alt='logo' className='block w-auto h-5' />
+                        : <FootLogo />
+                    }
+                  </a>
+                )
+            }
           </div>
         )}
       </div>

+ 4 - 0
web/i18n/i18next-config.ts

@@ -37,6 +37,8 @@ import exploreEn from './lang/explore.en'
 import exploreZh from './lang/explore.zh'
 import billingEn from './lang/billing.en'
 import billingZh from './lang/billing.zh'
+import customEn from './lang/custom.en'
+import customZh from './lang/custom.zh'
 
 const resources = {
   'en': {
@@ -62,6 +64,7 @@ const resources = {
       explore: exploreEn,
       // billing
       billing: billingEn,
+      custom: customEn,
     },
   },
   'zh-Hans': {
@@ -86,6 +89,7 @@ const resources = {
       datasetCreation: datasetCreationZh,
       explore: exploreZh,
       billing: billingZh,
+      custom: customZh,
     },
   },
 }

+ 30 - 0
web/i18n/lang/custom.en.ts

@@ -0,0 +1,30 @@
+const translation = {
+  custom: 'Customization',
+  upgradeTip: {
+    prefix: 'Upgrade your plan to',
+    suffix: 'customize your brand.',
+  },
+  webapp: {
+    title: 'Customize web app brand',
+    removeBrand: 'Remove Powered by Dify',
+    changeLogo: 'Change Powered by Brand Image',
+    changeLogoTip: 'SVG or PNG format with a minimum size of 40x40px',
+  },
+  app: {
+    title: 'Customize app header brand',
+    changeLogoTip: 'SVG or PNG format with a minimum size of 80x80px',
+  },
+  upload: 'Upload',
+  uploading: 'Uploading',
+  uploadedFail: 'Image upload failed, please re-upload.',
+  change: 'Change',
+  apply: 'Apply',
+  restore: 'Restore Defaults',
+  customize: {
+    contactUs: ' contact us ',
+    prefix: 'To customize the brand logo within the app, please',
+    suffix: 'to upgrade to the Enterprise edition.',
+  },
+}
+
+export default translation

+ 30 - 0
web/i18n/lang/custom.zh.ts

@@ -0,0 +1,30 @@
+const translation = {
+  custom: '定制',
+  upgradeTip: {
+    prefix: '升级您的计划以',
+    suffix: '定制您的品牌。',
+  },
+  webapp: {
+    title: '定制 web app 品牌',
+    removeBrand: '移除 Powered by Dify',
+    changeLogo: '更改 Powered by Brand 图片',
+    changeLogoTip: 'SVG 或 PNG 格式,最小尺寸为 40x40px',
+  },
+  app: {
+    title: '定制应用品牌',
+    changeLogoTip: 'SVG 或 PNG 格式,最小尺寸为 80x80px',
+  },
+  upload: '上传',
+  uploading: '上传中',
+  uploadedFail: '图片上传失败,请重新上传。',
+  change: '更改',
+  apply: '应用',
+  restore: '恢复默认',
+  customize: {
+    contactUs: '联系我们',
+    prefix: '如需在 Dify 内自定义品牌图标,请',
+    suffix: '升级至企业版。',
+  },
+}
+
+export default translation

+ 4 - 0
web/models/common.ts

@@ -123,6 +123,10 @@ export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
   providers: Provider[]
   in_trail: boolean
   trial_end_reason?: string
+  custom_config?: {
+    remove_webapp_brand?: boolean
+    replace_webapp_logo?: string
+  }
 }
 
 export type DataSourceNotionPage = {

+ 2 - 2
web/service/base.ts

@@ -297,7 +297,7 @@ const baseFetch = <T>(
   ]) as Promise<T>
 }
 
-export const upload = (options: any, isPublicAPI?: boolean): Promise<any> => {
+export const upload = (options: any, isPublicAPI?: boolean, url?: string): Promise<any> => {
   const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
   let token = ''
   if (isPublicAPI) {
@@ -318,7 +318,7 @@ export const upload = (options: any, isPublicAPI?: boolean): Promise<any> => {
   }
   const defaultOptions = {
     method: 'POST',
-    url: `${urlPrefix}/files/upload`,
+    url: url ? `${urlPrefix}${url}` : `${urlPrefix}/files/upload`,
     headers: {
       Authorization: `Bearer ${token}`,
     },

+ 4 - 0
web/service/common.ts

@@ -103,6 +103,10 @@ export const fetchCurrentWorkspace: Fetcher<ICurrentWorkspace, { url: string; pa
   return get<ICurrentWorkspace>(url, { params })
 }
 
+export const updateCurrentWorkspace: Fetcher<ICurrentWorkspace, { url: string; body: Record<string, any> }> = ({ url, body }) => {
+  return post<ICurrentWorkspace>(url, { body })
+}
+
 export const fetchWorkspaces: Fetcher<{ workspaces: IWorkspace[] }, { url: string; params: Record<string, any> }> = ({ url, params }) => {
   return get<{ workspaces: IWorkspace[] }>(url, { params })
 }