FR: #4048 - Add color customization to the chatbot (#4885)

Co-authored-by: crazywoola <427733928@qq.com>
This commit is contained in:
Diego Romero-Lovo 2024-06-26 04:51:00 -05:00 committed by GitHub
parent 8fa6cb5e03
commit 4c0a31d38b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 379 additions and 18 deletions

View File

@ -20,6 +20,8 @@ def parse_app_site_args():
parser.add_argument('icon_background', type=str, required=False, location='json') parser.add_argument('icon_background', type=str, required=False, location='json')
parser.add_argument('description', type=str, required=False, location='json') parser.add_argument('description', type=str, required=False, location='json')
parser.add_argument('default_language', type=supported_language, required=False, location='json') parser.add_argument('default_language', type=supported_language, required=False, location='json')
parser.add_argument('chat_color_theme', type=str, required=False, location='json')
parser.add_argument('chat_color_theme_inverted', type=bool, required=False, location='json')
parser.add_argument('customize_domain', type=str, required=False, location='json') parser.add_argument('customize_domain', type=str, required=False, location='json')
parser.add_argument('copyright', type=str, required=False, location='json') parser.add_argument('copyright', type=str, required=False, location='json')
parser.add_argument('privacy_policy', type=str, required=False, location='json') parser.add_argument('privacy_policy', type=str, required=False, location='json')
@ -55,6 +57,8 @@ class AppSite(Resource):
'icon_background', 'icon_background',
'description', 'description',
'default_language', 'default_language',
'chat_color_theme',
'chat_color_theme_inverted',
'customize_domain', 'customize_domain',
'copyright', 'copyright',
'privacy_policy', 'privacy_policy',

View File

