From a9f38e6d7264b8d42957f711e039db8d4a0a462d Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Mon, 9 Mar 2026 14:02:08 -0600 Subject: [PATCH] Se agrega realtime para chat de ia plan --- src/data/hooks/useAI.ts | 57 +++++-- src/routes/planes/$planId/_detalle/iaplan.tsx | 142 +++++++++++------- 2 files changed, 133 insertions(+), 66 deletions(-) diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index 1f95e4a..bc8b8dd 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' import { ai_plan_chat_v2, @@ -19,9 +20,9 @@ import { ai_subject_chat_v2, create_subject_conversation, } from '../api/ai.api' +import { supabaseBrowser } from '../supabase/client' -// eslint-disable-next-line node/prefer-node-protocol -import type { UUID } from 'crypto' +import type { UUID } from 'node:crypto' export function useAIPlanImprove() { return useMutation({ mutationFn: ai_plan_improve }) @@ -95,22 +96,58 @@ export function useConversationByPlan(planId: string | null) { } export function useMessagesByChat(conversationId: string | null) { - return useQuery({ - // La queryKey debe ser única; incluimos el ID para que se refresque al cambiar de chat - queryKey: ['conversation-messages', conversationId], + const queryClient = useQueryClient() + const supabase = supabaseBrowser() - // Solo ejecutamos la función si el ID no es null o undefined + const query = useQuery({ + queryKey: ['conversation-messages', conversationId], queryFn: () => { if (!conversationId) throw new Error('Conversation ID is required') return getMessagesByConversation(conversationId) }, - - // Importante: 'enabled' controla que no se dispare la petición si no hay ID enabled: !!conversationId, - - // Opcional: Mantener los datos previos mientras se carga la nueva conversación placeholderData: (previousData) => previousData, }) + + useEffect(() => { + if (!conversationId) return + + // Suscribirse a cambios en los mensajes de ESTA conversación + const channel = supabase + .channel(`realtime-messages-${conversationId}`) + .on( + 'postgres_changes', + { + event: '*', // Escuchamos INSERT y UPDATE + schema: 'public', + table: 'plan_mensajes_ia', + filter: `conversacion_plan_id=eq.${conversationId}`, + }, + (payload) => { + // Opción A: Invalidar la query para que React Query haga refetch (más seguro) + queryClient.invalidateQueries({ + queryKey: ['conversation-messages', conversationId], + }) + + /* Opción B: Actualización manual del caché (más rápido/fluido) + if (payload.eventType === 'INSERT') { + queryClient.setQueryData(['conversation-messages', conversationId], (old: any) => [...old, payload.new]) + } else if (payload.eventType === 'UPDATE') { + queryClient.setQueryData(['conversation-messages', conversationId], (old: any) => + old.map((m: any) => m.id === payload.new.id ? payload.new : m) + ) + } + */ + }, + ) + .subscribe() + + return () => { + supabase.removeChannel(channel) + } + }, [conversationId, queryClient, supabase]) + + return query } export function useUpdateRecommendationApplied() { diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index 08ae97b..ee3519d 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -98,14 +98,14 @@ function RouteComponent() { const [openIA, setOpenIA] = useState(false) const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat() const { mutate: updateStatusMutation } = useUpdateConversationStatus() - + const [isSyncing, setIsSyncing] = useState(false) const [activeChatId, setActiveChatId] = useState( undefined, ) const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) const { data: mensajesDelChat, isLoading: isLoadingMessages } = - useMessagesByChat(activeChatId) + useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null const [selectedArchivoIds, setSelectedArchivoIds] = useState>( [], ) @@ -152,10 +152,6 @@ function RouteComponent() { ) }, [availableFields, filterQuery, selectedFields]) - const activeChatData = useMemo(() => { - return lastConversation?.find((chat: any) => chat.id === activeChatId) - }, [lastConversation, activeChatId]) - const chatMessages = useMemo(() => { if (!activeChatId || !mensajesDelChat) return [] @@ -274,6 +270,7 @@ function RouteComponent() { isSending, messages.length, chatMessages.length, + messages, ]) useEffect(() => { @@ -288,7 +285,7 @@ function RouteComponent() { setInput((prev) => injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), ) - }, [availableFields]) + }, [availableFields, routerState.location.state]) const createNewChat = () => { setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo @@ -404,17 +401,16 @@ function RouteComponent() { if (isSending || (!rawText.trim() && selectedFields.length === 0)) return const currentFields = [...selectedFields] + const finalContent = buildPrompt(rawText, currentFields) setIsSending(true) - setOptimisticMessage(rawText) - - // Limpiar input inmediatamente para feedback visual + setOptimisticMessage(finalContent) setInput('') setSelectedFields([]) try { const payload = { - planId, - content: buildPrompt(rawText, currentFields), + planId: planId as any, + content: finalContent, conversacionId: activeChatId, campos: currentFields.length > 0 @@ -423,13 +419,12 @@ function RouteComponent() { } const response = await sendChat(payload) - - // IMPORTANTE: Si es un chat nuevo, actualizar el ID antes de invalidar + setIsSyncing(true) if (response.conversacionId && response.conversacionId !== activeChatId) { setActiveChatId(response.conversacionId) } - // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se + // ESPERAMOS a que la caché se actualice antes de quitar el "isSending" await Promise.all([ queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], @@ -440,12 +435,27 @@ function RouteComponent() { ]) } catch (error) { console.error('Error:', error) - } finally { - setIsSending(false) setOptimisticMessage(null) + } finally { + // Solo ahora quitamos los indicadores de carga + setIsSending(false) + // setOptimisticMessage(null) } } + useEffect(() => { + if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return + + // Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja + const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any + + // Ahora la validación es directa y no debería dar avisos de "unnecessary" + if (ultimoMensajeDB?.respuesta) { + setIsSyncing(false) + setOptimisticMessage(null) + } + }, [mensajesDelChat, isSyncing]) + const totalReferencias = useMemo(() => { return ( selectedArchivoIds.length + @@ -647,42 +657,55 @@ function RouteComponent() { ) : ( <> - {chatMessages.map((msg: any) => ( -
+ {chatMessages.map((msg: any) => { + const isAI = msg.role === 'assistant' + const isUser = msg.role === 'user' + // IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map + const isProcessing = msg.isProcessing + + return (
- {/* Icono opcional de advertencia si es refusal */} - {msg.isRefusal && ( -
- Aviso del Asistente -
- )} +
+ {/* Aviso de Refusal */} + {msg.isRefusal && ( +
+ Aviso del Asistente +
+ )} - {msg.content} + {/* CONTENIDO CORRECTO: Usamos msg.content */} + {isAI && isProcessing ? ( +
+
+ + + +
+
+ ) : ( + msg.content // <--- CAMBIO CLAVE + )} - {!msg.isRefusal && - msg.suggestions && - msg.suggestions.length > 0 && ( + {/* Recomendaciones */} + {isAI && msg.suggestions?.length > 0 && (
)} +
-
- ))} + ) + })} - {optimisticMessage && ( -
-
- {optimisticMessage} + {(isSending || isSyncing) && + optimisticMessage && + !chatMessages.some( + (m) => m.content === optimisticMessage, + ) && ( +
+
+ {optimisticMessage} +
-
- )} + )} - {isSending && ( + {(isSending || isSyncing) && (
@@ -715,7 +743,9 @@ function RouteComponent() {
- Esperando respuesta... + {isSyncing + ? 'Actualizando historial...' + : 'Esperando respuesta...'}