diff --git a/web/src/components/embed-dialog/index.tsx b/web/src/components/embed-dialog/index.tsx index d2656b2ae..dbb45df24 100644 --- a/web/src/components/embed-dialog/index.tsx +++ b/web/src/components/embed-dialog/index.tsx @@ -1,6 +1,6 @@ import CopyToClipboard from '@/components/copy-to-clipboard'; import { SelectWithSearch } from '@/components/originui/select-with-search'; -import { Button } from '@/components/ui/button'; +import { Button, ButtonLoading } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -17,6 +17,7 @@ import { } from '@/components/ui/form'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { SharedFrom } from '@/constants/chat'; import { LanguageAbbreviation, @@ -48,23 +49,78 @@ const FormSchema = z.object({ locale: z.string(), embedType: z.enum(['fullscreen', 'widget']), enableStreaming: z.boolean(), + muteWidget: z.boolean(), theme: z.enum([ThemeEnum.Light, ThemeEnum.Dark]), userId: z.string().optional(), + widgetTitle: z.string(), + widgetSubtitle: z.string(), + widgetFooterText: z.string(), + widgetFooterLink: z.string(), + widgetAccentColor: z.string(), + widgetBackgroundColor: z.string(), + widgetTextColor: z.string(), + widgetHeaderTextColor: z.string(), + widgetFooterTextColor: z.string(), }); +export type WidgetSettings = Pick< + z.infer, + | 'enableStreaming' + | 'muteWidget' + | 'widgetTitle' + | 'widgetSubtitle' + | 'widgetFooterText' + | 'widgetFooterLink' + | 'widgetAccentColor' + | 'widgetBackgroundColor' + | 'widgetTextColor' + | 'widgetHeaderTextColor' + | 'widgetFooterTextColor' +>; + +export const defaultWidgetSettings: WidgetSettings = { + enableStreaming: false, + muteWidget: false, + widgetTitle: '', + widgetSubtitle: '', + widgetFooterText: '', + widgetFooterLink: '', + widgetAccentColor: '#2563eb', + widgetBackgroundColor: '#ffffff', + widgetTextColor: '#111827', + widgetHeaderTextColor: '#ffffff', + widgetFooterTextColor: '#111827', +}; + type IProps = IModalProps & { token: string; from: SharedFrom; beta: string; isAgent: boolean; + initialWidgetSettings?: Partial; + onSaveWidgetSettings?: (settings: WidgetSettings) => Promise; + savingWidgetSettings?: boolean; }; +const normalizeHexColor = (value: string | undefined, fallback: string) => { + const normalizedValue = value?.trim() ?? ''; + return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue) + ? normalizedValue + : fallback; +}; + +/** + * Builds the embed code preview and customization UI for shared chat and agent widgets. + */ function EmbedDialog({ hideModal, token = '', from, beta = '', isAgent, + initialWidgetSettings, + onSaveWidgetSettings, + savingWidgetSettings, visible, }: IProps) { const { t } = useTranslation(); @@ -77,8 +133,9 @@ function EmbedDialog({ published: false, locale: '', embedType: 'fullscreen' as const, - enableStreaming: false, theme: ThemeEnum.Light, + ...defaultWidgetSettings, + ...initialWidgetSettings, }, }); @@ -98,8 +155,18 @@ function EmbedDialog({ locale, embedType, enableStreaming, + muteWidget, theme, userId, + widgetTitle, + widgetSubtitle, + widgetFooterText, + widgetFooterLink, + widgetAccentColor, + widgetBackgroundColor, + widgetTextColor, + widgetHeaderTextColor, + widgetFooterTextColor, } = values; const baseRoute = embedType === 'widget' @@ -125,6 +192,39 @@ function EmbedDialog({ if (embedType === 'widget') { src.searchParams.append('mode', 'master'); src.searchParams.append('streaming', String(enableStreaming)); + src.searchParams.append('muted', String(muteWidget)); + if (!isEmpty(trim(widgetTitle))) { + src.searchParams.append('widget_title', widgetTitle ?? ''); + } + if (!isEmpty(trim(widgetSubtitle))) { + src.searchParams.append('widget_subtitle', widgetSubtitle ?? ''); + } + if (!isEmpty(trim(widgetFooterText))) { + src.searchParams.append('widget_footer', widgetFooterText ?? ''); + } + if (!isEmpty(trim(widgetFooterLink))) { + src.searchParams.append('widget_footer_link', widgetFooterLink ?? ''); + } + src.searchParams.append( + 'widget_accent_color', + normalizeHexColor(widgetAccentColor, '#2563eb'), + ); + src.searchParams.append( + 'widget_background_color', + normalizeHexColor(widgetBackgroundColor, '#ffffff'), + ); + src.searchParams.append( + 'widget_text_color', + normalizeHexColor(widgetTextColor, '#111827'), + ); + src.searchParams.append( + 'widget_header_text_color', + normalizeHexColor(widgetHeaderTextColor, '#ffffff'), + ); + src.searchParams.append( + 'widget_footer_text_color', + normalizeHexColor(widgetFooterTextColor, '#111827'), + ); } if (theme && embedType === 'fullscreen') { src.searchParams.append('theme', theme); @@ -179,9 +279,29 @@ window.addEventListener('message',e=>{ window.open(iframeSrc, '_blank'); }, [generateIframeSrc]); + const handleSaveWidgetSettings = useCallback(async () => { + if (!onSaveWidgetSettings) { + return; + } + + await onSaveWidgetSettings({ + enableStreaming: values.enableStreaming, + muteWidget: values.muteWidget, + widgetTitle: values.widgetTitle, + widgetSubtitle: values.widgetSubtitle, + widgetFooterText: values.widgetFooterText, + widgetFooterLink: values.widgetFooterLink, + widgetAccentColor: values.widgetAccentColor, + widgetBackgroundColor: values.widgetBackgroundColor, + widgetTextColor: values.widgetTextColor, + widgetHeaderTextColor: values.widgetHeaderTextColor, + widgetFooterTextColor: values.widgetFooterTextColor, + }); + }, [onSaveWidgetSettings, values]); + return ( - + {t('common.embedIntoSite')} @@ -189,103 +309,274 @@ window.addEventListener('message',e=>{
- ( - - {t('chat.embedType')} - - -
- - -
-
- - -
-
-
- -
- )} - /> - {values.embedType === 'fullscreen' && ( - ( - - {t('chat.theme')} - - -
- - -
-
- - -
-
-
- -
+ + + Embed Setup + Widget Customization + + + ( + + {t('chat.embedType')} + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> + {values.embedType === 'fullscreen' && ( + ( + + {t('chat.theme')} + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> )} - /> - )} - - {isAgent && ( - - )} - {values.embedType === 'widget' && ( - - )} - - - - {isAgent && ( - - - - )} + + {isAgent && ( + + )} + {values.embedType === 'widget' && ( + + )} + {values.embedType === 'widget' && ( + + )} + + + + {isAgent && ( + + + + )} +
+ +
+ These settings apply to the floating widget embed. +
+
+ + + + + + + + + + + + + ( + + Widget accent color + +
+ + +
+
+ +
+ )} + /> + ( + + Background color + +
+ + +
+
+ +
+ )} + /> + ( + + Text color + +
+ + +
+
+ +
+ )} + /> + ( + + Header text color + +
+ + +
+
+ +
+ )} + /> + ( + + Footer text color + +
+ + +
+
+ +
+ )} + /> +
+
+
{t('search.embedCode')} -
+
+ @@ -293,14 +584,26 @@ window.addEventListener('message',e=>{
- +
+ {isAgent && onSaveWidgetSettings && ( + + {t('flow.save')} widget settings + + )} + +
{t(isAgent ? 'flow' : 'chat', { keyPrefix: 'header' })} ID diff --git a/web/src/components/floating-chat-widget.tsx b/web/src/components/floating-chat-widget.tsx index 46fb49482..9b5c375cf 100644 --- a/web/src/components/floating-chat-widget.tsx +++ b/web/src/components/floating-chat-widget.tsx @@ -1,3 +1,4 @@ +import CopyToClipboard from '@/components/copy-to-clipboard'; import PdfSheet from '@/components/pdf-drawer'; import { useClickDrawer } from '@/components/pdf-drawer/hooks'; import { MessageType, SharedFrom } from '@/constants/chat'; @@ -14,6 +15,75 @@ import { } from '../pages/next-chats/hooks/use-send-shared-message'; import FloatingChatWidgetMarkdown from './floating-chat-widget-markdown'; +/** + * Normalizes a hex color input and falls back to a safe default when invalid. + */ +const normalizeHexColor = (value: string | null, fallback: string) => { + return value && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) + ? value + : fallback; +}; + +/** + * Darkens a hex color to derive hover and gradient variants for the widget chrome. + */ +const darkenHexColor = (hexColor: string, amount = 0.12) => { + const normalizedHex = hexColor.replace('#', ''); + const expandedHex = + normalizedHex.length === 3 + ? normalizedHex + .split('') + .map((char) => `${char}${char}`) + .join('') + : normalizedHex; + const channels = expandedHex.match(/.{2}/g); + + if (!channels) { + return hexColor; + } + + return `#${channels + .map((channel) => { + const value = parseInt(channel, 16); + const adjustedValue = Math.max( + 0, + Math.min(255, Math.round(value * (1 - amount))), + ); + return adjustedValue.toString(16).padStart(2, '0'); + }) + .join('')}`; +}; + +/** + * Accepts a footer link from the widget query string and returns a safe HTTP(S) URL. + */ +const normalizeWidgetFooterLink = (value: string | null) => { + const normalizedValue = value?.trim(); + + if (!normalizedValue) { + return undefined; + } + + const candidate = /^[a-z][a-z\d+.-]*:/i.test(normalizedValue) + ? normalizedValue + : `https://${normalizedValue}`; + + try { + const url = new URL(candidate); + + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.toString(); + } + } catch { + return undefined; + } + + return undefined; +}; + +/** + * Renders the embeddable floating chat widget and applies URL-driven widget settings. + */ const FloatingChatWidget = () => { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -36,6 +106,34 @@ const FloatingChatWidget = () => { const urlParams = new URLSearchParams(window.location.search); const mode = urlParams.get('mode') || 'full'; // 'button', 'window', or 'full' const enableStreaming = urlParams.get('streaming') === 'true'; // Only enable if explicitly set to true + const isMuted = urlParams.get('muted') === 'true'; + const widgetTitle = urlParams.get('widget_title')?.trim(); + const widgetSubtitle = urlParams.get('widget_subtitle')?.trim(); + const widgetFooter = urlParams.get('widget_footer')?.trim(); + const widgetFooterLink = normalizeWidgetFooterLink( + urlParams.get('widget_footer_link'), + ); + const widgetAccentColor = normalizeHexColor( + urlParams.get('widget_accent_color'), + '#2563eb', + ); + const widgetAccentColorStrong = darkenHexColor(widgetAccentColor); + const widgetBackgroundColor = normalizeHexColor( + urlParams.get('widget_background_color'), + '#ffffff', + ); + const widgetTextColor = normalizeHexColor( + urlParams.get('widget_text_color'), + '#111827', + ); + const widgetHeaderTextColor = normalizeHexColor( + urlParams.get('widget_header_text_color'), + '#ffffff', + ); + const widgetFooterTextColor = normalizeHexColor( + urlParams.get('widget_footer_text_color'), + '#111827', + ); const { handlePressEnter, @@ -58,6 +156,49 @@ const FloatingChatWidget = () => { )(); const title = data.title; + const displayTitle = widgetTitle || title || t('chat.chatSupport'); + const displaySubtitle = widgetSubtitle || t('chat.replyInstantly'); + const displayFooter = widgetFooter || ''; + const renderFooter = () => { + if (!displayFooter) { + return null; + } + + return ( +
+ {widgetFooterLink ? ( + + {displayFooter} + + ) : ( + displayFooter + )} +
+ ); + }; + const bodyContainerStyle: React.CSSProperties = { + borderRadius: '0 0 16px 16px', + backgroundColor: widgetBackgroundColor, + color: widgetTextColor, + }; + const inputStyle: React.CSSProperties = { + minHeight: '44px', + maxHeight: '120px', + color: widgetTextColor, + backgroundColor: widgetBackgroundColor, + }; const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = useClickDrawer(); @@ -69,6 +210,10 @@ const FloatingChatWidget = () => { // Play sound when opening const playNotificationSound = useCallback(() => { + if (isMuted) { + return; + } + try { const audioContext = new ( window.AudioContext || (window as any).webkitAudioContext @@ -94,10 +239,14 @@ const FloatingChatWidget = () => { console.warn(error); // Silent fail if audio not supported } - }, []); + }, [isMuted]); // Play sound for AI responses (Intercom-style) const playResponseSound = useCallback(() => { + if (isMuted) { + return; + } + try { const audioContext = new ( window.AudioContext || (window as any).webkitAudioContext @@ -124,7 +273,7 @@ const FloatingChatWidget = () => { // Silent fail if audio not supported } - }, []); + }, [isMuted]); // Set loaded state and locale useEffect(() => { @@ -338,9 +487,10 @@ const FloatingChatWidget = () => { '*', ); }} - className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${ + className={`w-14 h-14 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${ isOpen ? 'scale-95' : 'scale-100 hover:scale-105' }`} + style={{ backgroundColor: widgetAccentColor }} >
{