diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx
index 6d74f646540..5c1e8b62cc2 100644
--- a/web/__tests__/base/chat-flow.test.tsx
+++ b/web/__tests__/base/chat-flow.test.tsx
@@ -1,11 +1,11 @@
import type { RefObject } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
-import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
+import { useChatWithHistoryContext } from '@/app/components/base/chat/chat-with-history/context'
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
-import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
@@ -14,6 +14,20 @@ vi.mock('@/app/components/base/chat/chat-with-history/hooks', () => ({
useChatWithHistory: vi.fn(),
}))
+vi.mock('@/app/components/base/chat/chat-with-history/chat-wrapper', () => {
+ const ChatThemeProbe = () => {
+ const { theme } = useChatWithHistoryContext()
+
+ return (
+
+ {theme?.primaryColor}:{String(theme?.chatColorThemeInverted)}
+
+ )
+ }
+
+ return { default: ChatThemeProbe }
+})
+
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
MediaType: {
@@ -109,24 +123,17 @@ describe('Base Chat Flow', () => {
vi.clearAllMocks()
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn)
- renderHook(() => useThemeContext()).result.current.buildTheme()
})
// Chat-with-history shell integration across layout, responsive shell, and theme setup.
describe('Chat With History Shell', () => {
- it('builds theme, updates the document title, and expands the collapsed desktop sidebar on hover', async () => {
- const themeBuilder = renderHook(() => useThemeContext()).result.current
+ it('updates the document title and expands the collapsed desktop sidebar on hover', () => {
const { container } = render()
const titles = screen.getAllByText('Test Chat')
expect(titles.length).toBeGreaterThan(0)
expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat')
- await waitFor(() => {
- expect(themeBuilder.theme.primaryColor).toBe('blue')
- expect(themeBuilder.theme.chatColorThemeInverted).toBe(false)
- })
-
vi.mocked(useChatWithHistory).mockReturnValue({
...defaultHookReturn,
sidebarCollapseState: true,
@@ -147,6 +154,27 @@ describe('Base Chat Flow', () => {
}
})
+ it('renders a new theme when site configuration changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByLabelText('chat theme')).toHaveTextContent('blue:false')
+
+ vi.mocked(useChatWithHistory).mockReturnValue({
+ ...defaultHookReturn,
+ appData: {
+ ...mockAppData,
+ site: {
+ ...mockAppData.site,
+ chat_color_theme: '#654321',
+ chat_color_theme_inverted: true,
+ },
+ },
+ })
+ rerender()
+
+ expect(screen.getByLabelText('chat theme')).toHaveTextContent('#654321:true')
+ })
+
it('falls back to the mobile loading shell when site metadata is unavailable', () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
vi.mocked(useChatWithHistory).mockReturnValue({
diff --git a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx
index 8bea2d6d14b..d775e339444 100644
--- a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx
+++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx
@@ -18,18 +18,9 @@ vi.mock('../style.module.css', () => ({
pluginInstallIcon: 'pluginInstallIcon',
},
}))
-const mockThemeBuilder = {
- buildTheme: vi.fn(),
- theme: {
- primaryColor: '#123456',
- },
-}
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
-vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({
- useThemeContext: () => mockThemeBuilder,
-}))
const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
const mockedCopy = vi.mocked(copy)
const originalCompressionStream = globalThis.CompressionStream
@@ -83,7 +74,7 @@ describe('Embedded', () => {
globalThis.CompressionStream = originalCompressionStream
})
- it('builds theme and copies iframe snippet', async () => {
+ it('copies iframe snippet', async () => {
await act(async () => {
render()
})
@@ -103,10 +94,6 @@ describe('Embedded', () => {
fireEvent.click(innerDiv ?? actionButton)
})
- expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(
- siteInfo.chat_color_theme,
- siteInfo.chat_color_theme_inverted,
- )
await waitFor(() => {
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
})
@@ -217,6 +204,7 @@ describe('Embedded', () => {
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain("token: 'token'")
+ expect(codeBlock?.textContent ?? '').toContain('background-color: #000000')
})
const actionButton = getCopyButton()
diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx
index 351e093dd18..810efbca6d6 100644
--- a/web/app/components/app/overview/embedded/index.tsx
+++ b/web/app/components/app/overview/embedded/index.tsx
@@ -10,10 +10,10 @@ import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgeni
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
import { useAtomValue } from 'jotai'
-import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
+import { Suspense, use, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
-import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
+import { createTheme } from '@/app/components/base/chat/embedded-chatbot/theme/theme'
import { InputVarType } from '@/app/components/workflow/types'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { basePath } from '@/utils/var'
@@ -142,7 +142,10 @@ const EmbeddedContent = ({
const latestResolvedIframeUrlRef = useRef('')
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
- const themeBuilder = useThemeContext()
+ const theme = createTheme(
+ siteInfo?.chat_color_theme ?? null,
+ siteInfo?.chat_color_theme_inverted ?? false,
+ )
const isTestEnv =
langGeniusVersionInfo.current_env === 'TESTING' ||
langGeniusVersionInfo.current_env === 'DEVELOPMENT'
@@ -171,18 +174,11 @@ const EmbeddedContent = ({
url: appBaseUrl,
token: accessToken,
webAppRoute,
- primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
+ primaryColor: theme.primaryColor,
isTestEnv,
inputValues: hiddenInputValues,
}),
- [
- accessToken,
- appBaseUrl,
- hiddenInputValues,
- isTestEnv,
- themeBuilder.theme?.primaryColor,
- webAppRoute,
- ],
+ [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, theme.primaryColor, webAppRoute],
)
const onClickCopy = async () => {
@@ -218,13 +214,6 @@ const EmbeddedContent = ({
)
}
- useEffect(() => {
- themeBuilder.buildTheme(
- siteInfo?.chat_color_theme ?? null,
- siteInfo?.chat_color_theme_inverted ?? false,
- )
- }, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder])
-
return (
<>
diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx
index bf6a1726d30..843adb7f5d7 100644
--- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx
+++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx
@@ -37,12 +37,6 @@ vi.mock('@/next/navigation', () => ({
useParams: vi.fn(() => ({})),
}))
-vi.mock('../../embedded-chatbot/theme/theme-context', () => ({
- useThemeContext: vi.fn(() => ({
- buildTheme: vi.fn(),
- })),
-}))
-
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
index 9bdcb00fe01..3fc3eb69aa9 100644
--- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
+++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
@@ -48,7 +48,7 @@ const ChatWrapper = () => {
handleFeedback,
currentChatInstanceRef,
appData,
- themeBuilder,
+ theme,
sidebarCollapseState,
clearChatList,
setClearChatList,
@@ -456,7 +456,7 @@ const ChatWrapper = () => {
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
- themeBuilder={themeBuilder}
+ theme={theme}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
sidebarCollapseState={sidebarCollapseState}
diff --git a/web/app/components/base/chat/chat-with-history/context.ts b/web/app/components/base/chat/chat-with-history/context.ts
index 0f34afbd254..89496d39563 100644
--- a/web/app/components/base/chat/chat-with-history/context.ts
+++ b/web/app/components/base/chat/chat-with-history/context.ts
@@ -2,7 +2,7 @@
import type { RefObject } from 'react'
import type { ChatProps } from '../chat'
-import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
+import type { Theme } from '../embedded-chatbot/theme/theme'
import type { Callback, ChatConfig, ChatItemInTree, Feedback } from '../types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { noop } from 'es-toolkit/function'
@@ -37,7 +37,7 @@ export type ChatWithHistoryContextValue = {
appId?: string
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
- themeBuilder?: ThemeBuilder
+ theme?: Theme
sidebarCollapseState?: boolean
handleSidebarCollapse: (state: boolean) => void
clearChatList?: boolean
diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx
index d0a7e427863..645e25662b1 100644
--- a/web/app/components/base/chat/chat-with-history/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/index.tsx
@@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
-import { useThemeContext } from '../embedded-chatbot/theme/theme-context'
+import { createTheme } from '../embedded-chatbot/theme/theme'
import ChatWrapper from './chat-wrapper'
import { ChatWithHistoryContext, useChatWithHistoryContext } from './context'
import Header from './header'
@@ -19,24 +19,13 @@ type ChatWithHistoryProps = {
className?: string
}
const ChatWithHistory: FC
= ({ className }) => {
- const {
- appData,
- appChatListDataLoading,
- chatShouldReloadKey,
- isMobile,
- themeBuilder,
- sidebarCollapseState,
- } = useChatWithHistoryContext()
+ const { appData, appChatListDataLoading, chatShouldReloadKey, isMobile, sidebarCollapseState } =
+ useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState
- const customConfig = appData?.custom_config
const site = appData?.site
const [showSidePanel, setShowSidePanel] = useState(false)
- useEffect(() => {
- themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
- }, [site, customConfig, themeBuilder])
-
useEffect(() => {
if (!isSidebarCollapsed) setShowSidePanel(false)
}, [isSidebarCollapsed])
@@ -100,7 +89,6 @@ const ChatWithHistoryWrap: FC = ({
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
- const themeBuilder = useThemeContext()
const {
appData,
@@ -141,6 +129,10 @@ const ChatWithHistoryWrap: FC = ({
allInputsHidden,
initUserVariables,
} = useChatWithHistory(installedAppInfo)
+ const theme = createTheme(
+ appData?.site?.chat_color_theme ?? null,
+ appData?.site?.chat_color_theme_inverted ?? false,
+ )
return (
= ({
appId,
handleFeedback,
currentChatInstanceRef,
- themeBuilder,
+ theme,
sidebarCollapseState,
handleSidebarCollapse,
clearChatList,
diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx
index 1079ee6494e..2c2c000043a 100644
--- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx
+++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/index.spec.tsx
@@ -44,7 +44,7 @@ const defaultContextValues: Partial = {
currentConversationId: '',
handleStartChat: mockHandleStartChat,
allInputsHidden: false,
- themeBuilder: undefined,
+ theme: undefined,
inputsForms: [{ variable: 'test_var', type: InputVarType.textInput, label: 'Test Label' }],
currentConversationInputs: {},
newConversationInputs: {},
@@ -112,9 +112,7 @@ describe('InputsFormNode', () => {
setMockContext({
currentConversationId: '',
- themeBuilder: {
- theme: { primaryColor: themeColor },
- } as unknown as ChatWithHistoryContextValue['themeBuilder'],
+ theme: { primaryColor: themeColor } as unknown as ChatWithHistoryContextValue['theme'],
})
render()
diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx
index 0b77ef3361c..883045cbd8e 100644
--- a/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx
@@ -14,14 +14,8 @@ type Props = Readonly<{
const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
const { t } = useTranslation()
- const {
- isMobile,
- currentConversationId,
- handleStartChat,
- allInputsHidden,
- themeBuilder,
- inputsForms,
- } = useChatWithHistoryContext()
+ const { isMobile, currentConversationId, handleStartChat, allInputsHidden, theme, inputsForms } =
+ useChatWithHistoryContext()
if (allInputsHidden || inputsForms.length === 0) return null
@@ -77,9 +71,9 @@ const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
className="w-full"
onClick={() => handleStartChat(() => setCollapsed(true))}
style={
- themeBuilder?.theme
+ theme
? {
- backgroundColor: themeBuilder?.theme.primaryColor,
+ backgroundColor: theme.primaryColor,
}
: {}
}
diff --git a/web/app/components/base/chat/chat/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/__tests__/index.spec.tsx
index 1bd4e20fece..72185a62cdd 100644
--- a/web/app/components/base/chat/chat/__tests__/index.spec.tsx
+++ b/web/app/components/base/chat/chat/__tests__/index.spec.tsx
@@ -4,6 +4,7 @@ import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
+import { createTheme } from '../../embedded-chatbot/theme/theme'
import Chat from '../index'
// ─── Why each mock exists ─────────────────────────────────────────────────────
@@ -33,8 +34,8 @@ vi.mock('../answer', () => ({
}))
vi.mock('../question', () => ({
- default: ({ item }: { item: ChatItem }) => (
-
+ default: ({ item, theme }: { item: ChatItem; theme?: { primaryColor: string } }) => (
+
{item.content}
),
@@ -48,6 +49,7 @@ vi.mock('../chat-input-area', () => ({
footerNotice,
onBeforeSpeechToText,
speechToTextTarget,
+ theme,
}: {
customPlaceholder?: string
disabled?: boolean
@@ -55,6 +57,7 @@ vi.mock('../chat-input-area', () => ({
footerNotice?: string
onBeforeSpeechToText?: () => Promise
speechToTextTarget?: SpeechToTextTarget
+ theme?: { primaryColor: string }
}) => (
({
? speechToTextTarget.appSourceType
: speechToTextTarget?.type
}
+ data-theme-color={theme?.primaryColor}
>
{footerNotice}
@@ -668,15 +672,14 @@ describe('Chat', () => {
expect(screen.getByTestId('question-item')).toBeInTheDocument()
})
- it('should pass theme from themeBuilder to Question', () => {
- const mockTheme = { chatBubbleColorStyle: 'test' }
- const themeBuilder = { theme: mockTheme }
+ it('should pass theme to Question', () => {
+ const theme = createTheme('#123456')
renderChat({
- themeBuilder: themeBuilder as unknown as ChatProps['themeBuilder'],
+ theme,
chatList: [makeChatItem({ id: 'q1', isAnswer: false })],
})
- expect(screen.getByTestId('question-item')).toBeInTheDocument()
+ expect(screen.getByTestId('question-item')).toHaveAttribute('data-theme-color', '#123456')
})
it('should pass switchSibling to Question component', () => {
@@ -944,15 +947,14 @@ describe('Chat', () => {
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
})
- it('should pass theme from themeBuilder to ChatInputArea', () => {
- const mockTheme = { someThemeProperty: true }
- const themeBuilder = { theme: mockTheme }
+ it('should pass theme to ChatInputArea', () => {
+ const theme = createTheme('#654321')
renderChat({
noChatInput: false,
- themeBuilder: themeBuilder as unknown as ChatProps['themeBuilder'],
+ theme,
})
- expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
+ expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-theme-color', '#654321')
})
})
diff --git a/web/app/components/base/chat/chat/__tests__/question.spec.tsx b/web/app/components/base/chat/chat/__tests__/question.spec.tsx
index 034ba95fec0..fa1fa3cba37 100644
--- a/web/app/components/base/chat/chat/__tests__/question.spec.tsx
+++ b/web/app/components/base/chat/chat/__tests__/question.spec.tsx
@@ -1,4 +1,4 @@
-import type { Theme } from '../../embedded-chatbot/theme/theme-context'
+import type { Theme } from '../../embedded-chatbot/theme/theme'
import type { ChatConfig, ChatItem, OnRegenerate } from '../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { toast } from '@langgenius/dify-ui/toast'
@@ -6,7 +6,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import * as React from 'react'
-import { ThemeBuilder } from '../../embedded-chatbot/theme/theme-context'
+import { createTheme } from '../../embedded-chatbot/theme/theme'
import { ChatContextProvider } from '../context-provider'
import Question from '../question'
@@ -402,9 +402,7 @@ describe('Question component', () => {
})
it('should apply theme bubble styles when theme provided', () => {
- const themeBuilder = new ThemeBuilder()
- themeBuilder.buildTheme('#ff0000', false)
- const theme = themeBuilder.theme
+ const theme = createTheme('#ff0000')
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
@@ -783,9 +781,7 @@ describe('Question component', () => {
})
it('should render theme styles only in non-edit mode', () => {
- const themeBuilder = new ThemeBuilder()
- themeBuilder.buildTheme('#00ff00', true)
- const theme = themeBuilder.theme
+ const theme = createTheme('#00ff00', true)
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx
index 32fdc40c9fd..3d10ce26157 100644
--- a/web/app/components/base/chat/chat/chat-input-area/index.tsx
+++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
-import type { Theme } from '../../embedded-chatbot/theme/theme-context'
+import type { Theme } from '../../embedded-chatbot/theme/theme'
import type { EnableType, OnSend } from '../../types'
import type { InputForm } from '../type'
import type { FileUpload } from '@/app/components/base/features/types'
diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx
index 2e9a10605bb..a1d077845c0 100644
--- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx
+++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx
@@ -1,5 +1,5 @@
import type { FC, Ref } from 'react'
-import type { Theme } from '../../embedded-chatbot/theme/theme-context'
+import type { Theme } from '../../embedded-chatbot/theme/theme'
import type { EnableType } from '../../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { Button } from '@langgenius/dify-ui/button'
diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx
index 51466a847b5..acaae0eb32b 100644
--- a/web/app/components/base/chat/chat/index.tsx
+++ b/web/app/components/base/chat/chat/index.tsx
@@ -1,5 +1,5 @@
import type { FC, ReactNode } from 'react'
-import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
+import type { Theme } from '../embedded-chatbot/theme/theme'
import type { ChatConfig, ChatItem, Feedback, OnRegenerate, OnSend } from '../types'
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
import type { AnswerActionPosition } from './answer/operation'
@@ -62,7 +62,7 @@ export type ChatProps = {
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
hideLogModal?: boolean
- themeBuilder?: ThemeBuilder
+ theme?: Theme
switchSibling?: (siblingMessageId: string) => void
showFeatureBar?: boolean
showFileUpload?: boolean
@@ -123,7 +123,7 @@ const Chat: FC = ({
chatAnswerContainerInner,
hideProcessDetail,
hideLogModal,
- themeBuilder,
+ theme,
switchSibling,
showFeatureBar,
showFileUpload,
@@ -242,7 +242,7 @@ const Chat: FC = ({
key={item.id}
item={item}
questionIcon={questionIcon}
- theme={themeBuilder?.theme}
+ theme={theme}
enableEdit={config?.questionEditEnable}
switchSibling={switchSibling}
hideAvatar={hideAvatar}
@@ -298,7 +298,7 @@ const Chat: FC = ({
onSend={onSend}
inputs={inputs}
inputsForm={inputsForm}
- theme={themeBuilder?.theme}
+ theme={theme}
isResponding={isResponding}
readonly={readonly}
sendButtonLabel={sendButtonLabel}
diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx
index 1e435ba1c32..e3b869d8ae9 100644
--- a/web/app/components/base/chat/chat/question.tsx
+++ b/web/app/components/base/chat/chat/question.tsx
@@ -1,5 +1,5 @@
import type { FC, ReactNode } from 'react'
-import type { Theme } from '../embedded-chatbot/theme/theme-context'
+import type { Theme } from '../embedded-chatbot/theme/theme'
import type { ChatItem } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx
index f06a28de716..bdff3289b74 100644
--- a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx
@@ -190,7 +190,7 @@ const createContextValue = (
disableFeedback: false,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
- themeBuilder: undefined,
+ theme: undefined,
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx
index 2d966c8d192..ba36785f98e 100644
--- a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx
@@ -1,5 +1,6 @@
import type { ReactElement, RefObject } from 'react'
import type { ChatConfig } from '../../types'
+import type { Theme } from '../theme/theme'
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
import { screen } from '@testing-library/react'
import { vi } from 'vitest'
@@ -40,16 +41,14 @@ vi.mock('../chat-wrapper', () => ({
vi.mock('../header', () => ({
__esModule: true,
- default: () => chat header
,
-}))
-
-vi.mock('../theme/theme-context', () => ({
- useThemeContext: vi.fn(() => ({
- buildTheme: vi.fn(),
- theme: {
- backgroundHeaderColorStyle: '',
- },
- })),
+ default: ({ theme }: { theme?: Theme }) => (
+
+ chat header
+
+ {theme?.primaryColor}:{String(theme?.chatColorThemeInverted)}
+
+
+ ),
}))
const mockIsDify = vi.fn(() => false)
@@ -59,25 +58,25 @@ vi.mock('../utils', () => ({
type EmbeddedChatbotHookReturn = ReturnType
+const createAppData = (chatColorTheme = 'blue', chatColorThemeInverted = false): AppData => ({
+ app_id: 'app-1',
+ can_replace_logo: true,
+ custom_config: {
+ remove_webapp_brand: false,
+ replace_webapp_logo: '',
+ },
+ enable_site: true,
+ end_user_id: 'user-1',
+ site: {
+ title: 'Embedded App',
+ chat_color_theme: chatColorTheme,
+ chat_color_theme_inverted: chatColorThemeInverted,
+ },
+})
+
const createHookReturn = (
overrides: Partial = {},
): EmbeddedChatbotHookReturn => {
- const appData: AppData = {
- app_id: 'app-1',
- can_replace_logo: true,
- custom_config: {
- remove_webapp_brand: false,
- replace_webapp_logo: '',
- },
- enable_site: true,
- end_user_id: 'user-1',
- site: {
- title: 'Embedded App',
- chat_color_theme: 'blue',
- chat_color_theme_inverted: false,
- },
- }
-
const base: EmbeddedChatbotHookReturn = {
appSourceType: 'webApp' as EmbeddedChatbotHookReturn['appSourceType'],
isInstalledApp: false,
@@ -86,7 +85,7 @@ const createHookReturn = (
currentConversationItem: undefined,
removeConversationIdInfo: vi.fn(),
handleConversationIdInfoChange: vi.fn(),
- appData,
+ appData: createAppData(),
appParams: {} as ChatConfig,
appMeta: { tool_icons: {} } as AppMeta,
appPinnedConversationData: { data: [], has_more: false, limit: 20 },
@@ -154,6 +153,53 @@ describe('EmbeddedChatbot index', () => {
})
})
+ describe('Theme ownership', () => {
+ it('keeps themes isolated between chat roots', () => {
+ vi.mocked(useEmbeddedChatbot)
+ .mockReturnValueOnce(
+ createHookReturn({
+ appData: createAppData('#FF0000'),
+ }),
+ )
+ .mockReturnValueOnce(
+ createHookReturn({
+ appData: createAppData('#00FF00', true),
+ }),
+ )
+
+ render(
+ <>
+
+
+ >,
+ )
+
+ expect(screen.getAllByLabelText('chat theme')).toHaveLength(2)
+ expect(screen.getAllByLabelText('chat theme')[0]).toHaveTextContent('#FF0000:false')
+ expect(screen.getAllByLabelText('chat theme')[1]).toHaveTextContent('#00FF00:true')
+ })
+
+ it('renders a new theme when site configuration changes', () => {
+ vi.mocked(useEmbeddedChatbot).mockReturnValue(
+ createHookReturn({
+ appData: createAppData('#123456'),
+ }),
+ )
+ const { rerender } = render()
+
+ expect(screen.getByLabelText('chat theme')).toHaveTextContent('#123456:false')
+
+ vi.mocked(useEmbeddedChatbot).mockReturnValue(
+ createHookReturn({
+ appData: createAppData('#654321', true),
+ }),
+ )
+ rerender()
+
+ expect(screen.getByLabelText('chat theme')).toHaveTextContent('#654321:true')
+ })
+ })
+
describe('Powered by branding', () => {
it('should show workspace logo on mobile when branding is enabled', () => {
mockBrandingWorkspaceLogo = 'https://example.com/workspace-logo.png'
diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
index 493036889d6..b4be34faf3c 100644
--- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
@@ -49,7 +49,7 @@ const ChatWrapper = () => {
disableFeedback,
handleFeedback,
currentChatInstanceRef,
- themeBuilder,
+ theme,
clearChatList,
setClearChatList,
setIsResponding,
@@ -464,7 +464,7 @@ const ChatWrapper = () => {
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
- themeBuilder={themeBuilder}
+ theme={theme}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
sendOnEnter={sendOnEnter}
diff --git a/web/app/components/base/chat/embedded-chatbot/context.ts b/web/app/components/base/chat/embedded-chatbot/context.ts
index d9c9812ba2a..658802eb533 100644
--- a/web/app/components/base/chat/embedded-chatbot/context.ts
+++ b/web/app/components/base/chat/embedded-chatbot/context.ts
@@ -2,7 +2,7 @@
import type { RefObject } from 'react'
import type { ChatConfig, ChatItem, Feedback } from '../types'
-import type { ThemeBuilder } from './theme/theme-context'
+import type { Theme } from './theme/theme'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { noop } from 'es-toolkit/function'
import { createContext, useContext } from 'use-context-selector'
@@ -35,7 +35,7 @@ export type EmbeddedChatbotContextValue = {
disableFeedback?: boolean
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
- themeBuilder?: ThemeBuilder
+ theme?: Theme
clearChatList?: boolean
setClearChatList: (state: boolean) => void
isResponding?: boolean
diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx
index 9c307d3fd66..6f3aa4024fb 100644
--- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx
@@ -1,5 +1,5 @@
import type { FC } from 'react'
-import type { Theme } from '../theme/theme-context'
+import type { Theme } from '../theme/theme'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx
index 06622364671..39f254dd0b8 100644
--- a/web/app/components/base/chat/embedded-chatbot/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/index.tsx
@@ -2,7 +2,6 @@
import type { AppData } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
-import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
import Header from '@/app/components/base/chat/embedded-chatbot/header'
@@ -15,7 +14,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
import { AppSourceType } from '@/service/share'
import { EmbeddedChatbotContext, useEmbeddedChatbotContext } from './context'
import { useEmbeddedChatbot } from './hooks'
-import { useThemeContext } from './theme/theme-context'
+import { createTheme } from './theme/theme'
import { CssTransform } from './theme/utils'
import { isDify } from './utils'
@@ -27,20 +26,15 @@ const Chatbot = () => {
appChatListDataLoading,
chatShouldReloadKey,
handleNewConversation,
- themeBuilder,
+ theme,
} = useEmbeddedChatbotContext()
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
- const customConfig = appData?.custom_config
const site = appData?.site
const difyIcon =
- useEffect(() => {
- themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
- }, [site, customConfig, themeBuilder])
-
useDocumentTitle(site?.title || 'Chat')
return (
@@ -51,9 +45,7 @@ const Chatbot = () => {
isMobile ? 'h-[calc(100vh-60px)] shadow-xs' : 'h-screen bg-chatbot-bg',
)}
style={
- isMobile
- ? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? ''))
- : {}
+ isMobile ? Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? '')) : {}
}
>
{
allowResetChat={allowResetChat}
title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''}
- theme={themeBuilder?.theme}
+ theme={theme}
onCreateNewChat={handleNewConversation}
/>
{
const EmbeddedChatbotWrapper = () => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
- const themeBuilder = useThemeContext()
const {
appData,
@@ -143,6 +134,10 @@ const EmbeddedChatbotWrapper = () => {
allInputsHidden,
initUserVariables,
} = useEmbeddedChatbot(AppSourceType.webApp)
+ const theme = createTheme(
+ appData?.site?.chat_color_theme ?? null,
+ appData?.site?.chat_color_theme_inverted ?? false,
+ )
return (
{
appId,
handleFeedback,
currentChatInstanceRef,
- themeBuilder,
+ theme,
clearChatList,
setClearChatList,
isResponding,
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx
index ec3122f8fce..a2d9b930f4f 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/index.spec.tsx
@@ -18,7 +18,7 @@ const mockContextValue = {
appSourceType: AppSourceType.webApp,
isMobile: false,
currentConversationId: null,
- themeBuilder: null,
+ theme: undefined,
handleStartChat: vi.fn(),
allInputsHidden: false,
inputsForms: [{ variable: 'test' }],
@@ -98,10 +98,8 @@ describe('InputsFormNode', () => {
it('should apply theme primary color to start chat button', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...mockContextValue,
- themeBuilder: {
- theme: {
- primaryColor: '#ff0000',
- },
+ theme: {
+ primaryColor: '#ff0000',
},
} as unknown as any)
render()
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
index 9b999a15744..7a7e9c39399 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
@@ -18,7 +18,7 @@ const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
appSourceType,
isMobile,
currentConversationId,
- themeBuilder,
+ theme,
handleStartChat,
allInputsHidden,
inputsForms,
@@ -87,9 +87,9 @@ const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
className="w-full"
onClick={() => handleStartChat(() => setCollapsed(true))}
style={
- themeBuilder?.theme
+ theme
? {
- backgroundColor: themeBuilder?.theme.primaryColor,
+ backgroundColor: theme.primaryColor,
}
: {}
}
diff --git a/web/app/components/base/chat/embedded-chatbot/theme/__tests__/theme-context.spec.ts b/web/app/components/base/chat/embedded-chatbot/theme/__tests__/theme-context.spec.ts
deleted file mode 100644
index ad2fc1bc097..00000000000
--- a/web/app/components/base/chat/embedded-chatbot/theme/__tests__/theme-context.spec.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { Theme, ThemeBuilder, useThemeContext } from '../theme-context'
-
-// Scenario: Theme class configures colors from chatColorTheme and chatColorThemeInverted flags.
-describe('Theme', () => {
- describe('Default colors', () => {
- it('should use default primary color when chatColorTheme is null', () => {
- const theme = new Theme(null, false)
-
- expect(theme.primaryColor).toBe('#1C64F2')
- })
-
- it('should use gradient background header when chatColorTheme is null', () => {
- const theme = new Theme(null, false)
-
- expect(theme.backgroundHeaderColorStyle).toBe(
- 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
- )
- })
-
- it('should have empty chatBubbleColorStyle when chatColorTheme is null', () => {
- const theme = new Theme(null, false)
-
- expect(theme.chatBubbleColorStyle).toBe('')
- })
-
- it('should use default colors when chatColorTheme is empty string', () => {
- const theme = new Theme('', false)
-
- expect(theme.primaryColor).toBe('#1C64F2')
- expect(theme.backgroundHeaderColorStyle).toBe(
- 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
- )
- })
- })
-
- describe('Custom color (configCustomColor)', () => {
- it('should set primaryColor to chatColorTheme value', () => {
- const theme = new Theme('#FF5733', false)
-
- expect(theme.primaryColor).toBe('#FF5733')
- })
-
- it('should set backgroundHeaderColorStyle to solid custom color', () => {
- const theme = new Theme('#FF5733', false)
-
- expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #FF5733')
- })
-
- it('should include primary color in backgroundButtonDefaultColorStyle', () => {
- const theme = new Theme('#FF5733', false)
-
- expect(theme.backgroundButtonDefaultColorStyle).toContain('#FF5733')
- })
-
- it('should set roundedBackgroundColorStyle with 5% opacity rgba', () => {
- const theme = new Theme('#FF5733', false)
-
- // #FF5733 → r=255 g=87 b=51
- expect(theme.roundedBackgroundColorStyle).toBe('backgroundColor: rgba(255,87,51,0.05)')
- })
-
- it('should set chatBubbleColorStyle with 15% opacity rgba', () => {
- const theme = new Theme('#FF5733', false)
-
- expect(theme.chatBubbleColorStyle).toBe('backgroundColor: rgba(255,87,51,0.15)')
- })
- })
-
- describe('Inverted color (configInvertedColor)', () => {
- it('should use white background header when inverted with no custom color', () => {
- const theme = new Theme(null, true)
-
- expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #ffffff')
- })
-
- it('should set colorFontOnHeaderStyle to default primaryColor when inverted with no custom color', () => {
- const theme = new Theme(null, true)
-
- expect(theme.colorFontOnHeaderStyle).toBe('color: #1C64F2')
- })
-
- it('should set headerBorderBottomStyle when inverted', () => {
- const theme = new Theme(null, true)
-
- expect(theme.headerBorderBottomStyle).toBe('borderBottom: 1px solid #ccc')
- })
-
- it('should set colorPathOnHeader to primaryColor when inverted', () => {
- const theme = new Theme(null, true)
-
- expect(theme.colorPathOnHeader).toBe('#1C64F2')
- })
-
- it('should have empty headerBorderBottomStyle when not inverted', () => {
- const theme = new Theme(null, false)
-
- expect(theme.headerBorderBottomStyle).toBe('')
- })
- })
-
- describe('Custom color + inverted combined', () => {
- it('should override background to white even when custom color is set', () => {
- const theme = new Theme('#FF5733', true)
-
- // configCustomColor runs first (solid bg), then configInvertedColor overrides to white
- expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #ffffff')
- })
-
- it('should use custom primaryColor for colorFontOnHeaderStyle when inverted', () => {
- const theme = new Theme('#FF5733', true)
-
- expect(theme.colorFontOnHeaderStyle).toBe('color: #FF5733')
- })
-
- it('should set colorPathOnHeader to custom primaryColor when inverted', () => {
- const theme = new Theme('#FF5733', true)
-
- expect(theme.colorPathOnHeader).toBe('#FF5733')
- })
- })
-})
-
-// Scenario: ThemeBuilder manages a lazily-created Theme instance and rebuilds on config change.
-describe('ThemeBuilder', () => {
- describe('theme getter', () => {
- it('should create a default Theme when _theme is undefined (first access)', () => {
- const builder = new ThemeBuilder()
-
- const theme = builder.theme
-
- expect(theme).toBeInstanceOf(Theme)
- expect(theme.primaryColor).toBe('#1C64F2')
- })
-
- it('should return the same Theme instance on subsequent accesses', () => {
- const builder = new ThemeBuilder()
-
- const first = builder.theme
- const second = builder.theme
-
- expect(first).toBe(second)
- })
- })
-
- describe('buildTheme', () => {
- it('should create a Theme with the given color on first call', () => {
- const builder = new ThemeBuilder()
-
- builder.buildTheme('#AABBCC', false)
-
- expect(builder.theme.primaryColor).toBe('#AABBCC')
- })
-
- it('should not rebuild the Theme when called again with the same config', () => {
- const builder = new ThemeBuilder()
- builder.buildTheme('#AABBCC', false)
- const themeAfterFirstBuild = builder.theme
-
- builder.buildTheme('#AABBCC', false)
-
- // Same instance: no rebuild occurred
- expect(builder.theme).toBe(themeAfterFirstBuild)
- })
-
- it('should rebuild the Theme when chatColorTheme changes', () => {
- const builder = new ThemeBuilder()
- builder.buildTheme('#AABBCC', false)
- const originalTheme = builder.theme
-
- builder.buildTheme('#FF0000', false)
-
- expect(builder.theme).not.toBe(originalTheme)
- expect(builder.theme.primaryColor).toBe('#FF0000')
- })
-
- it('should rebuild the Theme when chatColorThemeInverted changes', () => {
- const builder = new ThemeBuilder()
- builder.buildTheme('#AABBCC', false)
- const originalTheme = builder.theme
-
- builder.buildTheme('#AABBCC', true)
-
- expect(builder.theme).not.toBe(originalTheme)
- expect(builder.theme.chatColorThemeInverted).toBe(true)
- })
-
- it('should use default args (null, false) when called with no arguments', () => {
- const builder = new ThemeBuilder()
-
- builder.buildTheme()
-
- expect(builder.theme.chatColorTheme).toBeNull()
- expect(builder.theme.chatColorThemeInverted).toBe(false)
- })
-
- it('should store chatColorTheme and chatColorThemeInverted on the built Theme', () => {
- const builder = new ThemeBuilder()
-
- builder.buildTheme('#123456', true)
-
- expect(builder.theme.chatColorTheme).toBe('#123456')
- expect(builder.theme.chatColorThemeInverted).toBe(true)
- })
- })
-})
-
-// Scenario: useThemeContext returns a ThemeBuilder from the nearest ThemeContext.
-describe('useThemeContext', () => {
- it('should return a ThemeBuilder instance from the default context', () => {
- const { result } = renderHook(() => useThemeContext())
-
- expect(result.current).toBeInstanceOf(ThemeBuilder)
- })
-
- it('should expose a valid theme on the returned ThemeBuilder', () => {
- const { result } = renderHook(() => useThemeContext())
-
- expect(result.current.theme).toBeInstanceOf(Theme)
- })
-})
diff --git a/web/app/components/base/chat/embedded-chatbot/theme/__tests__/theme.spec.ts b/web/app/components/base/chat/embedded-chatbot/theme/__tests__/theme.spec.ts
new file mode 100644
index 00000000000..2ef51e22291
--- /dev/null
+++ b/web/app/components/base/chat/embedded-chatbot/theme/__tests__/theme.spec.ts
@@ -0,0 +1,59 @@
+import { createTheme } from '../theme'
+
+describe('createTheme', () => {
+ it('uses the default palette when no custom color is configured', () => {
+ const theme = createTheme()
+
+ expect(theme).toMatchObject({
+ chatColorTheme: null,
+ chatColorThemeInverted: false,
+ primaryColor: '#1C64F2',
+ backgroundHeaderColorStyle: 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
+ headerBorderBottomStyle: '',
+ colorFontOnHeaderStyle: 'color: white',
+ colorPathOnHeader: 'text-text-primary-on-surface',
+ backgroundButtonDefaultColorStyle: 'backgroundColor: #1C64F2',
+ roundedBackgroundColorStyle: 'backgroundColor: rgb(245 248 255)',
+ chatBubbleColorStyle: '',
+ })
+ })
+
+ it('treats an empty custom color as the default palette', () => {
+ expect(createTheme('')).toMatchObject({
+ chatColorTheme: '',
+ primaryColor: '#1C64F2',
+ backgroundHeaderColorStyle: 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
+ chatBubbleColorStyle: '',
+ })
+ })
+
+ it('derives the custom palette without mutating another theme', () => {
+ const firstTheme = createTheme('#FF5733')
+ const secondTheme = createTheme('#123456')
+
+ expect(firstTheme).toMatchObject({
+ chatColorTheme: '#FF5733',
+ primaryColor: '#FF5733',
+ backgroundHeaderColorStyle: 'backgroundColor: #FF5733',
+ backgroundButtonDefaultColorStyle: 'backgroundColor: #FF5733; color: color: white;',
+ roundedBackgroundColorStyle: 'backgroundColor: rgba(255,87,51,0.05)',
+ chatBubbleColorStyle: 'backgroundColor: rgba(255,87,51,0.15)',
+ })
+ expect(secondTheme.primaryColor).toBe('#123456')
+ expect(firstTheme.primaryColor).toBe('#FF5733')
+ expect(Object.isFrozen(firstTheme)).toBe(true)
+ expect(Reflect.set(firstTheme, 'primaryColor', '#000000')).toBe(false)
+ expect(firstTheme.primaryColor).toBe('#FF5733')
+ })
+
+ it('derives the inverted palette from the configured primary color', () => {
+ expect(createTheme('#FF5733', true)).toMatchObject({
+ chatColorThemeInverted: true,
+ primaryColor: '#FF5733',
+ backgroundHeaderColorStyle: 'backgroundColor: #ffffff',
+ headerBorderBottomStyle: 'borderBottom: 1px solid #ccc',
+ colorFontOnHeaderStyle: 'color: #FF5733',
+ colorPathOnHeader: '#FF5733',
+ })
+ })
+})
diff --git a/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts b/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts
deleted file mode 100644
index 64d4253754b..00000000000
--- a/web/app/components/base/chat/embedded-chatbot/theme/theme-context.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-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 = 'text-text-primary-on-surface'
- public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
- public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
- public chatBubbleColorStyle = ''
-
- 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}; color: ${this.colorFontOnHeaderStyle};`
- this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
- this.chatBubbleColorStyle = `backgroundColor: ${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) {
- this._theme = new Theme()
- return this._theme
- } 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(new ThemeBuilder())
-export const useThemeContext = () => useContext(ThemeContext)
diff --git a/web/app/components/base/chat/embedded-chatbot/theme/theme.ts b/web/app/components/base/chat/embedded-chatbot/theme/theme.ts
new file mode 100644
index 00000000000..2d8a420cd7f
--- /dev/null
+++ b/web/app/components/base/chat/embedded-chatbot/theme/theme.ts
@@ -0,0 +1,44 @@
+import { hexToRGBA } from './utils'
+
+export type Theme = Readonly<{
+ chatColorTheme: string | null
+ chatColorThemeInverted: boolean
+ primaryColor: string
+ backgroundHeaderColorStyle: string
+ headerBorderBottomStyle: string
+ colorFontOnHeaderStyle: string
+ colorPathOnHeader: string
+ backgroundButtonDefaultColorStyle: string
+ roundedBackgroundColorStyle: string
+ chatBubbleColorStyle: string
+}>
+
+export const createTheme = (
+ chatColorTheme: string | null = null,
+ chatColorThemeInverted = false,
+): Theme => {
+ const hasCustomColor = chatColorTheme !== null && chatColorTheme !== ''
+ const primaryColor = hasCustomColor ? chatColorTheme : '#1C64F2'
+ const colorFontOnHeaderStyle = chatColorThemeInverted ? `color: ${primaryColor}` : 'color: white'
+
+ return Object.freeze({
+ chatColorTheme,
+ chatColorThemeInverted,
+ primaryColor,
+ backgroundHeaderColorStyle: chatColorThemeInverted
+ ? 'backgroundColor: #ffffff'
+ : hasCustomColor
+ ? `backgroundColor: ${primaryColor}`
+ : 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
+ headerBorderBottomStyle: chatColorThemeInverted ? 'borderBottom: 1px solid #ccc' : '',
+ colorFontOnHeaderStyle,
+ colorPathOnHeader: chatColorThemeInverted ? primaryColor : 'text-text-primary-on-surface',
+ backgroundButtonDefaultColorStyle: hasCustomColor
+ ? `backgroundColor: ${primaryColor}; color: color: white;`
+ : 'backgroundColor: #1C64F2',
+ roundedBackgroundColorStyle: hasCustomColor
+ ? `backgroundColor: ${hexToRGBA(primaryColor, 0.05)}`
+ : 'backgroundColor: rgb(245 248 255)',
+ chatBubbleColorStyle: hasCustomColor ? `backgroundColor: ${hexToRGBA(primaryColor, 0.15)}` : '',
+ })
+}
diff --git a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx
index 40f53e95498..b45e524d9d4 100644
--- a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx
+++ b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx
@@ -19,12 +19,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
},
}))
-vi.mock('../../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
- useThemeContext: () => ({
- primaryColor: '#1890ff',
- }),
-}))
-
vi.mock('@/app/components/base/chat/embedded-chatbot/chat-wrapper', () => ({
default: () => ChatWrapper
,
}))
diff --git a/web/app/components/explore/try-app/app/chat.tsx b/web/app/components/explore/try-app/app/chat.tsx
index 5509559c51b..a896cadc17a 100644
--- a/web/app/components/explore/try-app/app/chat.tsx
+++ b/web/app/components/explore/try-app/app/chat.tsx
@@ -16,9 +16,9 @@ import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrappe
import { EmbeddedChatbotContext } from '@/app/components/base/chat/embedded-chatbot/context'
import { useEmbeddedChatbot } from '@/app/components/base/chat/embedded-chatbot/hooks'
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
+import { createTheme } from '@/app/components/base/chat/embedded-chatbot/theme/theme'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { AppSourceType } from '@/service/share'
-import { useThemeContext } from '../../../base/chat/embedded-chatbot/theme/theme-context'
type Props = Readonly<{
appId: string
@@ -30,8 +30,11 @@ const TryApp: FC = ({ appId, appDetail, className }) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
- const themeBuilder = useThemeContext()
const { removeConversationIdInfo, ...chatData } = useEmbeddedChatbot(AppSourceType.tryApp, appId)
+ const theme = createTheme(
+ chatData.appData?.site?.chat_color_theme ?? null,
+ chatData.appData?.site?.chat_color_theme_inverted ?? false,
+ )
const currentConversationId = chatData.currentConversationId
const inputsForms = chatData.inputsForms
useEffect(() => {
@@ -50,7 +53,7 @@ const TryApp: FC = ({ appId, appDetail, className }) => {
...chatData,
disableFeedback: true,
isMobile,
- themeBuilder,
+ theme,
} as EmbeddedChatbotContextValue
}
>
diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx
index f8095df4d2c..8f78a4a7631 100644
--- a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx
+++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx
@@ -44,15 +44,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
-vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({
- useThemeContext: () => ({
- buildTheme: vi.fn(),
- theme: {
- primaryColor: '#1C64F2',
- },
- }),
-}))
-
vi.mock('@/context/account-state', async () => {
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
return createAccountStateModuleMock(() => ({