import { useCallback, useEffect, useRef, useState } from 'react'; import type { ActionType, Message, XerticaAssistantProps } from 'xertica-ui/assistant'; import { generateDemoResponse } from 'xertica-ui/assistant'; // Shown in the chat itself — kept short and non-technical on purpose. The // 🔐/❌ marker makes AssistantMessageBubble render this as a highlighted // alert card instead of a plain reply bubble. Real diagnostic detail (this // fetch only fails like this when the proxy — `npm run server` in // templates/ — isn't running, or FDM_API_KEY is missing from .env) goes to // the browser console instead, where whoever's testing this can find it. const NETWORK_ERROR_MESSAGE = '❌ Não consegui me conectar ao assistente agora. Tente novamente em instantes.'; const GENERIC_ERROR_MESSAGE = '❌ Algo deu errado ao falar com o assistente. Tente novamente.'; const NOT_CONFIGURED_MESSAGE = '🔐 O assistente ainda não está configurado corretamente. Avise a equipe responsável.'; const ENABLE_DEMO_MODE_ACTION = 'enable-demo-mode'; const enableDemoModeButton = { id: ENABLE_DEMO_MODE_ACTION, label: 'Habilitar modo de demonstração', activeLabel: 'Desativar modo de demonstração', }; /** * Streams real answers from FDM's Agent Engine via server/fdm-proxy.mjs * (`npm run server`), never directly — FDM sends no CORS headers and the API * key must stay server-side. Shared by every XerticaAssistant instance (the * lateral panel on Home/Template/Settings + the full-page views) so the SSE * parsing and thread continuity live in one place. * * Also owns the "FDM isn't configured" fallback: on mount it checks * `GET /api/assistant/status`, and every failure path in `streamFdm` — the * proxy not running (`npm run server` never started), FDM_API_KEY missing, * or any other network/stream error — attaches a "Habilitar modo de * demonstração" button to the error message instead of leaving the user at * a dead end. Any of these states is treated the same way: the real * assistant isn't usable right now, so offer the escape hatch rather than * try to disambiguate why. Clicking it flips `assistantProps` over to the * design system's own mocked `demoMode`/`generateDemoResponse` — no page * needs to know which backend is actually answering. */ export function useFdmAssistant() { const threadIdRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [isConfigured, setIsConfigured] = useState(true); const [demoMode, setDemoMode] = useState(false); useEffect(() => { let cancelled = false; fetch('/api/assistant/status') .then(res => (res.ok ? res.json() : Promise.reject(new Error(`status ${res.status}`)))) .then(data => { if (!cancelled) setIsConfigured(typeof data?.configured === 'boolean' ? data.configured : false); }) .catch(() => { // Proxy unreachable (server not started) or returned something // unparseable (e.g. Vite's dev proxy answering for a dead upstream // with its own error page instead of a real HTTP failure) — either // way the real assistant isn't usable right now, so fail toward // offering demo mode rather than staying silently optimistic. if (!cancelled) setIsConfigured(false); }); return () => { cancelled = true; }; }, []); // The FDM assistant may call kb_search internally to ground *any* answer, // not just ones sent via the "Pesquisar" quick action — but the // "Resultados Encontrados" card should only ever show up when the user // explicitly asked to search, so `searchResults` events are only surfaced // into the message when `action === 'search'`. async function* streamFdm( message: string, action?: ActionType ): AsyncGenerator> { if (!isConfigured) { yield { content: NOT_CONFIGURED_MESSAGE, action: enableDemoModeButton }; return; } setIsProcessing(true); let accumulated = ''; let sawChunk = false; try { const res = await fetch('/api/assistant/message/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, threadId: threadIdRef.current }), }); if (!res.ok || !res.body) { const data = await res.json().catch(() => ({})); if (!data.error) console.error('Resposta inesperada do proxy do FDM:', res.status); // Any pre-stream failure — missing API key, invalid key, the proxy // itself down (a dead upstream often comes back as a non-JSON error // page rather than a `code` field) — means FDM isn't answerable // right now, so always offer the demo-mode escape hatch here. setIsConfigured(false); yield { content: data.error || GENERIC_ERROR_MESSAGE, action: enableDemoModeButton }; return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() ?? ''; for (const block of blocks) { let eventType = 'message'; const dataLines: string[] = []; for (const line of block.split('\n')) { if (line.startsWith('event:')) eventType = line.slice('event:'.length).trim(); else if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim()); } if (dataLines.length === 0) continue; const data = JSON.parse(dataLines.join('\n')); if (eventType === 'threadId') { threadIdRef.current = data.threadId ?? threadIdRef.current; } else if (eventType === 'searchResults') { if (action === 'search') { yield { attachmentType: 'search', searchResults: data.results, searchSources: data.sources, }; } } else if (eventType === 'chunk') { if (!sawChunk) { sawChunk = true; setIsProcessing(false); // the growing text itself is now the "it's responding" cue } accumulated += data.content; yield { content: accumulated }; } else if (eventType === 'error') { if (!data.error) console.error('Evento de erro sem mensagem recebido do proxy do FDM.'); yield { content: accumulated || data.error || GENERIC_ERROR_MESSAGE, action: enableDemoModeButton, }; return; } } } } catch (err) { console.error( 'Falha ao conectar no proxy do FDM — confirme que ele está rodando (`npm run server` dentro de templates/) e que FDM_API_KEY está configurada em templates/.env:', err ); setIsConfigured(false); yield { content: accumulated || NETWORK_ERROR_MESSAGE, action: enableDemoModeButton }; } finally { setIsProcessing(false); } } // Toggles rather than just enabling — the same button (in every alert // message it's ever appeared on) switches demo mode back off, since // `activeMessageActionId` below drives all of them off this one state. // Turning it back off also resets `isConfigured` optimistically, so the // next send gives the real FDM proxy a fresh chance instead of bouncing // straight back to the alert from stale state. const handleMessageAction = useCallback((actionId: string) => { if (actionId !== ENABLE_DEMO_MODE_ACTION) return; setDemoMode(prev => { const next = !prev; if (!next) setIsConfigured(true); return next; }); }, []); // Spread directly onto — pages never need to branch on // which backend is actually answering. const assistantProps: Partial = demoMode ? { demoMode: true, responseGenerator: generateDemoResponse, onMessageAction: handleMessageAction, activeMessageActionId: ENABLE_DEMO_MODE_ACTION, } : { streamResponseGenerator: streamFdm, isProcessing, // FDM has no text-to-speech/podcast tool (checked against its MCP // tool catalog: kb_*, vt_*, at_*, graph_*) — the mock "Generate // podcast" action/button would offer a capability the real // backend can't deliver, so it's hidden while answering via FDM. enablePodcastGeneration: false, onMessageAction: handleMessageAction, }; return { assistantProps, demoMode }; }