@ -26,6 +26,8 @@ class AppSiteApi(WebApiResource):
site_fields = { site_fields = {
'title': fields.String, 'title': fields.String,
'chat_color_theme': fields.String,
'chat_color_theme_inverted': fields.Boolean,
'icon': fields.String, 'icon': fields.String,
'icon_background': fields.String, 'icon_background': fields.String,
'description': fields.String, 'description': fields.String,

View File

@ -111,6 +111,8 @@ site_fields = {
'icon_background': fields.String, 'icon_background': fields.String,
'description': fields.String, 'description': fields.String,
'default_language': fields.String, 'default_language': fields.String,
'chat_color_theme': fields.String,
'chat_color_theme_inverted': fields.Boolean,
'customize_domain': fields.String, 'customize_domain': fields.String,
'copyright': fields.String, 'copyright': fields.String,
'privacy_policy': fields.String, 'privacy_policy': fields.String,

View File

@ -0,0 +1,22 @@
"""merge branches
Revision ID: 63f9175e515b
Revises: 2a3aebbbf4bb, b69ca54b9208
Create Date: 2024-06-26 09:46:36.573505
"""
import models as models
# revision identifiers, used by Alembic.
revision = '63f9175e515b'
down_revision = ('2a3aebbbf4bb', 'b69ca54b9208')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -0,0 +1,35 @@
"""add chatbot color theme
Revision ID: b69ca54b9208
Revises: 4ff534e1eb11
Create Date: 2024-06-25 01:14:21.523873
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = 'b69ca54b9208'
down_revision = '4ff534e1eb11'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('sites', schema=None) as batch_op:
batch_op.add_column(sa.Column('chat_color_theme', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('chat_color_theme_inverted', sa.Boolean(), server_default=sa.text('false'), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('sites', schema=None) as batch_op:
batch_op.drop_column('chat_color_theme_inverted')
batch_op.drop_column('chat_color_theme')
# ### end Alembic commands ###

View File

@ -1042,6 +1042,8 @@ class Site(db.Model):
icon_background = db.Column(db.String(255)) icon_background = db.Column(db.String(255))
description = db.Column(db.Text) description = db.Column(db.Text)
default_language = db.Column(db.String(255), nullable=False) default_language = db.Column(db.String(255), nullable=False)
chat_color_theme = db.Column(db.String(255))
chat_color_theme_inverted = db.Column(db.Boolean, nullable=False, server_default=db.text('false'))
copyright = db.Column(db.String(255)) copyright = db.Column(db.String(255))
privacy_policy = db.Column(db.String(255)) privacy_policy = db.Column(db.String(255))
show_workflow_steps = db.Column(db.Boolean, nullable=False, server_default=db.text('true')) show_workflow_steps = db.Column(db.Boolean, nullable=False, server_default=db.text('true'))

View File

@ -226,6 +226,7 @@ const AppPublisher = ({
</div> </div>
</PortalToFollowElemContent> </PortalToFollowElemContent>
<EmbeddedModal <EmbeddedModal
siteInfo={appDetail?.site}
isShow={embeddingModalOpen} isShow={embeddingModalOpen}
onClose={() => setEmbeddingModalOpen(false)} onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL} appBaseUrl={appBaseURL}

View File

@ -247,12 +247,14 @@ function AppCard({
? ( ? (
<> <>
<SettingsModal <SettingsModal
isChat={appMode === 'chat'}
appInfo={appInfo} appInfo={appInfo}
isShow={showSettingsModal} isShow={showSettingsModal}
onClose={() => setShowSettingsModal(false)} onClose={() => setShowSettingsModal(false)}
onSave={onSaveSiteConfig} onSave={onSaveSiteConfig}
/> />
<EmbeddedModal <EmbeddedModal
siteInfo={appInfo.site}
isShow={showEmbedded} isShow={showEmbedded}
onClose={() => setShowEmbedded(false)} onClose={() => setShowEmbedded(false)}
appBaseUrl={app_base_url} appBaseUrl={app_base_url}

View File

@ -8,8 +8,11 @@ import copyStyle from '@/app/components/base/copy-btn/style.module.css'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import type { SiteInfo } from '@/models/share'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
type Props = { type Props = {
siteInfo?: SiteInfo
isShow: boolean isShow: boolean
onClose: () => void onClose: () => void
accessToken: string accessToken: string
@ -28,7 +31,7 @@ const OPTION_MAP = {
</iframe>`, </iframe>`,
}, },
scripts: { scripts: {
getContent: (url: string, token: string, isTestEnv?: boolean) => getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) =>
`<script> `<script>
window.difyChatbotConfig = { window.difyChatbotConfig = {
token: '${token}'${isTestEnv token: '${token}'${isTestEnv
@ -44,7 +47,12 @@ const OPTION_MAP = {
src="${url}/embed.min.js" src="${url}/embed.min.js"
id="${token}" id="${token}"
defer> defer>
</script>`, </script>
<style>
#dify-chatbot-bubble-button {
background-color: ${primaryColor} !important;
}
</style>`,
}, },
chromePlugin: { chromePlugin: {
getContent: (url: string, token: string) => `ChatBot URL: ${url}/chatbot/${token}`, getContent: (url: string, token: string) => `ChatBot URL: ${url}/chatbot/${token}`,
@ -60,12 +68,14 @@ type OptionStatus = {
chromePlugin: boolean chromePlugin: boolean
} }
const Embedded = ({ isShow, onClose, appBaseUrl, accessToken, className }: Props) => { const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const [option, setOption] = useState<Option>('iframe') const [option, setOption] = useState<Option>('iframe')
const [isCopied, setIsCopied] = useState<OptionStatus>({ iframe: false, scripts: false, chromePlugin: false }) const [isCopied, setIsCopied] = useState<OptionStatus>({ iframe: false, scripts: false, chromePlugin: false })
const { langeniusVersionInfo } = useAppContext() const { langeniusVersionInfo } = useAppContext()
const themeBuilder = useThemeContext()
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
const isTestEnv = langeniusVersionInfo.current_env === 'TESTING' || langeniusVersionInfo.current_env === 'DEVELOPMENT' const isTestEnv = langeniusVersionInfo.current_env === 'TESTING' || langeniusVersionInfo.current_env === 'DEVELOPMENT'
const onClickCopy = () => { const onClickCopy = () => {
if (option === 'chromePlugin') { if (option === 'chromePlugin') {
@ -74,7 +84,7 @@ const Embedded = ({ isShow, onClose, appBaseUrl, accessToken, className }: Props
copy(splitUrl[1]) copy(splitUrl[1])
} }
else { else {
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, isTestEnv)) copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv))
} }
setIsCopied({ ...isCopied, [option]: true }) setIsCopied({ ...isCopied, [option]: true })
} }
@ -154,7 +164,7 @@ const Embedded = ({ isShow, onClose, appBaseUrl, accessToken, className }: Props
</div> </div>
<div className="flex items-start justify-start w-full gap-2 p-3 overflow-x-auto"> <div className="flex items-start justify-start w-full gap-2 p-3 overflow-x-auto">
<div className="grow shrink basis-0 text-slate-700 text-[13px] leading-tight font-mono"> <div className="grow shrink basis-0 text-slate-700 text-[13px] leading-tight font-mono">
<pre className='select-text'>{OPTION_MAP[option].getContent(appBaseUrl, accessToken, isTestEnv)}</pre> <pre className='select-text'>{OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv)}</pre>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,6 +17,7 @@ import { useToastContext } from '@/app/components/base/toast'
import { languages } from '@/i18n/language' import { languages } from '@/i18n/language'
export type ISettingsModalProps = { export type ISettingsModalProps = {
isChat: boolean
appInfo: AppDetailResponse appInfo: AppDetailResponse
isShow: boolean isShow: boolean
defaultValue?: string defaultValue?: string
@ -28,6 +29,8 @@ export type ConfigParams = {
title: string title: string
description: string description: string
default_language: string default_language: string
chat_color_theme: string
chat_color_theme_inverted: boolean
prompt_public: boolean prompt_public: boolean
copyright: string copyright: string
privacy_policy: string privacy_policy: string
@ -40,6 +43,7 @@ export type ConfigParams = {
const prefixSettings = 'appOverview.overview.appInfo.settings' const prefixSettings = 'appOverview.overview.appInfo.settings'
const SettingsModal: FC<ISettingsModalProps> = ({ const SettingsModal: FC<ISettingsModalProps> = ({
isChat,
appInfo, appInfo,
isShow = false, isShow = false,
onClose, onClose,
@ -48,8 +52,27 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const { notify } = useToastContext() const { notify } = useToastContext()
const [isShowMore, setIsShowMore] = useState(false) const [isShowMore, setIsShowMore] = useState(false)
const { icon, icon_background } = appInfo const { icon, icon_background } = appInfo
const { title, description, copyright, privacy_policy, custom_disclaimer, default_language, show_workflow_steps } = appInfo.site const {
const [inputInfo, setInputInfo] = useState({ title, desc: description, copyright, privacyPolicy: privacy_policy, customDisclaimer: custom_disclaimer, show_workflow_steps }) title,
description,
chat_color_theme,
chat_color_theme_inverted,
copyright,
privacy_policy,
custom_disclaimer,
default_language,
show_workflow_steps,
} = appInfo.site
const [inputInfo, setInputInfo] = useState({
title,
desc: description,
chatColorTheme: chat_color_theme,
chatColorThemeInverted: chat_color_theme_inverted,
copyright,
privacyPolicy: privacy_policy,
customDisclaimer: custom_disclaimer,
show_workflow_steps,
})
const [language, setLanguage] = useState(default_language) const [language, setLanguage] = useState(default_language)
const [saveLoading, setSaveLoading] = useState(false) const [saveLoading, setSaveLoading] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
@ -58,7 +81,16 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const [emoji, setEmoji] = useState({ icon, icon_background }) const [emoji, setEmoji] = useState({ icon, icon_background })
useEffect(() => { useEffect(() => {
setInputInfo({ title, desc: description, copyright, privacyPolicy: privacy_policy, customDisclaimer: custom_disclaimer, show_workflow_steps }) setInputInfo({
title,
desc: description,
chatColorTheme: chat_color_theme,
chatColorThemeInverted: chat_color_theme_inverted,
copyright,
privacyPolicy: privacy_policy,
customDisclaimer: custom_disclaimer,
show_workflow_steps,
})
setLanguage(default_language) setLanguage(default_language)
setEmoji({ icon, icon_background }) setEmoji({ icon, icon_background })
}, [appInfo]) }, [appInfo])
@ -75,11 +107,30 @@ const SettingsModal: FC<ISettingsModalProps> = ({
notify({ type: 'error', message: t('app.newApp.nameNotEmpty') }) notify({ type: 'error', message: t('app.newApp.nameNotEmpty') })
return return
} }
const validateColorHex = (hex: string | null) => {
if (hex === null || hex.length === 0)
return true
const regex = /#([A-Fa-f0-9]{6})/
const check = regex.test(hex)
return check
}
if (inputInfo !== null) {
if (!validateColorHex(inputInfo.chatColorTheme)) {
notify({ type: 'error', message: t(`${prefixSettings}.invalidHexMessage`) })
return
}
}
setSaveLoading(true) setSaveLoading(true)
const params = { const params = {
title: inputInfo.title, title: inputInfo.title,
description: inputInfo.desc, description: inputInfo.desc,
default_language: language, default_language: language,
chat_color_theme: inputInfo.chatColorTheme,
chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
prompt_public: false, prompt_public: false,
copyright: inputInfo.copyright, copyright: inputInfo.copyright,
privacy_policy: inputInfo.privacyPolicy, privacy_policy: inputInfo.privacyPolicy,
@ -95,7 +146,13 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const onChange = (field: string) => { const onChange = (field: string) => {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInputInfo(item => ({ ...item, [field]: e.target.value })) let value: string | boolean
if (e.target.type === 'checkbox')
value = (e.target as HTMLInputElement).checked
else
value = e.target.value
setInputInfo(item => ({ ...item, [field]: value }))
} }
} }
@ -144,6 +201,14 @@ const SettingsModal: FC<ISettingsModalProps> = ({
onSelect={item => setInputInfo({ ...inputInfo, show_workflow_steps: item.value === 'true' })} onSelect={item => setInputInfo({ ...inputInfo, show_workflow_steps: item.value === 'true' })}
/> />
</>} </>}
{isChat && <> <div className={`mt-8 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.chatColorTheme`)}</div>
<p className={`mt-1 ${s.settingsTip} text-gray-500`}>{t(`${prefixSettings}.chatColorThemeDesc`)}</p>
<input className={`w-full mt-2 rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
value={inputInfo.chatColorTheme ?? ''}
onChange={onChange('chatColorTheme')}
placeholder= 'E.g #A020F0'
/>
</>}
{!isShowMore && <div className='w-full cursor-pointer mt-8' onClick={() => setIsShowMore(true)}> {!isShowMore && <div className='w-full cursor-pointer mt-8' onClick={() => setIsShowMore(true)}>
<div className='flex justify-between'> <div className='flex justify-between'>
<div className={`font-medium ${s.settingTitle} flex-grow text-gray-900`}>{t(`${prefixSettings}.more.entry`)}</div> <div className={`font-medium ${s.settingTitle} flex-grow text-gray-900`}>{t(`${prefixSettings}.more.entry`)}</div>

View File

@ -1,3 +1,4 @@
import type { CSSProperties } from 'react'
import React from 'react' import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority' import { type VariantProps, cva } from 'class-variance-authority'
import classNames from 'classnames' import classNames from 'classnames'
@ -29,15 +30,17 @@ const buttonVariants = cva(
export type ButtonProps = { export type ButtonProps = {
loading?: boolean loading?: boolean
styleCss?: CSSProperties
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants> } & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, ...props }, ref) => { ({ className, variant, size, loading, styleCss, children, ...props }, ref) => {
return ( return (
<button <button
type='button' type='button'
className={classNames(buttonVariants({ variant, size, className }))} className={classNames(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
style={styleCss}
{...props} {...props}
> >
{children} {children}

View File

@ -15,6 +15,8 @@ import type {
} from '../types' } from '../types'
import { TransferMethod } from '../types' import { TransferMethod } from '../types'
import { useChatWithHistoryContext } from '../chat-with-history/context' import { useChatWithHistoryContext } from '../chat-with-history/context'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import TooltipPlus from '@/app/components/base/tooltip-plus' import TooltipPlus from '@/app/components/base/tooltip-plus'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -35,11 +37,13 @@ type ChatInputProps = {
visionConfig?: VisionConfig visionConfig?: VisionConfig
speechToTextConfig?: EnableType speechToTextConfig?: EnableType
onSend?: OnSend onSend?: OnSend
theme?: Theme | null
} }
const ChatInput: FC<ChatInputProps> = ({ const ChatInput: FC<ChatInputProps> = ({
visionConfig, visionConfig,
speechToTextConfig, speechToTextConfig,
onSend, onSend,
theme,
}) => { }) => {
const { appData } = useChatWithHistoryContext() const { appData } = useChatWithHistoryContext()
const { t } = useTranslation() const { t } = useTranslation()
@ -112,14 +116,25 @@ const ChatInput: FC<ChatInputProps> = ({
}) })
} }
const [isActiveIconFocused, setActiveIconFocused] = useState(false)
const media = useBreakpoints() const media = useBreakpoints()
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile
const sendIconThemeStyle = theme
? {
color: (isActiveIconFocused || query || (query.trim() !== '')) ? theme.primaryColor : '#d1d5db',
}
: {}
const sendBtn = ( const sendBtn = (
<div <div
className='group flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#EBF5FF] cursor-pointer' className='group flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#EBF5FF] cursor-pointer'
onMouseEnter={() => setActiveIconFocused(true)}
onMouseLeave={() => setActiveIconFocused(false)}
onClick={handleSend} onClick={handleSend}
style={isActiveIconFocused ? CssTransform(theme?.chatBubbleColorStyle ?? '') : {}}
> >
<Send03 <Send03
style={sendIconThemeStyle}
className={` className={`
w-5 h-5 text-gray-300 group-hover:text-primary-600 w-5 h-5 text-gray-300 group-hover:text-primary-600
${!!query.trim() && 'text-primary-600'} ${!!query.trim() && 'text-primary-600'}

View File

@ -19,6 +19,7 @@ import type {
Feedback, Feedback,
OnSend, OnSend,
} from '../types' } from '../types'
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import Question from './question' import Question from './question'
import Answer from './answer' import Answer from './answer'
import ChatInput from './chat-input' import ChatInput from './chat-input'
@ -58,7 +59,9 @@ export type ChatProps = {
chatAnswerContainerInner?: string chatAnswerContainerInner?: string
hideProcessDetail?: boolean hideProcessDetail?: boolean
hideLogModal?: boolean hideLogModal?: boolean
themeBuilder?: ThemeBuilder
} }
const Chat: FC<ChatProps> = ({ const Chat: FC<ChatProps> = ({
appData, appData,
config, config,
@ -85,6 +88,7 @@ const Chat: FC<ChatProps> = ({
chatAnswerContainerInner, chatAnswerContainerInner,
hideProcessDetail, hideProcessDetail,
hideLogModal, hideLogModal,
themeBuilder,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({ const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
@ -221,6 +225,7 @@ const Chat: FC<ChatProps> = ({
key={item.id} key={item.id}
item={item} item={item}
questionIcon={questionIcon} questionIcon={questionIcon}
theme={themeBuilder?.theme}
/> />
) )
}) })
@ -262,6 +267,7 @@ const Chat: FC<ChatProps> = ({
visionConfig={config?.file_upload?.image} visionConfig={config?.file_upload?.image}
speechToTextConfig={config?.speech_to_text} speechToTextConfig={config?.speech_to_text}
onSend={onSend} onSend={onSend}
theme={themeBuilder?.theme}
/> />
) )
} }

View File

@ -6,6 +6,8 @@ import {
memo, memo,
} from 'react' } from 'react'
import type { ChatItem } from '../types' import type { ChatItem } from '../types'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import { QuestionTriangle } from '@/app/components/base/icons/src/vender/solid/general' import { QuestionTriangle } from '@/app/components/base/icons/src/vender/solid/general'
import { User } from '@/app/components/base/icons/src/public/avatar' import { User } from '@/app/components/base/icons/src/public/avatar'
import { Markdown } from '@/app/components/base/markdown' import { Markdown } from '@/app/components/base/markdown'
@ -14,10 +16,12 @@ import ImageGallery from '@/app/components/base/image-gallery'
type QuestionProps = { type QuestionProps = {
item: ChatItem item: ChatItem
questionIcon?: ReactNode questionIcon?: ReactNode
theme: Theme | null | undefined
} }
const Question: FC<QuestionProps> = ({ const Question: FC<QuestionProps> = ({
item, item,
questionIcon, questionIcon,
theme,
}) => { }) => {
const { const {
content, content,
@ -25,12 +29,17 @@ const Question: FC<QuestionProps> = ({
} = item } = item
const imgSrcs = message_files?.length ? message_files.map(item => item.url) : [] const imgSrcs = message_files?.length ? message_files.map(item => item.url) : []
return ( return (
<div className='flex justify-end mb-2 last:mb-0 pl-10'> <div className='flex justify-end mb-2 last:mb-0 pl-10'>
<div className='group relative mr-4'> <div className='group relative mr-4'>
<QuestionTriangle className='absolute -right-2 top-0 w-2 h-3 text-[#D1E9FF]/50' /> <QuestionTriangle
<div className='px-4 py-3 bg-[#D1E9FF]/50 rounded-b-2xl rounded-tl-2xl text-sm text-gray-900'> className='absolute -right-2 top-0 w-2 h-3 text-[#D1E9FF]/50'
style={theme ? { color: theme.chatBubbleColor } : {}}
/>
<div
className='px-4 py-3 bg-[#D1E9FF]/50 rounded-b-2xl rounded-tl-2xl text-sm text-gray-900'
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
{ {
!!imgSrcs.length && ( !!imgSrcs.length && (
<ImageGallery srcs={imgSrcs} /> <ImageGallery srcs={imgSrcs} />

View File

@ -32,6 +32,7 @@ const ChatWrapper = () => {
appMeta, appMeta,
handleFeedback, handleFeedback,
currentChatInstanceRef, currentChatInstanceRef,
themeBuilder,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const appConfig = useMemo(() => { const appConfig = useMemo(() => {
const config = appParams || {} const config = appParams || {}
@ -130,6 +131,7 @@ const ChatWrapper = () => {
suggestedQuestions={suggestedQuestions} suggestedQuestions={suggestedQuestions}
answerIcon={isDify() ? <LogoAvatar className='relative shrink-0' /> : null} answerIcon={isDify() ? <LogoAvatar className='relative shrink-0' /> : null}
hideProcessDetail hideProcessDetail
themeBuilder={themeBuilder}
/> />
) )
} }

View File

@ -2,6 +2,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import cn from 'classnames' import cn from 'classnames'
import { useEmbeddedChatbotContext } from '../context' import { useEmbeddedChatbotContext } from '../context'
import { useThemeContext } from '../theme/theme-context'
import { CssTransform } from '../theme/utils'
import Form from './form' import Form from './form'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
@ -22,6 +24,7 @@ const ConfigPanel = () => {
const [collapsed, setCollapsed] = useState(true) const [collapsed, setCollapsed] = useState(true)
const customConfig = appData?.custom_config const customConfig = appData?.custom_config
const site = appData?.site const site = appData?.site
const themeBuilder = useThemeContext()
return ( return (
<div className='flex flex-col max-h-[80%] w-full max-w-[720px]'> <div className='flex flex-col max-h-[80%] w-full max-w-[720px]'>
@ -34,6 +37,7 @@ const ConfigPanel = () => {
)} )}
> >
<div <div
style={CssTransform(themeBuilder.theme?.roundedBackgroundColorStyle ?? '')}
className={` className={`
flex flex-wrap px-6 py-4 rounded-t-xl bg-indigo-25 flex flex-wrap px-6 py-4 rounded-t-xl bg-indigo-25
${isMobile && '!px-4 !py-3'} ${isMobile && '!px-4 !py-3'}
@ -68,6 +72,7 @@ const ConfigPanel = () => {
{t('share.chat.configStatusDes')} {t('share.chat.configStatusDes')}
</div> </div>
<Button <Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
variant='secondary-accent' variant='secondary-accent'
size='small' size='small'
className='shrink-0' className='shrink-0'
@ -96,6 +101,7 @@ const ConfigPanel = () => {
<Form /> <Form />
<div className={cn('pl-[136px] flex items-center', isMobile && '!pl-0')}> <div className={cn('pl-[136px] flex items-center', isMobile && '!pl-0')}>
<Button <Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
variant='primary' variant='primary'
className='mr-2' className='mr-2'
onClick={() => { onClick={() => {
@ -119,6 +125,7 @@ const ConfigPanel = () => {
<div className='p-6 rounded-b-xl'> <div className='p-6 rounded-b-xl'>
<Form /> <Form />
<Button <Button
styleCss={CssTransform(themeBuilder.theme?.backgroundButtonDefaultColorStyle ?? '')}
className={cn(inputsForms.length && !isMobile && 'ml-[136px]')} className={cn(inputsForms.length && !isMobile && 'ml-[136px]')}
variant='primary' variant='primary'
size='large' size='large'

View File

@ -7,6 +7,7 @@ import type {
ChatItem, ChatItem,
Feedback, Feedback,
} from '../types' } from '../types'
import type { ThemeBuilder } from './theme/theme-context'
import type { import type {
AppConversationData, AppConversationData,
AppData, AppData,
@ -40,6 +41,7 @@ export type EmbeddedChatbotContextValue = {
appId?: string appId?: string
handleFeedback: (messageId: string, feedback: Feedback) => void handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }> currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder
} }
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({

View File

@ -2,18 +2,22 @@ import type { FC } from 'react'
import React from 'react' import React from 'react'
import { RiRefreshLine } from '@remixicon/react' import { RiRefreshLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { Theme } from './theme/theme-context'
import { CssTransform } from './theme/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
export type IHeaderProps = { export type IHeaderProps = {
isMobile?: boolean isMobile?: boolean
customerIcon?: React.ReactNode customerIcon?: React.ReactNode
title: string title: string
theme?: Theme
onCreateNewChat?: () => void onCreateNewChat?: () => void
} }
const Header: FC<IHeaderProps> = ({ const Header: FC<IHeaderProps> = ({
isMobile, isMobile,
customerIcon, customerIcon,
title, title,
theme,
onCreateNewChat, onCreateNewChat,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -23,14 +27,15 @@ const Header: FC<IHeaderProps> = ({
return ( return (
<div <div
className={` className={`
shrink-0 flex items-center justify-between h-14 px-4 bg-gray-100 shrink-0 flex items-center justify-between h-14 px-4
bg-gradient-to-r from-blue-600 to-sky-500
`} `}
style={Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? ''), CssTransform(theme?.headerBorderBottomStyle ?? '')) }
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{customerIcon} {customerIcon}
<div <div
className={'text-sm font-bold text-white'} className={'text-sm font-bold text-white'}
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
> >
{title} {title}
</div> </div>
@ -43,7 +48,7 @@ const Header: FC<IHeaderProps> = ({
<div className='flex cursor-pointer hover:rounded-lg hover:bg-black/5 w-8 h-8 items-center justify-center' onClick={() => { <div className='flex cursor-pointer hover:rounded-lg hover:bg-black/5 w-8 h-8 items-center justify-center' onClick={() => {
onCreateNewChat?.() onCreateNewChat?.()
}}> }}>
<RiRefreshLine className="h-4 w-4 text-sm font-bold text-white" /> <RiRefreshLine className="h-4 w-4 text-sm font-bold text-white" color={theme?.colorPathOnHeader}/>
</div> </div>
</Tooltip> </Tooltip>
</div> </div>

View File

@ -10,6 +10,7 @@ import {
} from './context' } from './context'
import { useEmbeddedChatbot } from './hooks' import { useEmbeddedChatbot } from './hooks'
import { isDify } from './utils' import { isDify } from './utils'
import { useThemeContext } from './theme/theme-context'
import { checkOrSetAccessToken } from '@/app/components/share/utils' import { checkOrSetAccessToken } from '@/app/components/share/utils'
import AppUnavailable from '@/app/components/base/app-unavailable' import AppUnavailable from '@/app/components/base/app-unavailable'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -29,6 +30,7 @@ const Chatbot = () => {
showConfigPanelBeforeChat, showConfigPanelBeforeChat,
appChatListDataLoading, appChatListDataLoading,
handleNewConversation, handleNewConversation,
themeBuilder,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length) const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length)
@ -38,6 +40,7 @@ const Chatbot = () => {
const difyIcon = <LogoHeader /> const difyIcon = <LogoHeader />
useEffect(() => { useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
if (site) { if (site) {
if (customConfig) if (customConfig)
document.title = `${site.title}` document.title = `${site.title}`
@ -63,6 +66,7 @@ const Chatbot = () => {
isMobile={isMobile} isMobile={isMobile}
title={site?.title || ''} title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''} customerIcon={isDify() ? difyIcon : ''}
theme={themeBuilder?.theme}
onCreateNewChat={handleNewConversation} onCreateNewChat={handleNewConversation}
/> />
<div className='flex bg-white overflow-hidden'> <div className='flex bg-white overflow-hidden'>
@ -87,6 +91,7 @@ const Chatbot = () => {
const EmbeddedChatbotWrapper = () => { const EmbeddedChatbotWrapper = () => {
const media = useBreakpoints() const media = useBreakpoints()
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const { const {
appInfoError, appInfoError,
@ -141,6 +146,7 @@ const EmbeddedChatbotWrapper = () => {
appId, appId,
handleFeedback, handleFeedback,
currentChatInstanceRef, currentChatInstanceRef,
themeBuilder,
}}> }}>
<Chatbot /> <Chatbot />
</EmbeddedChatbotContext.Provider> </EmbeddedChatbotContext.Provider>

View File

@ -0,0 +1,72 @@
import { createContext, useContext } from 'use-context-selector'
import { hexToRGBA } from './utils'
export class Theme {
public chatColorTheme: string | null
public chatColorThemeInverted: boolean
public primaryColor = '#1C64F2'
public backgroundHeaderColorStyle = 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)'
public headerBorderBottomStyle = ''
public colorFontOnHeaderStyle = 'color: white'
public colorPathOnHeader = 'white'
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
public chatBubbleColorStyle = 'backgroundColor: rgb(225 239 254)'
public chatBubbleColor = 'rgb(225 239 254)'
constructor(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
this.chatColorTheme = chatColorTheme
this.chatColorThemeInverted = chatColorThemeInverted
this.configCustomColor()
this.configInvertedColor()
}
private configCustomColor() {
if (this.chatColorTheme !== null && this.chatColorTheme !== '') {
this.primaryColor = this.chatColorTheme ?? '#1C64F2'
this.backgroundHeaderColorStyle = `backgroundColor: ${this.primaryColor}`
this.backgroundButtonDefaultColorStyle = `backgroundColor: ${this.primaryColor}`
this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
this.chatBubbleColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.15)}`
this.chatBubbleColor = `${hexToRGBA(this.primaryColor, 0.15)}`
}
}
private configInvertedColor() {
if (this.chatColorThemeInverted) {
this.backgroundHeaderColorStyle = 'backgroundColor: #ffffff'
this.colorFontOnHeaderStyle = `color: ${this.primaryColor}`
this.headerBorderBottomStyle = 'borderBottom: 1px solid #ccc'
this.colorPathOnHeader = this.primaryColor
}
}
}
export class ThemeBuilder {
private _theme?: Theme
private buildChecker = false
public get theme() {
if (this._theme === undefined)
throw new Error('The theme should be built first and then accessed')
else
return this._theme
}
public buildTheme(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
if (!this.buildChecker) {
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
this.buildChecker = true
}
else {
if (this.theme?.chatColorTheme !== chatColorTheme || this.theme?.chatColorThemeInverted !== chatColorThemeInverted) {
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
this.buildChecker = true
}
}
}
}
const ThemeContext = createContext<ThemeBuilder>(new ThemeBuilder())
export const useThemeContext = () => useContext(ThemeContext)

View File

@ -0,0 +1,29 @@
export function hexToRGBA(hex: string, opacity: number): string {
hex = hex.replace('#', '')
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
// Returning an RGB color object
return `rgba(${r},${g},${b},${opacity.toString()})`
}
/**
* Since strings cannot be directly assigned to the 'style' attribute in JSX,
* this method transforms the string into an object representation of the styles.
*/
export function CssTransform(cssString: string): object {
if (cssString.length === 0)
return {}
const style: object = {}
const propertyValuePairs = cssString.split(';')
for (const pair of propertyValuePairs) {
if (pair.trim().length > 0) {
const [property, value] = pair.split(':')
Object.assign(style, { [property.trim()]: value.trim() })
}
}
return style
}

View File

@ -33,7 +33,7 @@
"attributes": { "attributes": {
"d": "M6.96353 1.5547C7.40657 0.890144 6.93018 0 6.13148 0H0V12L6.96353 1.5547Z", "d": "M6.96353 1.5547C7.40657 0.890144 6.93018 0 6.13148 0H0V12L6.96353 1.5547Z",
"fill": "currentColor", "fill": "currentColor",
"fill-opacity": "0.5" "fill-opacity": "0"
}, },
"children": [] "children": []
} }

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Anzeigen', show: 'Anzeigen',
hide: 'Verbergen', hide: 'Verbergen',
}, },
chatColorTheme: 'Chat-Farbschema',
chatColorThemeDesc: 'Legen Sie das Farbschema des Chatbots fest',
chatColorThemeInverted: 'Invertiert',
invalidHexMessage: 'Ungültiger Hex-Wert',
more: { more: {
entry: 'Mehr Einstellungen anzeigen', entry: 'Mehr Einstellungen anzeigen',
copyright: 'Urheberrecht', copyright: 'Urheberrecht',

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Show', show: 'Show',
hide: 'Hide', hide: 'Hide',
}, },
chatColorTheme: 'Chat color theme',
chatColorThemeDesc: 'Set the color theme of the chatbot',
chatColorThemeInverted: 'Inverted',
invalidHexMessage: 'Invalid hex value',
more: { more: {
entry: 'Show more settings', entry: 'Show more settings',
copyright: 'Copyright', copyright: 'Copyright',

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Afficher', show: 'Afficher',
hide: 'Masquer', hide: 'Masquer',
}, },
chatColorTheme: 'Thème de couleur du chatbot',
chatColorThemeDesc: 'Définir le thème de couleur du chatbot',
chatColorThemeInverted: 'Inversé',
invalidHexMessage: 'Valeur hexadécimale invalide',
more: { more: {
entry: 'Afficher plus de paramètres', entry: 'Afficher plus de paramètres',
copyright: 'Droits d\'auteur', copyright: 'Droits d\'auteur',

View File

@ -53,6 +53,10 @@ const translation = {
show: 'दिखाएं', show: 'दिखाएं',
hide: 'छुपाएं', hide: 'छुपाएं',
}, },
chatColorTheme: 'चैटबॉट का रंग थीम',
chatColorThemeDesc: 'चैटबॉट का रंग थीम निर्धारित करें',
chatColorThemeInverted: 'उल्टा',
invalidHexMessage: 'अमान्य हेक्स मान',
more: { more: {
entry: 'अधिक सेटिंग्स दिखाएं', entry: 'अधिक सेटिंग्स दिखाएं',
copyright: 'कॉपीराइट', copyright: 'कॉपीराइट',

View File

@ -49,6 +49,10 @@ const translation = {
show: '表示', show: '表示',
hide: '非表示', hide: '非表示',
}, },
chatColorTheme: 'チャットボットのカラーテーマ',
chatColorThemeDesc: 'チャットボットのカラーテーマを設定します',
chatColorThemeInverted: '反転',
invalidHexMessage: '無効な16進数値',
more: { more: {
entry: 'その他の設定を表示', entry: 'その他の設定を表示',
copyright: '著作権', copyright: '著作権',

View File

@ -49,6 +49,10 @@ const translation = {
show: '표시', show: '표시',
hide: '숨기기', hide: '숨기기',
}, },
chatColorTheme: '챗봇 색상 테마',
chatColorThemeDesc: '챗봇의 색상 테마를 설정하세요',
chatColorThemeInverted: '반전',
invalidHexMessage: '잘못된 16진수 값',
more: { more: {
entry: '추가 설정 보기', entry: '추가 설정 보기',
copyright: '저작권', copyright: '저작권',

View File

@ -53,6 +53,10 @@ const translation = {
show: 'Pokaż', show: 'Pokaż',
hide: 'Ukryj', hide: 'Ukryj',
}, },
chatColorTheme: 'Motyw kolorystyczny czatu',
chatColorThemeDesc: 'Ustaw motyw kolorystyczny czatu',
chatColorThemeInverted: 'Odwrócony',
invalidHexMessage: 'Nieprawidłowa wartość heksadecymalna',
more: { more: {
entry: 'Pokaż więcej ustawień', entry: 'Pokaż więcej ustawień',
copyright: 'Prawa autorskie', copyright: 'Prawa autorskie',

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Mostrar', show: 'Mostrar',
hide: 'Ocultar', hide: 'Ocultar',
}, },
chatColorTheme: 'Tema de cor do chatbot',
chatColorThemeDesc: 'Defina o tema de cor do chatbot',
chatColorThemeInverted: 'Inve',
invalidHexMessage: 'Valor hex inválido',
more: { more: {
entry: 'Mostrar mais configurações', entry: 'Mostrar mais configurações',
copyright: 'Direitos autorais', copyright: 'Direitos autorais',

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Afișați', show: 'Afișați',
hide: 'Ascundeți', hide: 'Ascundeți',
}, },
chatColorTheme: 'Tema de culoare a chatului',
chatColorThemeDesc: 'Setați tema de culoare a chatbotului',
chatColorThemeInverted: 'Inversat',
invalidHexMessage: 'Valoare hex nevalidă',
more: { more: {
entry: 'Afișați mai multe setări', entry: 'Afișați mai multe setări',
copyright: 'Drepturi de autor', copyright: 'Drepturi de autor',

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Показати', show: 'Показати',
hide: 'Приховати', hide: 'Приховати',
}, },
chatColorTheme: 'Тема кольору чату',
chatColorThemeDesc: 'Встановіть тему кольору чат-бота',
chatColorThemeInverted: 'Інвертовано',
invalidHexMessage: 'Недійсне шістнадцяткове значення',
more: { more: {
entry: 'Показати додаткові налаштування', entry: 'Показати додаткові налаштування',
copyright: 'Авторське право', copyright: 'Авторське право',

View File

@ -49,6 +49,10 @@ const translation = {
show: 'Hiển thị', show: 'Hiển thị',
hide: 'Ẩn', hide: 'Ẩn',
}, },
chatColorTheme: 'Chủ đề màu sắc trò chuyện',
chatColorThemeDesc: 'Thiết lập chủ đề màu sắc của chatbot',
chatColorThemeInverted: 'Đảo ngược',
invalidHexMessage: 'Giá trị không hợp lệ của hệ màu hex',
more: { more: {
entry: 'Hiển thị thêm cài đặt', entry: 'Hiển thị thêm cài đặt',
copyright: 'Bản quyền', copyright: 'Bản quyền',

View File

@ -49,6 +49,10 @@ const translation = {
show: '显示', show: '显示',
hide: '隐藏', hide: '隐藏',
}, },
chatColorTheme: '聊天颜色主题',
chatColorThemeDesc: '设置聊天机器人的颜色主题',
chatColorThemeInverted: '反转',
invalidHexMessage: '无效的十六进制值',
more: { more: {
entry: '展示更多设置', entry: '展示更多设置',
copyright: '版权', copyright: '版权',

View File

@ -49,6 +49,10 @@ const translation = {
show: '展示', show: '展示',
hide: '隱藏', hide: '隱藏',
}, },
chatColorTheme: '聊天顏色主題',
chatColorThemeDesc: '設定聊天機器人的顏色主題',
chatColorThemeInverted: '反轉',
invalidHexMessage: '無效的十六進制值',
more: { more: {
entry: '展示更多設定', entry: '展示更多設定',
copyright: '版權', copyright: '版權',

View File

@ -11,6 +11,8 @@ export type ConversationItem = {
export type SiteInfo = { export type SiteInfo = {
title: string title: string
chat_color_theme?: string
chat_color_theme_inverted?: boolean
icon?: string icon?: string
icon_background?: string icon_background?: string
description?: string description?: string

View File

@ -246,6 +246,12 @@ export type SiteConfig = {
title: string title: string
/** Application Description will be shown in the Client */ /** Application Description will be shown in the Client */
description: string description: string
/** Define the color in hex for different elements of the chatbot, such as:
* The header, the button , etc.
*/
chat_color_theme: string
/** Invert the color of the theme set in chat_color_theme */
chat_color_theme_inverted: boolean
/** Author */ /** Author */
author: string author: string
/** User Support Email Address */ /** User Support Email Address */