Forráskód Böngészése

feat: custom webapp logo (#1766)

zxhlyh 1 éve%!(EXTRA string=óta)
szülő
commit
5bb841935e
40 módosított fájl, 888 hozzáadás és 24 törlés
  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>

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
web/app/components/base/icons/assets/vender/solid/communication/message-dots-circle.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 9 - 0
web/app/components/base/icons/assets/vender/solid/editor/colors.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'

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 38 - 0
web/app/components/base/icons/src/vender/solid/communication/MessageDotsCircle.json


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

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 62 - 0
web/app/components/base/icons/src/vender/solid/editor/Colors.json


+ 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 })
 }