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'; import { useFetchExternalAgentInputs } from '@/hooks/use-agent-request'; import { useFetchExternalChatInfo } from '@/hooks/use-chat-request'; import i18n, { changeLanguageAsync } from '@/locales/config'; import { useSendNextSharedMessage } from '@/pages/agent/hooks/use-send-shared-message'; import { MessageCircle, Minimize2, Send, X } from 'lucide-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetSharedChatSearchParams, useSendSharedMessage, } 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); const [isMinimized, setIsMinimized] = useState(false); const [inputValue, setInputValue] = useState(''); const [lastResponseId, setLastResponseId] = useState(null); const [displayMessages, setDisplayMessages] = useState([]); const [isLoaded, setIsLoaded] = useState(false); const messagesEndRef = useRef(null); const { sharedId: conversationId, locale, from, } = useGetSharedChatSearchParams(); const isFromAgent = from === SharedFrom.Agent; // Check if we're in button-only mode or window-only mode 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 hookResult = ( isFromAgent ? useSendNextSharedMessage : useSendSharedMessage )(() => {}); const { handlePressEnter, handleInputChange, value: hookValue, sendLoading, derivedMessages, hasError, } = hookResult; const findReferenceByMessageId = (hookResult as any).findReferenceByMessageId; // Sync our local input with the hook's value when needed useEffect(() => { if (hookValue && hookValue !== inputValue) { setInputValue(hookValue); } }, [hookValue, inputValue]); const { data } = ( isFromAgent ? useFetchExternalAgentInputs : useFetchExternalChatInfo )(); 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(); // PDF drawer state tracking useEffect(() => { // Drawer state management }, [visible, documentId, selectedChunk]); // Play sound when opening const playNotificationSound = useCallback(() => { if (isMuted) { return; } try { const audioContext = new ( window.AudioContext || (window as any).webkitAudioContext )(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = 800; oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime( 0.01, audioContext.currentTime + 0.3, ); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); } catch (error) { 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 )(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = 600; oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.2, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime( 0.01, audioContext.currentTime + 0.2, ); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.2); } catch (error) { console.warn(error); // Silent fail if audio not supported } }, [isMuted]); // Set loaded state and locale useEffect(() => { // Set component as loaded after a brief moment to prevent flash const timer = setTimeout(() => { setIsLoaded(true); // Tell parent window that we're ready to be shown window.parent.postMessage( { type: 'WIDGET_READY', }, '*', ); }, 50); if (locale && i18n.language !== locale) { changeLanguageAsync(locale); } return () => clearTimeout(timer); }, [locale]); // Handle message display based on streaming preference useEffect(() => { if (!derivedMessages) { setDisplayMessages([]); return; } if (enableStreaming) { // Show messages as they stream setDisplayMessages(derivedMessages); } else { // Only show complete messages (non-streaming mode) const completeMessages = derivedMessages.filter((msg, index) => { // Always show user messages immediately if (msg.role === MessageType.User) return true; // For AI messages, only show when response is complete (not loading) if (msg.role === MessageType.Assistant) { return !sendLoading || index < derivedMessages.length - 1; } return true; }); setDisplayMessages(completeMessages); } }, [derivedMessages, enableStreaming, sendLoading]); // Auto-scroll to bottom when display messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [displayMessages]); // Render different content based on mode // Master mode - handles everything and creates second iframe dynamically useEffect(() => { if (mode !== 'master') return; const isInIframe = window.self !== window.top; if (isInIframe) { // Embedded: tell parent to create chat window iframe window.parent.postMessage( { type: 'CREATE_CHAT_WINDOW', src: window.location.href.replace('mode=master', 'mode=window'), }, '*', ); } else { // Standalone: create chat window iframe ourselves if (!document.getElementById('chat-win')) { const i = document.createElement('iframe'); i.id = 'chat-win'; i.src = window.location.href.replace('mode=master', 'mode=window'); i.style.cssText = 'position:fixed;bottom:104px;right:24px;width:380px;height:500px;border:none;background:transparent;z-index:9998;display:none'; i.frameBorder = '0'; i.allow = 'microphone;camera'; document.body.appendChild(i); } } // Listen for toggle messages to show/hide the chat window iframe const handleToggle = (e: MessageEvent) => { if (e.data.type === 'TOGGLE_CHAT') { const chatWindow = document.getElementById( 'chat-win', ) as HTMLIFrameElement; if (chatWindow) { chatWindow.style.display = e.data.isOpen ? 'block' : 'none'; } } }; window.addEventListener('message', handleToggle); return () => window.removeEventListener('message', handleToggle); }, [mode]); // Play sound only when AI response is complete (not streaming chunks) useEffect(() => { if (derivedMessages && derivedMessages.length > 0 && !sendLoading) { const lastMessage = derivedMessages[derivedMessages.length - 1]; if ( lastMessage.role === MessageType.Assistant && lastMessage.id !== lastResponseId && derivedMessages.length > 1 ) { setLastResponseId(lastMessage.id || ''); playResponseSound(); } } }, [derivedMessages, sendLoading, lastResponseId, playResponseSound]); const toggleChat = useCallback(() => { if (mode === 'button') { // In button mode, communicate with parent window to show/hide chat window window.parent.postMessage( { type: 'TOGGLE_CHAT', isOpen: !isOpen, }, '*', ); setIsOpen(!isOpen); if (!isOpen) { playNotificationSound(); } } else { // In full mode, handle locally if (!isOpen) { setIsOpen(true); setIsMinimized(false); playNotificationSound(); } else { setIsOpen(false); setIsMinimized(false); } } }, [isOpen, mode, playNotificationSound]); const minimizeChat = useCallback(() => { setIsMinimized(true); }, []); const handleSendMessage = useCallback(() => { if (!inputValue.trim() || sendLoading) return; // Update the hook's internal state first const syntheticEvent = { target: { value: inputValue }, currentTarget: { value: inputValue }, preventDefault: () => {}, } as any; handleInputChange(syntheticEvent); // Wait for state to update, then send setTimeout(() => { handlePressEnter({ enableThinking: false, enableInternet: false }); // Clear our local input after sending setInputValue(''); }, 50); }, [inputValue, sendLoading, handleInputChange, handlePressEnter]); const handleKeyPress = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }, [handleSendMessage], ); if (!conversationId) { return (
Error: No conversation ID provided
); } // Remove the blocking return - we'll handle visibility with CSS instead const messageCount = displayMessages?.length || 0; // Show just the button in master mode if (mode === 'master') { return (
{/* Unread Badge */} {!isOpen && messageCount > 0 && (
{messageCount > 9 ? '9+' : messageCount}
)}
); } if (mode === 'button') { // Only render the floating button return (
{/* Unread Badge */} {!isOpen && messageCount > 0 && (
{messageCount > 9 ? '9+' : messageCount}
)}
); } if (mode === 'window') { // Only render the chat window (always open) return ( <>
{/* Header */}

{displayTitle}

{displaySubtitle}

{/* Messages and Input */}
{ const element = e.currentTarget; const isAtTop = element.scrollTop === 0; const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 1; // Allow scroll to pass through to parent when at boundaries if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) { e.preventDefault(); // Let the parent handle the scroll window.parent.postMessage( { type: 'SCROLL_PASSTHROUGH', deltaY: e.deltaY, }, '*', ); } }} > {displayMessages?.map((message, index) => (
{message.role === MessageType.User ? (

{message.content}

) : (
)}
))} {/* Clean Typing Indicator */} {sendLoading && !enableStreaming && (
)}
{/* Input Area */}