Que haya chat de la IA #149 #159
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user