import { useEffect, useMemo, useRef, useState } from 'react'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; import { Close } from '@radix-ui/react-dialog'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import Modal from '@/components/Common/Modal'; import ButtonInput from '@/components/Inputs/ButtonInput'; import Tooltip from '@/components/Common/Tooltip'; import useSettingsData from '@/hooks/useSettingsData'; import Icon from '@/utils/Icon'; import { getChatStatus, getLocalStorage, postChatMessage, setLocalStorage } from '@/utils/api'; type ChatAvailability = { enabled?: boolean; abilities_enabled?: boolean; ai_client_loaded?: boolean; has_configured_provider?: boolean; disabled_reason?: string; }; type ChatSession = { id: string; title: string; history: Array>; createdAt: number; updatedAt: number; }; type TimelineItem = { id: string; type: 'message'; role?: 'user' | 'assistant'; text?: string; }; const STORAGE_KEY = 'chat_conversations_v1'; const MAX_STORED_SESSIONS = 20; const MAX_MESSAGES_PER_SESSION = 40; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; const DEFAULT_TITLE = __( 'New chat', 'burst-statistics' ); const LOADING_STEPS = [ __( 'Understanding your question...', 'burst-statistics' ), __( 'Fetching analytics data...', 'burst-statistics' ), __( 'Preparing a concise answer...', 'burst-statistics' ) ]; const asString = ( value: unknown ): string => { return 'string' === typeof value ? value : ''; }; const boolFromSetting = ( value: unknown, fallback = true ): boolean => { if ( 'boolean' === typeof value ) { return value; } if ( 'number' === typeof value ) { return 1 === value; } if ( 'string' === typeof value ) { if ([ '1', 'true', 'yes', 'on' ].includes( value.toLowerCase() ) ) { return true; } if ([ '0', 'false', 'no', 'off' ].includes( value.toLowerCase() ) ) { return false; } } return fallback; }; const parseExplicitBooleanSetting = ( value: unknown ): boolean | null => { if ( 'boolean' === typeof value ) { return value; } if ( 'number' === typeof value ) { if ( 1 === value ) { return true; } if ( 0 === value ) { return false; } return null; } if ( 'string' === typeof value ) { const normalized = value.toLowerCase(); if ([ '1', 'true', 'yes', 'on' ].includes( normalized ) ) { return true; } if ([ '0', 'false', 'no', 'off' ].includes( normalized ) ) { return false; } } return null; }; const getPartChannel = ( part: Record ): string => { const channel = part.channel; if ( 'string' === typeof channel ) { return channel; } if ( channel && 'object' === typeof channel ) { return asString( ( channel as { value?: unknown }).value ); } return ''; }; const shortText = ( value: string, max = 80 ): string => { return value.length > max ? `${value.substring( 0, max ).trim()}...` : value; }; const extractMessageText = ( message: Record ): string => { if ( Array.isArray( message.parts ) ) { const textParts = message.parts .filter( ( part ) => part && 'object' === typeof part ) .map( ( part ) => part as Record ) .filter( ( part ) => { const channel = getPartChannel( part ); return ! channel || 'content' === channel; }) .map( ( part ) => asString( part.text ) ) .filter( ( text ) => '' !== text ); return textParts.join( '\n\n' ); } return asString( message.content ); }; const sanitizeHistoryForStorage = ( history: Array> ): Array> => { const sanitized: Array> = []; history.forEach( ( rawMessage ) => { const rawRole = asString( rawMessage.role ); const role = 'user' === rawRole ? 'user' : 'assistant' === rawRole || 'model' === rawRole ? 'assistant' : ''; const text = extractMessageText( rawMessage ).trim(); if ( '' === role || '' === text ) { return; } const nextMessage = { role, content: text }; const previousMessage = sanitized[sanitized.length - 1]; if ( previousMessage && 'assistant' === role && 'assistant' === previousMessage.role ) { sanitized[sanitized.length - 1] = nextMessage; return; } sanitized.push( nextMessage ); }); return sanitized.slice( -MAX_MESSAGES_PER_SESSION ); }; const applySessionStorageLimits = ( sessionList: ChatSession[] ): ChatSession[] => { const minUpdatedAt = Date.now() - SESSION_TTL_MS; const sanitized = sessionList .filter( ( session ) => session.updatedAt >= minUpdatedAt ) .map( ( session ) => ({ ...session, history: sanitizeHistoryForStorage( session.history ) }) ) .sort( ( a, b ) => b.updatedAt - a.updatedAt ) .slice( 0, MAX_STORED_SESSIONS ); return sanitized; }; const randomSlug = (): string => { // Prefer crypto.randomUUID when available (secure contexts only). if ( 'undefined' !== typeof crypto && 'function' === typeof crypto.randomUUID ) { return crypto.randomUUID().replace( /-/g, '' ).slice( 0, 8 ); } // Fallback: 4 random bytes as hex (works in any context that has Web Crypto). const bytes = new Uint8Array( 4 ); crypto.getRandomValues( bytes ); return Array.from( bytes, ( b ) => b.toString( 16 ).padStart( 2, '0' ) ).join( '' ); }; const createSession = (): ChatSession => { const ts = Date.now(); return { id: `${ts}-${randomSlug()}`, title: DEFAULT_TITLE, history: [], createdAt: ts, updatedAt: ts }; }; const isSessionBlank = ( session: ChatSession ): boolean => { return 0 === sanitizeHistoryForStorage( session.history ).length; }; const normalizeChatStatus = ( status: unknown ): ChatAvailability => { if ( ! status || 'object' !== typeof status ) { return {}; } const typed = status as Record; const hasOwn = ( key: string ): boolean => Object.prototype.hasOwnProperty.call( typed, key ); return { enabled: hasOwn( 'enabled' ) ? boolFromSetting( typed.enabled, false ) : undefined, abilities_enabled: hasOwn( 'abilities_enabled' ) ? boolFromSetting( typed.abilities_enabled, true ) : undefined, ai_client_loaded: hasOwn( 'ai_client_loaded' ) ? boolFromSetting( typed.ai_client_loaded, false ) : undefined, has_configured_provider: hasOwn( 'has_configured_provider' ) ? boolFromSetting( typed.has_configured_provider, false ) : undefined, disabled_reason: asString( typed.disabled_reason ) }; }; const buildTimeline = ( history: Array> ): TimelineItem[] => { const output: TimelineItem[] = []; history.forEach( ( message, messageIndex ) => { const rawRole = asString( message.role ); const role: 'user' | 'assistant' = 'user' === rawRole ? 'user' : 'assistant' === rawRole || 'model' === rawRole ? 'assistant' : 'assistant'; const text = extractMessageText( message ); if ( text ) { output.push({ id: `message-${messageIndex}`, type: 'message', role, text }); } }); return output; }; const getDefaultDisabledReason = ( status: ChatAvailability ): string => { if ( ! status.ai_client_loaded ) { return __( 'The WordPress AI Client plugin is not available. Install and activate it to use chat.', 'burst-statistics' ); } if ( ! status.has_configured_provider ) { return __( 'No AI connector is configured. Connect a provider to use chat.', 'burst-statistics' ); } return __( 'Chat is currently unavailable.', 'burst-statistics' ); }; const ChatAssistantModal = () => { const { getValue } = useSettingsData(); const [ isOpen, setIsOpen ] = useState( false ); const [ sessions, setSessions ] = useState([]); const [ activeSessionId, setActiveSessionId ] = useState( '' ); const [ prompt, setPrompt ] = useState( '' ); const [ deleteSessionId, setDeleteSessionId ] = useState( null ); const [ pendingMessage, setPendingMessage ] = useState( '' ); const [ isSending, setIsSending ] = useState( false ); const [ loadingStep, setLoadingStep ] = useState( 0 ); const [ requestError, setRequestError ] = useState( '' ); const [ chatStatus, setChatStatus ] = useState( normalizeChatStatus( window.burst_settings?.chat_availability ) ); const messagesContainerRef = useRef( null ); const scrollRef = useRef( null ); const explicitAbilitiesSetting = parseExplicitBooleanSetting( getValue( 'enable_abilities_api' ) ); const abilitiesEnabled = null !== explicitAbilitiesSetting ? explicitAbilitiesSetting : ( chatStatus.abilities_enabled ?? true ); const scrollToBottom = ( behavior: ScrollBehavior = 'smooth' ) => { if ( messagesContainerRef.current ) { messagesContainerRef.current.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior }); return; } scrollRef.current?.scrollIntoView({ behavior }); }; const refreshChatStatus = async() => { try { const response = await getChatStatus(); const normalized = normalizeChatStatus( response ); setChatStatus( normalized ); if ( window.burst_settings ) { window.burst_settings.chat_availability = normalized; } } catch { // Keep existing status when request fails. } }; useEffect( () => { const stored = getLocalStorage( STORAGE_KEY, []); if ( Array.isArray( stored ) && 0 < stored.length ) { const sanitizedSessions = applySessionStorageLimits( stored as ChatSession[] ); if ( 0 < sanitizedSessions.length ) { setSessions( sanitizedSessions ); setActiveSessionId( asString( sanitizedSessions[0].id ) ); } else { const firstSession = createSession(); setSessions([ firstSession ]); setActiveSessionId( firstSession.id ); } } else { const firstSession = createSession(); setSessions([ firstSession ]); setActiveSessionId( firstSession.id ); } void refreshChatStatus(); }, []); useEffect( () => { if ( ! sessions.length ) { return; } setLocalStorage( STORAGE_KEY, applySessionStorageLimits( sessions ) ); }, [ sessions ]); useEffect( () => { if ( isOpen ) { void refreshChatStatus(); } }, [ isOpen ]); useEffect( () => { if ( abilitiesEnabled ) { void refreshChatStatus(); } }, [ abilitiesEnabled ]); useEffect( () => { if ( ! sessions.length ) { return; } const hasActiveSession = sessions.some( ( session ) => session.id === activeSessionId ); if ( ! hasActiveSession ) { setActiveSessionId( sessions[0].id ); } }, [ sessions, activeSessionId ]); const activeSession = useMemo( () => { return sessions.find( ( session ) => session.id === activeSessionId ) || null; }, [ sessions, activeSessionId ]); const timeline = useMemo( () => { return buildTimeline( activeSession?.history || []); }, [ activeSession ]); const sessionPendingDelete = useMemo( () => { if ( ! deleteSessionId ) { return null; } return sessions.find( ( session ) => session.id === deleteSessionId ) || null; }, [ sessions, deleteSessionId ]); const visibleTimeline = timeline; useEffect( () => { if ( ! isOpen ) { return; } scrollToBottom( 'smooth' ); }, [ visibleTimeline, isSending, pendingMessage, isOpen ]); useEffect( () => { if ( ! isOpen ) { return; } const frameId = window.requestAnimationFrame( () => { scrollToBottom( 'auto' ); }); return () => { window.cancelAnimationFrame( frameId ); }; }, [ isOpen, activeSessionId ]); useEffect( () => { if ( ! isSending ) { setLoadingStep( 0 ); return; } const intervalId = window.setInterval( () => { setLoadingStep( ( prev ) => Math.min( prev + 1, LOADING_STEPS.length - 1 ) ); }, 1400 ); return () => { window.clearInterval( intervalId ); }; }, [ isSending ]); const disabledReason = useMemo( () => { if ( ! abilitiesEnabled ) { return __( 'Chat is disabled because Abilities API is switched off in Burst settings.', 'burst-statistics' ); } if ( false === chatStatus.ai_client_loaded ) { return __( 'to enable the AI chat, please install and configure the WordPress AI plugin', 'burst-statistics' ); } if ( false === chatStatus.enabled ) { return chatStatus.disabled_reason || getDefaultDisabledReason( chatStatus ); } return ''; }, [ abilitiesEnabled, chatStatus ]); const isDisabled = Boolean( disabledReason ); if ( ! abilitiesEnabled ) { return null; } const ensureActiveSession = (): ChatSession => { if ( activeSession ) { return activeSession; } const fallback = createSession(); setSessions([ fallback ]); setActiveSessionId( fallback.id ); return fallback; }; const createNewChat = () => { const reusableSession = sessions.find( isSessionBlank ); if ( reusableSession ) { setActiveSessionId( reusableSession.id ); setRequestError( '' ); setPrompt( '' ); return; } const session = createSession(); setSessions( ( prev ) => applySessionStorageLimits([ session, ...prev ]) ); setActiveSessionId( session.id ); setRequestError( '' ); setPrompt( '' ); }; const deleteChat = ( sessionId: string ) => { setSessions( ( prev ) => { const next = prev.filter( ( item ) => item.id !== sessionId ); if ( ! next.length ) { const fallback = createSession(); setActiveSessionId( fallback.id ); return [ fallback ]; } if ( activeSessionId === sessionId ) { setActiveSessionId( next[0].id ); } return applySessionStorageLimits( next ); }); }; const openDeleteChatConfirm = ( sessionId: string ) => { setDeleteSessionId( sessionId ); }; const closeDeleteChatConfirm = () => { setDeleteSessionId( null ); }; const confirmDeleteChat = () => { if ( ! deleteSessionId ) { return; } deleteChat( deleteSessionId ); closeDeleteChatConfirm(); }; const sendMessage = async() => { if ( isSending || isDisabled ) { return; } const userMessage = prompt.trim(); if ( ! userMessage ) { return; } const session = ensureActiveSession(); const baseHistory = sanitizeHistoryForStorage( session.history ); setRequestError( '' ); setPendingMessage( userMessage ); setPrompt( '' ); setIsSending( true ); try { const response = await postChatMessage( userMessage, baseHistory ); const nextHistory = Array.isArray( response?.history ) ? sanitizeHistoryForStorage( response.history as Array> ) : session.history; const title = DEFAULT_TITLE === session.title ? shortText( userMessage ) : session.title; const updatedAt = Date.now(); setSessions( ( prev ) => { const next = prev.map( ( item ) => { if ( item.id !== session.id ) { return item; } return { ...item, title, history: nextHistory as Array>, updatedAt }; }); return applySessionStorageLimits( next ); }); } catch ( error ) { const message = error instanceof Error && error.message ? error.message : __( 'Could not send the message. Please try again.', 'burst-statistics' ); setRequestError( message ); setPrompt( userMessage ); } finally { setPendingMessage( '' ); setIsSending( false ); } }; const modalContent = (

{__( 'Chats', 'burst-statistics' )}

{sessions.map( ( session ) => (
) )}
{activeSession?.title || DEFAULT_TITLE}
{visibleTimeline.map( ( item ) => { if ( 'message' === item.type ) { const isUser = 'user' === item.role; const markdownTextClass = isUser ? '!text-text-white' : 'text-inherit'; return (
(

{children}

), h1: ({ children }) => (

{children}

), h2: ({ children }) => (

{children}

), h3: ({ children }) => (

{children}

), h4: ({ children }) => (

{children}

), ul: ({ children }) => (
    {children}
), ol: ({ children }) => (
    {children}
), li: ({ children }) => (
  • {children}
  • ), strong: ({ children }) => ( {children} ), blockquote: ({ children }) => (
    {children}
    ), pre: ({ children }) => (
    														{children}
    													
    ), code: ({ className, children }) => { const isBlockCode = 'string' === typeof className && 0 < className.length; if ( ! isBlockCode ) { return ( {children} ); } return ( {children} ); }, a: ({ href, children }) => ( {children} ), table: ({ children }) => (
    {children}
    ), thead: ({ children }) => ( {children} ), tbody: ({ children }) => {children}, tr: ({ children }) => ( {children} ), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ) }} > {item.text || ''}
    ); } return null; })} {pendingMessage && (
    {pendingMessage}
    )} {isSending && (
    {LOADING_STEPS.map( ( step, index ) => { const isCompleted = index < loadingStep; const isActive = index === loadingStep; return (
    {step}
    ); })}
    )} {! timeline.length && ! isSending && ! pendingMessage && (
    {__( 'Ask anything about your analytics. The assistant can run Burst abilities automatically when needed.', 'burst-statistics' )}
    )} {requestError && (
    {requestError}
    )}
    { event.preventDefault(); void sendMessage(); }} className="border-t border-gray-200 p-3" >