fix(chat): derive themes per chat root (#39790)

This commit is contained in:
yyh 2026-07-30 14:11:13 +08:00 committed by GitHub
parent 44a3ea7c0c
commit 3d01e8389a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 295 additions and 479 deletions

View File

@ -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 (
<span aria-label="chat theme">
{theme?.primaryColor}:{String(theme?.chatColorThemeInverted)}
</span>
)
}
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(<ChatWithHistory className="chat-history-shell" />)
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(<ChatWithHistory />)
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(<ChatWithHistory />)
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({

View File

@ -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(<Embedded {...baseProps} />)
})
@ -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()

View File

@ -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 (
<>
<div className="mt-8 mb-4 system-sm-medium text-text-primary">

View File

@ -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'))

View File

@ -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}

View File

@ -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

View File

@ -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<ChatWithHistoryProps> = ({ 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<ChatWithHistoryWrapProps> = ({
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const {
appData,
@ -141,6 +129,10 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
allInputsHidden,
initUserVariables,
} = useChatWithHistory(installedAppInfo)
const theme = createTheme(
appData?.site?.chat_color_theme ?? null,
appData?.site?.chat_color_theme_inverted ?? false,
)
return (
<ChatWithHistoryContext.Provider
@ -173,7 +165,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
theme,
sidebarCollapseState,
handleSidebarCollapse,
clearChatList,

View File

@ -44,7 +44,7 @@ const defaultContextValues: Partial<ChatWithHistoryContextValue> = {
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)

View File

@ -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,
}
: {}
}

View File

@ -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 }) => (
<div data-testid="question-item" data-id={item.id}>
default: ({ item, theme }: { item: ChatItem; theme?: { primaryColor: string } }) => (
<div data-testid="question-item" data-id={item.id} data-theme-color={theme?.primaryColor}>
{item.content}
</div>
),
@ -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<unknown>
speechToTextTarget?: SpeechToTextTarget
theme?: { primaryColor: string }
}) => (
<div
data-testid="chat-input-area"
@ -70,6 +73,7 @@ vi.mock('../chat-input-area', () => ({
? speechToTextTarget.appSourceType
: speechToTextTarget?.type
}
data-theme-color={theme?.primaryColor}
>
{footerNotice}
</div>
@ -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')
})
})

View File

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

View File

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

View File

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

View File

@ -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<ChatProps> = ({
chatAnswerContainerInner,
hideProcessDetail,
hideLogModal,
themeBuilder,
theme,
switchSibling,
showFeatureBar,
showFileUpload,
@ -242,7 +242,7 @@ const Chat: FC<ChatProps> = ({
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<ChatProps> = ({
onSend={onSend}
inputs={inputs}
inputsForm={inputsForm}
theme={themeBuilder?.theme}
theme={theme}
isResponding={isResponding}
readonly={readonly}
sendButtonLabel={sendButtonLabel}

View File

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

View File

@ -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,

View File

@ -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: () => <div>chat header</div>,
}))
vi.mock('../theme/theme-context', () => ({
useThemeContext: vi.fn(() => ({
buildTheme: vi.fn(),
theme: {
backgroundHeaderColorStyle: '',
},
})),
default: ({ theme }: { theme?: Theme }) => (
<div role="banner">
<span>chat header</span>
<span aria-label="chat theme">
{theme?.primaryColor}:{String(theme?.chatColorThemeInverted)}
</span>
</div>
),
}))
const mockIsDify = vi.fn(() => false)
@ -59,25 +58,25 @@ vi.mock('../utils', () => ({
type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot>
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> = {},
): 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(
<>
<EmbeddedChatbot />
<EmbeddedChatbot />
</>,
)
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(<EmbeddedChatbot />)
expect(screen.getByLabelText('chat theme')).toHaveTextContent('#123456:false')
vi.mocked(useEmbeddedChatbot).mockReturnValue(
createHookReturn({
appData: createAppData('#654321', true),
}),
)
rerender(<EmbeddedChatbot />)
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'

View File

@ -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}

View File

@ -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

View File

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

View File

@ -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 = <LogoHeader />
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 ?? '')) : {}
}
>
<Header
@ -61,7 +53,7 @@ const Chatbot = () => {
allowResetChat={allowResetChat}
title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''}
theme={themeBuilder?.theme}
theme={theme}
onCreateNewChat={handleNewConversation}
/>
<div
@ -108,7 +100,6 @@ const Chatbot = () => {
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 (
<EmbeddedChatbotContext.Provider
@ -172,7 +167,7 @@ const EmbeddedChatbotWrapper = () => {
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
theme,
clearChatList,
setClearChatList,
isResponding,

View File

@ -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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)

View File

@ -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,
}
: {}
}

View File

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

View File

@ -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',
})
})
})

View File

@ -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<ThemeBuilder>(new ThemeBuilder())
export const useThemeContext = () => useContext(ThemeContext)

View File

@ -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)}` : '',
})
}

View File

@ -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: () => <div data-testid="chat-wrapper">ChatWrapper</div>,
}))

View File

@ -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<Props> = ({ 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<Props> = ({ appId, appDetail, className }) => {
...chatData,
disableFeedback: true,
isMobile,
themeBuilder,
theme,
} as EmbeddedChatbotContextValue
}
>

View File

@ -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(() => ({