Que haya chat de la IA #149 #159

Merged
roberto.silva merged 3 commits from issue/149-que-haya-chat-de-la-ia into main 2026-03-09 20:18:31 +00:00
2 changed files with 133 additions and 66 deletions
Showing only changes of commit a9f38e6d72 - Show all commits

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_plan_chat_v2, ai_plan_chat_v2,
@@ -19,9 +20,9 @@ import {
ai_subject_chat_v2, ai_subject_chat_v2,
create_subject_conversation, create_subject_conversation,
} from '../api/ai.api' } from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client'
// eslint-disable-next-line node/prefer-node-protocol import type { UUID } from 'node:crypto'
import type { UUID } from 'crypto'
export function useAIPlanImprove() { export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve }) return useMutation({ mutationFn: ai_plan_improve })
@@ -95,22 +96,58 @@ export function useConversationByPlan(planId: string | null) {
} }
export function useMessagesByChat(conversationId: string | null) { export function useMessagesByChat(conversationId: string | null) {
return useQuery({ const queryClient = useQueryClient()
// La queryKey debe ser única; incluimos el ID para que se refresque al cambiar de chat const supabase = supabaseBrowser()
queryKey: ['conversation-messages', conversationId],
// Solo ejecutamos la función si el ID no es null o undefined const query = useQuery({
queryKey: ['conversation-messages', conversationId],
queryFn: () => { queryFn: () => {
if (!conversationId) throw new Error('Conversation ID is required') if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesByConversation(conversationId) return getMessagesByConversation(conversationId)
}, },
// Importante: 'enabled' controla que no se dispare la petición si no hay ID
enabled: !!conversationId, enabled: !!conversationId,
// Opcional: Mantener los datos previos mientras se carga la nueva conversación
placeholderData: (previousData) => previousData, 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() { export function useUpdateRecommendationApplied() {

View File

@@ -98,14 +98,14 @@ function RouteComponent() {
const [openIA, setOpenIA] = useState(false) const [openIA, setOpenIA] = useState(false)
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat() const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
const { mutate: updateStatusMutation } = useUpdateConversationStatus() const { mutate: updateStatusMutation } = useUpdateConversationStatus()
const [isSyncing, setIsSyncing] = useState(false)
const [activeChatId, setActiveChatId] = useState<string | undefined>( const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined, undefined,
) )
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } = const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId) useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -152,10 +152,6 @@ function RouteComponent() {
) )
}, [availableFields, filterQuery, selectedFields]) }, [availableFields, filterQuery, selectedFields])
const activeChatData = useMemo(() => {
return lastConversation?.find((chat: any) => chat.id === activeChatId)
}, [lastConversation, activeChatId])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
if (!activeChatId || !mensajesDelChat) return [] if (!activeChatId || !mensajesDelChat) return []
@@ -274,6 +270,7 @@ function RouteComponent() {
isSending, isSending,
messages.length, messages.length,
chatMessages.length, chatMessages.length,
messages,
]) ])
useEffect(() => { useEffect(() => {
@@ -288,7 +285,7 @@ function RouteComponent() {
setInput((prev) => setInput((prev) =>
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
) )
}, [availableFields]) }, [availableFields, routerState.location.state])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo 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 if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const currentFields = [...selectedFields]
const finalContent = buildPrompt(rawText, currentFields)
setIsSending(true) setIsSending(true)
setOptimisticMessage(rawText) setOptimisticMessage(finalContent)
// Limpiar input inmediatamente para feedback visual
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
try { try {
const payload = { const payload = {
planId, planId: planId as any,
content: buildPrompt(rawText, currentFields), content: finalContent,
conversacionId: activeChatId, conversacionId: activeChatId,
campos: campos:
currentFields.length > 0 currentFields.length > 0
@@ -423,13 +419,12 @@ function RouteComponent() {
} }
const response = await sendChat(payload) const response = await sendChat(payload)
setIsSyncing(true)
// IMPORTANTE: Si es un chat nuevo, actualizar el ID antes de invalidar
if (response.conversacionId && response.conversacionId !== activeChatId) { if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId) 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([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], queryKey: ['conversation-by-plan', planId],
@@ -440,12 +435,27 @@ function RouteComponent() {
]) ])
} catch (error) { } catch (error) {
console.error('Error:', error) console.error('Error:', error)
} finally {
setIsSending(false)
setOptimisticMessage(null) 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(() => { const totalReferencias = useMemo(() => {
return ( return (
selectedArchivoIds.length + selectedArchivoIds.length +
@@ -647,42 +657,55 @@ function RouteComponent() {
</div> </div>
) : ( ) : (
<> <>
{chatMessages.map((msg: any) => ( {chatMessages.map((msg: any) => {
<div const isAI = msg.role === 'assistant'
key={msg.id} const isUser = msg.role === 'user'
className={`flex max-w-[85%] flex-col ${ // IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
msg.role === 'user' const isProcessing = msg.isProcessing
? 'ml-auto items-end'
: 'items-start' return (
}`}
>
<div <div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${ key={msg.id}
msg.role === 'user' className={`flex max-w-[85%] flex-col ${
? 'rounded-tr-none bg-teal-600 text-white' isUser ? 'ml-auto items-end' : 'items-start'
: `rounded-tl-none border bg-white text-slate-700 ${
// --- LÓGICA DE REFUSAL ---
msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
}`
}`} }`}
> >
{/* Icono opcional de advertencia si es refusal */} <div
{msg.isRefusal && ( className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase"> isUser
<span>Aviso del Asistente</span> ? 'rounded-tr-none bg-teal-600 text-white'
</div> : `rounded-tl-none border bg-white text-slate-700 ${
)} msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
}`
}`}
>
{/* Aviso de Refusal */}
{msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span>
</div>
)}
{msg.content} {/* CONTENIDO CORRECTO: Usamos msg.content */}
{isAI && isProcessing ? (
<div className="flex items-center gap-2 py-1">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
</div>
</div>
) : (
msg.content // <--- CAMBIO CLAVE
)}
{!msg.isRefusal && {/* Recomendaciones */}
msg.suggestions && {isAI && msg.suggestions?.length > 0 && (
msg.suggestions.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<ImprovementCard <ImprovementCard
suggestions={msg.suggestions} suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap
dbMessageId={msg.dbMessageId} dbMessageId={msg.dbMessageId}
planId={planId} planId={planId}
currentDatos={data?.datos} currentDatos={data?.datos}
@@ -693,19 +716,24 @@ function RouteComponent() {
/> />
</div> </div>
)} )}
</div>
</div> </div>
</div> )
))} })}
{optimisticMessage && ( {(isSending || isSyncing) &&
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end"> optimisticMessage &&
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm"> !chatMessages.some(
{optimisticMessage} (m) => m.content === optimisticMessage,
) && (
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
{optimisticMessage}
</div>
</div> </div>
</div> )}
)}
{isSending && ( {(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300"> <div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm"> <div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -715,7 +743,9 @@ function RouteComponent() {
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div> </div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase"> <span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
Esperando respuesta... {isSyncing
? 'Actualizando historial...'
: 'Esperando respuesta...'}
</span> </span>
</div> </div>
</div> </div>