import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { ActionType } from '../modern-chat-input/ModernChatInput'; import { toast } from 'sonner'; import { gerarResposta } from '../../shared/assistant-utils'; import { useIsMobile } from '../../shared/use-mobile'; import type { Message, Conversation, Suggestion, AssistantTab, AssistantMode, SearchResult, MockResponse, } from './types'; // ───────────────────────────────────────────────────────────────────────────── // Hook Props // ───────────────────────────────────────────────────────────────────────────── export interface UseAssistantProps { /** Layout mode for the assistant panel */ mode?: AssistantMode; /** Controlled expansion state */ isExpanded?: boolean; /** Toggle callback for controlled expansion */ onToggle?: () => void; /** Initially selected tab */ defaultTab?: AssistantTab; /** Enables demo mode with mock AI responses */ demoMode?: boolean; /** Custom mock responses for demo mode */ customResponses?: MockResponse[]; /** Pre-loaded messages to hydrate an existing conversation */ initialMessages?: Message[]; /** Previously saved conversations */ savedConversations?: Conversation[]; /** Suggested prompts shown in the empty state */ suggestions?: Suggestion[]; /** Callback fired when the user sends a message */ onSendMessage?: (message: string) => void; /** Whether the assistant is currently processing a response */ isProcessing?: boolean; /** * Custom response generator — overrides demo mode defaults. * `action` is set when the user sent the message via one of the chat input's * quick actions (`'document' | 'podcast' | 'search'`, see `ModernChatInput`), * `null`/`undefined` for a plain typed message. Use it to decide whether to * populate action-specific fields — e.g. only set `searchResults`/ * `searchSources`/`attachmentType: 'search'` when `action === 'search'`, so * the search-results card doesn't appear for ordinary conversation. */ responseGenerator?: ( message: string, action?: ActionType ) => Promise> | string | Partial; /** * Streaming response generator — takes priority over `responseGenerator`/`demoMode` * when provided. Each yielded `Partial` replaces the current state of the * in-progress assistant message (typically a growing `content` snapshot, not a delta — * accumulate deltas yourself before yielding, the same way you'd update local state). * A placeholder message is inserted immediately and merged on every yield, so the * reply grows incrementally instead of appearing as a single block once done. * `action` has the same meaning as in `responseGenerator` above. */ streamResponseGenerator?: ( message: string, action?: ActionType ) => AsyncGenerator, void, unknown>; /** Extended suggestions shown when the user clicks "More suggestions" */ richSuggestions?: Suggestion[]; /** Callback fired when the user clicks a rich suggestion */ onRichAction?: (actionId: string, actionText: string) => void; /** Callback fired when the user rates a message */ onEvaluation?: (messageId: string, type: 'like' | 'dislike', reason?: string) => void; /** Negative feedback categories shown in the dislike dropdown */ feedbackOptions?: string[]; } // ───────────────────────────────────────────────────────────────────────────── // Hook Return Value // ───────────────────────────────────────────────────────────────────────────── export interface UseAssistantReturn { // ── Layout state ────────────────────────────────────────────────────────── isFullPage: boolean; isExpanded: boolean; isMobile: boolean; abaSelecionada: AssistantTab; setAbaSelecionada: (tab: AssistantTab) => void; // ── Message state ───────────────────────────────────────────────────────── mensagens: Message[]; setMensagens: React.Dispatch>; mensagem: string; setMensagem: (value: string) => void; // ── Conversation state ──────────────────────────────────────────────────── conversas: Conversation[]; conversaAtual: string | null; conversasFiltradas: Conversation[]; // ── UI state ────────────────────────────────────────────────────────────── copiedId: string | null; generatingPodcastId: string | null; executingCommand: string | null; savedSearches: string[]; editingDocument: { content: string; title: string } | null; setEditingDocument: (doc: { content: string; title: string } | null) => void; showMoreSuggestions: boolean; setShowMoreSuggestions: (show: boolean) => void; evaluationState: { isOpen: boolean; messageId: string | null; type: 'dislike' | null; category?: string | null; reason: string; }; setEvaluationState: React.Dispatch>; // ── Suggestions ─────────────────────────────────────────────────────────── sugestoes: Suggestion[]; // ── Refs ────────────────────────────────────────────────────────────────── messagesEndRef: React.RefObject; fileInputRef: React.RefObject; audioInputRef: React.RefObject; // ── Handlers ────────────────────────────────────────────────────────────── handleToggle: () => void; handleExpandWithTab: (tab: AssistantTab) => void; handleEnviarMensagem: (arg?: string | ActionType) => Promise; handleToggleFavorite: (messageId: string) => void; handleCopyMessage: (content: string, messageId: string) => Promise; handleGeneratePodcast: (messageId: string, content: string) => Promise; handleDownloadDocument: (content: string, fileName: string) => void; handleDownloadPodcast: (audioUrl: string, fileName: string) => void; handleEditDocument: (content: string, title: string) => void; handleNovaConversa: () => void; handleSelecionarConversa: (conversaId: string) => void; handleToggleFavoritaConversa: (conversaId: string) => void; handleOpenSearchResult: (result: SearchResult) => void; handleExecuteSearchCommand: ( commandId: string, searchTerm: string, results: SearchResult[], messageId: string ) => Promise; handleRichSuggestionClick: (suggestion: Suggestion) => void; handleEvaluationClick: (messageId: string, type: 'like' | 'dislike') => void; openFeedbackDialog: (messageId: string, category: string | null) => void; handleSubmitDislike: () => void; } // DEFAULT_SUGGESTIONS is intentionally NOT defined at module scope. // See the `useAssistant` hook below where they are built with `useMemo` // so that `t()` is always called inside the React render cycle with the // currently active language. // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── /** * `useAssistant` — Headless hook for the XerticaAssistant component. * * Encapsulates all state management and business logic for the AI assistant panel. * Use this hook when you need to build a fully custom assistant UI while reusing * the same state logic as the default `XerticaAssistant` component. * * @example * ```tsx * import { useAssistant } from 'xertica-ui/assistant'; * * function MyCustomAssistant() { * const { * mensagens, * mensagem, * setMensagem, * handleEnviarMensagem, * isExpanded, * handleToggle, * } = useAssistant({ demoMode: true }); * * return ( *
* {mensagens.map(msg =>

{msg.content}

)} * setMensagem(e.target.value)} /> * *
* ); * } * ``` */ export function useAssistant({ mode = 'expanded', isExpanded: controlledIsExpanded, onToggle, defaultTab = 'chat', demoMode = true, customResponses = [], initialMessages = [], savedConversations = [], suggestions: propSuggestions, onSendMessage, isProcessing = false, responseGenerator, streamResponseGenerator, richSuggestions: _richSuggestions = [], onRichAction, onEvaluation, }: UseAssistantProps = {}): UseAssistantReturn { const { t } = useTranslation(); // ── Layout state ──────────────────────────────────────────────────────────── const isFullPage = mode === 'fullPage'; const [internalIsExpanded, setInternalIsExpanded] = useState(controlledIsExpanded ?? true); const isExpanded = controlledIsExpanded ?? internalIsExpanded; const isMobile = useIsMobile(); // ── Tab state ─────────────────────────────────────────────────────────────── const [abaSelecionada, setAbaSelecionada] = useState(defaultTab); // ── Message state ─────────────────────────────────────────────────────────── const [mensagens, setMensagens] = useState(initialMessages); const [mensagem, setMensagem] = useState(''); // ── Conversation state ────────────────────────────────────────────────────── const [conversas, setConversas] = useState(savedConversations); const [conversaAtual, setConversaAtual] = useState(null); // ── UI state ──────────────────────────────────────────────────────────────── const [copiedId, setCopiedId] = useState(null); const [generatingPodcastId, setGeneratingPodcastId] = useState(null); const [executingCommand, setExecutingCommand] = useState(null); const [savedSearches, setSavedSearches] = useState([]); const [editingDocument, setEditingDocument] = useState<{ content: string; title: string } | null>( null ); const [showMoreSuggestions, setShowMoreSuggestions] = useState(false); const [evaluationState, setEvaluationState] = useState({ isOpen: false, messageId: null, type: null, category: null, reason: '', }); // ── Refs ──────────────────────────────────────────────────────────────────── const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const audioInputRef = useRef(null); const responseTimerRef = useRef | null>(null); const commandTimerRef = useRef | null>(null); const hydratedRef = useRef(false); // ── Suggestions ───────────────────────────────────────────────────────────── // Built inside useMemo so t() is re-evaluated when language changes. const defaultSuggestions = useMemo( () => [ { id: '1', text: t('assistant.defaultSuggestions.createDocument') }, { id: '2', text: t('assistant.defaultSuggestions.searchFiles') }, { id: '3', text: t('assistant.defaultSuggestions.summarizeConversations') }, { id: '4', text: t('assistant.defaultSuggestions.createPodcast') }, ], [t] ); const sugestoes = propSuggestions ?? defaultSuggestions; // ── Effects ───────────────────────────────────────────────────────────────── // Cleanup timers on unmount useEffect(() => { return () => { if (responseTimerRef.current) clearTimeout(responseTimerRef.current); if (commandTimerRef.current) clearTimeout(commandTimerRef.current); }; }, []); // Tracks whether the user was near the bottom of the chat, updated only by // real scroll events — NOT recomputed after a new message is appended. // By the time the auto-scroll effect below runs, the container has // already grown to fit the new message, so measuring "distance from // bottom" at that point conflates "user scrolled away" with "the new // message itself was tall" and skips the auto-scroll on long replies // (exactly the messages a real backend like FDM tends to send). const isNearBottomRef = useRef(true); const scrollContainerRef = useRef(null); useEffect(() => { const container = messagesEndRef.current?.closest( '[data-radix-scroll-area-viewport]' ); if (!container || container === scrollContainerRef.current) return; scrollContainerRef.current = container; const handleScroll = () => { const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; isNearBottomRef.current = distanceFromBottom < 120; }; container.addEventListener('scroll', handleScroll, { passive: true }); return () => container.removeEventListener('scroll', handleScroll); }, [mensagens, abaSelecionada]); // Auto-scroll to the newest message (and to the typing indicator while a // response is pending) — but only when the user was already near the // bottom, so scrolling back up to read history is never yanked away. useEffect(() => { if (messagesEndRef.current && abaSelecionada === 'chat' && isNearBottomRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }, [mensagens, abaSelecionada, isProcessing]); // Sync initial messages when they change (one-shot hydration) useEffect(() => { if (!hydratedRef.current && initialMessages && initialMessages.length > 0) { setMensagens(initialMessages); hydratedRef.current = true; } }, [initialMessages]); // ── Computed ──────────────────────────────────────────────────────────────── const conversasFiltradas = useMemo( () => conversas.filter(c => (abaSelecionada === 'favoritos' ? c.isFavorite : true)), [conversas, abaSelecionada] ); // ── Handlers ──────────────────────────────────────────────────────────────── const handleToggle = useCallback(() => { if (onToggle) { onToggle(); } else { setInternalIsExpanded(prev => !prev); } }, [onToggle]); const handleExpandWithTab = useCallback( (tab: AssistantTab) => { setAbaSelecionada(tab); if (onToggle) { onToggle(); } else { setInternalIsExpanded(true); } }, [onToggle] ); const handleEnviarMensagem = useCallback( async (arg?: string | ActionType) => { let msgToSend = mensagem; const ACTION_TYPES = ['document', 'podcast', 'search']; const action: ActionType = typeof arg === 'string' && ACTION_TYPES.includes(arg) ? (arg as ActionType) : null; if (typeof arg === 'string' && !ACTION_TYPES.includes(arg)) { msgToSend = arg; } if (!msgToSend.trim() || isProcessing) return; const novaMensagem: Message = { id: `msg-${Date.now()}`, type: 'user', content: msgToSend, timestamp: new Date(), isFavorite: false, }; setMensagens(prev => [...prev, novaMensagem]); if (onSendMessage) { onSendMessage(msgToSend); } if (streamResponseGenerator) { // Streaming path: a placeholder is inserted immediately and merged on // every yield, so the reply grows incrementally instead of showing up // as a single block once the whole thing is ready. No artificial // delay here — the first chunk itself is the "it's responding" cue. const mensagemAtual = msgToSend; const placeholderId = `msg-${Date.now()}-ia`; const placeholder: Message = { id: placeholderId, type: 'assistant', content: '', timestamp: new Date(), isFavorite: false, }; setMensagens(prev => [...prev, placeholder]); try { for await (const chunk of streamResponseGenerator(mensagemAtual, action)) { setMensagens(prev => prev.map(m => (m.id === placeholderId ? { ...m, ...chunk } : m))); } } catch { setMensagens(prev => prev.map(m => m.id === placeholderId && !m.content ? { ...m, content: 'Ocorreu um erro ao receber a resposta.' } : m ) ); } } else if (demoMode || responseGenerator) { const mensagemAtual = msgToSend; responseTimerRef.current = setTimeout( async () => { let resposta: string | Partial; try { if (responseGenerator) { resposta = await responseGenerator(mensagemAtual, action); } else { resposta = gerarResposta(mensagemAtual, customResponses); } } catch { resposta = 'Ocorreu um erro ao receber a resposta.'; } let novaMensagemIA: Message = { id: `msg-${Date.now()}-ia`, type: 'assistant', content: '', timestamp: new Date(), isFavorite: false, }; if (typeof resposta === 'string') { novaMensagemIA.content = resposta; } else { novaMensagemIA = { ...novaMensagemIA, ...resposta }; } setMensagens(prev => [...prev, novaMensagemIA]); }, 1000 + Math.random() * 1000 ); } setMensagem(''); }, [ mensagem, isProcessing, onSendMessage, demoMode, responseGenerator, streamResponseGenerator, customResponses, ] ); const handleToggleFavorite = useCallback((messageId: string) => { setMensagens(prev => prev.map(msg => (msg.id === messageId ? { ...msg, isFavorite: !msg.isFavorite } : msg)) ); }, []); const handleCopyMessage = useCallback(async (content: string, messageId: string) => { try { if (navigator.clipboard && navigator.clipboard.writeText) { try { await navigator.clipboard.writeText(content); setCopiedId(messageId); setTimeout(() => setCopiedId(null), 2000); } catch (clipboardError) { if ( clipboardError instanceof Error && (clipboardError.name === 'NotAllowedError' || clipboardError.message.includes('permissions policy')) ) { throw new Error('Clipboard permission denied, falling back'); } throw clipboardError; } } else { throw new Error('Clipboard API not available'); } } catch { try { const textArea = document.createElement('textarea'); textArea.value = content; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand('copy'); document.body.removeChild(textArea); if (successful) { setCopiedId(messageId); setTimeout(() => setCopiedId(null), 2000); } } catch (fallbackErr) { console.error('Falha ao copiar:', fallbackErr); } } }, []); const handleGeneratePodcast = useCallback(async (messageId: string, content: string) => { setGeneratingPodcastId(messageId); setTimeout(() => { setMensagens(prev => prev.map(msg => msg.id === messageId ? { ...msg, attachmentType: 'podcast' as const, attachmentName: `${t('assistant.podcastName')} - ${content.substring(0, 30)}...`, audioUrl: 'data:audio/mpeg;base64,//uQx...', } : msg ) ); setGeneratingPodcastId(null); }, 2000); }, []); const handleDownloadDocument = useCallback((content: string, fileName: string) => { const blob = new Blob([content], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName.endsWith('.md') ? fileName : fileName + '.md'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, []); const handleDownloadPodcast = useCallback((audioUrl: string, fileName: string) => { const a = document.createElement('a'); a.href = audioUrl; a.download = fileName.endsWith('.mp3') ? fileName : fileName + '.mp3'; document.body.appendChild(a); a.click(); document.body.removeChild(a); }, []); const handleEditDocument = useCallback((content: string, title: string) => { setEditingDocument({ content, title }); }, []); const handleNovaConversa = useCallback(() => { setMensagens([]); setConversaAtual(null); setAbaSelecionada('chat'); }, []); const handleSelecionarConversa = useCallback( (conversaId: string) => { const conversa = conversas.find(c => c.id === conversaId); if (conversa) { setMensagens(conversa.messages); setConversaAtual(conversaId); setAbaSelecionada('chat'); } }, [conversas] ); const handleToggleFavoritaConversa = useCallback((conversaId: string) => { setConversas(prev => prev.map(conv => (conv.id === conversaId ? { ...conv, isFavorite: !conv.isFavorite } : conv)) ); }, []); const handleOpenSearchResult = useCallback((result: SearchResult) => { console.log('Abrir resultado:', result); }, []); const handleExecuteSearchCommand = useCallback( async (commandId: string, searchTerm: string, results: SearchResult[], messageId: string) => { setExecutingCommand(commandId); commandTimerRef.current = setTimeout(() => { if (commandId === '5') { setSavedSearches(prev => [...prev, messageId]); } setExecutingCommand(null); }, 1500); }, [] ); const handleRichSuggestionClick = useCallback( (suggestion: Suggestion) => { if (onRichAction) { onRichAction(suggestion.id, suggestion.text); } else { handleEnviarMensagem(suggestion.text); } setShowMoreSuggestions(false); }, [onRichAction, handleEnviarMensagem] ); const handleEvaluationClick = useCallback( (messageId: string, type: 'like' | 'dislike') => { if (type === 'like') { if (onEvaluation) onEvaluation(messageId, 'like'); setMensagens(prev => prev.map(m => (m.id === messageId ? { ...m, evaluation: 'like' as const } : m)) ); toast.success(t('assistant.likeToast')); } }, [onEvaluation] ); const openFeedbackDialog = useCallback((messageId: string, category: string | null) => { setEvaluationState({ isOpen: true, messageId, type: 'dislike', category, reason: '', }); }, []); const handleSubmitDislike = useCallback(() => { if (evaluationState.messageId) { const finalReason = evaluationState.category ? evaluationState.reason ? `${evaluationState.category}: ${evaluationState.reason}` : evaluationState.category : evaluationState.reason; if (onEvaluation) { onEvaluation(evaluationState.messageId, 'dislike', finalReason); } setMensagens(prev => prev.map(m => m.id === evaluationState.messageId ? { ...m, evaluation: 'dislike' as const, evaluationReason: finalReason } : m ) ); toast.success(t('assistant.feedbackToast')); } setEvaluationState({ isOpen: false, messageId: null, type: null, category: null, reason: '' }); }, [evaluationState, onEvaluation]); return { // Layout isFullPage, isExpanded, isMobile, abaSelecionada, setAbaSelecionada, // Messages mensagens, setMensagens, mensagem, setMensagem, // Conversations conversas, conversaAtual, conversasFiltradas, // UI copiedId, generatingPodcastId, executingCommand, savedSearches, editingDocument, setEditingDocument, showMoreSuggestions, setShowMoreSuggestions, evaluationState, setEvaluationState, // Suggestions sugestoes, // Refs messagesEndRef, fileInputRef, audioInputRef, // Handlers handleToggle, handleExpandWithTab, handleEnviarMensagem, handleToggleFavorite, handleCopyMessage, handleGeneratePodcast, handleDownloadDocument, handleDownloadPodcast, handleEditDocument, handleNovaConversa, handleSelecionarConversa, handleToggleFavoritaConversa, handleOpenSearchResult, handleExecuteSearchCommand, handleRichSuggestionClick, handleEvaluationClick, openFeedbackDialog, handleSubmitDislike, }; }