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