From 6012d0ced81fb5d3a2e346bf614cfceca156e922 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Wed, 4 Mar 2026 09:24:27 -0600 Subject: [PATCH 1/2] Ajuste de vista para las tablas #152 --- .../planes/detalle/Ia/ImprovementCard.tsx | 8 +- src/data/api/ai.api.ts | 75 ++++---- src/data/hooks/useAI.ts | 20 +++ src/routes/planes/$planId/_detalle/iaplan.tsx | 168 ++++++++++-------- 4 files changed, 162 insertions(+), 109 deletions(-) diff --git a/src/components/planes/detalle/Ia/ImprovementCard.tsx b/src/components/planes/detalle/Ia/ImprovementCard.tsx index 64bda36..64cdc29 100644 --- a/src/components/planes/detalle/Ia/ImprovementCard.tsx +++ b/src/components/planes/detalle/Ia/ImprovementCard.tsx @@ -8,6 +8,7 @@ export const ImprovementCard = ({ suggestions, onApply, planId, + dbMessageId, currentDatos, activeChatId, onApplySuccess, @@ -16,6 +17,7 @@ export const ImprovementCard = ({ onApply?: (key: string, value: string) => void planId: string currentDatos: any + dbMessageId: string activeChatId: any onApplySuccess?: (key: string) => void }) => { @@ -53,9 +55,11 @@ export const ImprovementCard = ({ setLocalApplied((prev) => [...prev, key]) if (onApplySuccess) onApplySuccess(key) - if (activeChatId) { + + // --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje --- + if (dbMessageId) { updateAppliedStatus.mutate({ - conversacionId: activeChatId, + conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario campoAfectado: key, }) } diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index 09602d6..d69669e 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -100,7 +100,7 @@ export async function library_search(payload: { export async function create_conversation(planId: string) { const supabase = supabaseBrowser() const { data, error } = await supabase.functions.invoke( - 'create-chat-conversation/conversations', + 'create-chat-conversation/plan/conversations', { method: 'POST', body: { @@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: { }): Promise<{ reply: string; meta?: any }> { const supabase = supabaseBrowser() const { data, error } = await supabase.functions.invoke( - `create-chat-conversation/conversations/${payload.conversacionId}/messages`, + `create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`, { method: 'POST', body: { @@ -175,6 +175,22 @@ export async function getConversationByPlan(planId: string) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return data ?? [] } +export async function getMessagesByConversation(conversationId: string) { + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('plan_mensajes_ia') + .select('*') + .eq('conversacion_plan_id', conversationId) + .order('fecha_creacion', { ascending: true }) // Ascendente para que el chat fluya en orden cronológico + + if (error) { + console.error('Error al obtener mensajes:', error.message) + throw error + } + + return data ?? [] +} export async function update_conversation_title( conversacionId: string, @@ -194,45 +210,40 @@ export async function update_conversation_title( } export async function update_recommendation_applied_status( - conversacionId: string, + mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente campoAfectado: string, ) { const supabase = supabaseBrowser() - // 1. Obtener el estado actual del JSON - const { data: conv, error: fetchError } = await supabase - .from('conversaciones_plan') - .select('conversacion_json') - .eq('id', conversacionId) + // 1. Obtener la propuesta actual de ese mensaje específico + const { data: msgData, error: fetchError } = await supabase + .from('plan_mensajes_ia') + .select('propuesta') + .eq('id', mensajeId) .single() if (fetchError) throw fetchError - if (!conv.conversacion_json) throw new Error('No se encontró la conversación') + if (!msgData?.propuesta) + throw new Error('No se encontró la propuesta en el mensaje') - // 2. Transformar el JSON para marcar como aplicada la recomendación específica - // Usamos una transformación inmutable para evitar efectos secundarios - const nuevoJson = (conv.conversacion_json as Array).map((msg) => { - if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) { - return { - ...msg, - recommendations: msg.recommendations.map((rec: any) => - rec.campo_afectado === campoAfectado - ? { ...rec, aplicada: true } - : rec, - ), - } - } - return msg - }) + const propuestaActual = msgData.propuesta as any - // 3. Actualizar la base de datos con el nuevo JSON - const { data, error: updateError } = await supabase - .from('conversaciones_plan') - .update({ conversacion_json: nuevoJson }) - .eq('id', conversacionId) - .select() - .single() + // 2. Modificar el array de recommendations dentro de la propuesta + // Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto + const nuevaPropuesta = { + ...propuestaActual, + recommendations: (propuestaActual.recommendations || []).map((rec: any) => + rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec, + ), + } + + // 3. Actualizar la base de datos con el nuevo objeto JSON + const { error: updateError } = await supabase + .from('plan_mensajes_ia') + .update({ propuesta: nuevaPropuesta }) + .eq('id', mensajeId) if (updateError) throw updateError - return data + + return true } diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index b3b5dbb..1e23617 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -12,6 +12,7 @@ import { update_conversation_status, update_recommendation_applied_status, update_conversation_title, + getMessagesByConversation, } from '../api/ai.api' // eslint-disable-next-line node/prefer-node-protocol @@ -88,6 +89,25 @@ 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], + + // Solo ejecutamos la función si el ID no es null o undefined + 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, + }) +} + export function useUpdateRecommendationApplied() { const qc = useQueryClient() diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index 283d560..35965ab 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea' import { useAIPlanChat, useConversationByPlan, + useMessagesByChat, useUpdateConversationStatus, useUpdateConversationTitle, } from '@/data' @@ -103,6 +104,8 @@ function RouteComponent() { ) const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) + const { data: mensajesDelChat, isLoading: isLoadingMessages } = + useMessagesByChat(activeChatId) const [selectedArchivoIds, setSelectedArchivoIds] = useState>( [], ) @@ -154,53 +157,50 @@ function RouteComponent() { }, [lastConversation, activeChatId]) const chatMessages = useMemo(() => { - // 1. Si no hay ID o no hay data del chat, retornamos vacío - if (!activeChatId || !activeChatData) return [] + if (!activeChatId || !mensajesDelChat) return [] - const json = (activeChatData.conversacion_json || - []) as unknown as Array + // flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD + return mensajesDelChat.flatMap((msg: any) => { + const messages = [] - // 2. Verificamos que 'json' sea realmente un array antes de mapear - if (!Array.isArray(json)) return [] + // 1. Mensaje del Usuario + messages.push({ + id: `${msg.id}-user`, + role: 'user', + content: msg.mensaje, + selectedFields: msg.campos || [], // Aquí están tus campos + }) - return json.map((msg, index: number) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!msg?.user) { - return { - id: `err-${index}`, + // 2. Mensaje del Asistente (si hay respuesta) + if (msg.respuesta) { + // Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations + const rawRecommendations = msg.propuesta?.recommendations || [] + + messages.push({ + id: `${msg.id}-ai`, + dbMessageId: msg.id, role: 'assistant', - content: '', - suggestions: [], - } + content: msg.respuesta, + isRefusal: msg.is_refusal, + suggestions: rawRecommendations.map((rec: any) => { + const fieldConfig = availableFields.find( + (f) => f.key === rec.campo_afectado, + ) + return { + key: rec.campo_afectado, + label: fieldConfig + ? fieldConfig.label + : rec.campo_afectado.replace(/_/g, ' '), + newValue: rec.texto_mejora, + applied: rec.aplicada, + } + }), + }) } - const isAssistant = msg.user === 'assistant' - - return { - id: `${activeChatId}-${index}`, - role: isAssistant ? 'assistant' : 'user', - content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío - isRefusal: isAssistant && msg.refusal === true, - suggestions: - isAssistant && msg.recommendations - ? msg.recommendations.map((rec) => { - const fieldConfig = availableFields.find( - (f) => f.key === rec.campo_afectado, - ) - return { - key: rec.campo_afectado, - label: fieldConfig - ? fieldConfig.label - : rec.campo_afectado.replace(/_/g, ' '), - newValue: rec.texto_mejora, - applied: rec.aplicada, - } - }) - : [], - } + return messages }) - }, [activeChatData, activeChatId, availableFields]) - + }, [mensajesDelChat, activeChatId, availableFields]) const scrollToBottom = () => { if (scrollRef.current) { // Buscamos el viewport interno del ScrollArea de Radix @@ -226,6 +226,8 @@ function RouteComponent() { }, [lastConversation]) useEffect(() => { + console.log(mensajesDelChat) + scrollToBottom() }, [chatMessages, isLoading]) @@ -242,30 +244,38 @@ function RouteComponent() { }, [input, selectedFields]) useEffect(() => { - if (isLoadingConv || !lastConversation) return + if (isLoadingConv || isSending) return - const isChatStillActive = activeChats.some( + const currentChatExists = activeChats.some( (chat) => chat.id === activeChatId, ) const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' - // Caso A: El chat actual ya no es válido (fue archivado o borrado) - if (activeChatId && !isChatStillActive && !isCreationMode) { + // 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó) + if (activeChatId && !currentChatExists && !isCreationMode) { setActiveChatId(undefined) setMessages([]) - return // Salimos para evitar ejecuciones extra en este render + return } - // Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar) - if (!activeChatId && activeChats.length > 0 && !isCreationMode) { + // 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats + if ( + !activeChatId && + activeChats.length > 0 && + !isCreationMode && + chatMessages.length === 0 + ) { setActiveChatId(activeChats[0].id) } + }, [ + activeChats, + activeChatId, + isLoadingConv, + isSending, + messages.length, + chatMessages.length, + ]) - // Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso - if (activeChats.length === 0 && activeChatId && !isCreationMode) { - setActiveChatId(undefined) - } - }, [activeChats, activeChatId, isLoadingConv, messages.length]) useEffect(() => { const state = routerState.location.state as any if (!state?.campo_edit || availableFields.length === 0) return @@ -352,13 +362,16 @@ function RouteComponent() { input: string, fields: Array, ) => { - const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() + // 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso) + // Esta regex ahora también limpia si el texto termina de forma natural + const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim() if (fields.length === 0) return cleaned const fieldLabels = fields.map((f) => f.label).join(', ') - return `${cleaned}\n[Campos: ${fieldLabels}]` + // 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo" + return `${cleaned}: ${fieldLabels}` } const toggleField = (field: SelectedField) => { @@ -388,42 +401,46 @@ function RouteComponent() { const handleSend = async (promptOverride?: string) => { const rawText = promptOverride || input - if (!rawText.trim() && selectedFields.length === 0) return if (isSending || (!rawText.trim() && selectedFields.length === 0)) return + const currentFields = [...selectedFields] - const finalPrompt = buildPrompt(rawText, currentFields) setIsSending(true) setOptimisticMessage(rawText) - setInput('') - setSelectedArchivoIds([]) - setSelectedRepositorioIds([]) - setUploadedFiles([]) - try { - const payload: any = { - planId: planId, - content: finalPrompt, - conversacionId: activeChatId || undefined, - } - if (currentFields.length > 0) { - payload.campos = currentFields.map((f) => f.key) + // Limpiar input inmediatamente para feedback visual + setInput('') + setSelectedFields([]) + + try { + const payload = { + planId, + content: buildPrompt(rawText, currentFields), + conversacionId: activeChatId, + campos: + currentFields.length > 0 + ? currentFields.map((f) => f.key) + : undefined, } const response = await sendChat(payload) + // IMPORTANTE: Si es un chat nuevo, actualizar el ID antes de invalidar if (response.conversacionId && response.conversacionId !== activeChatId) { setActiveChatId(response.conversacionId) } - await queryClient.invalidateQueries({ - queryKey: ['conversation-by-plan', planId], - }) - setOptimisticMessage(null) + // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se refresquen + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['conversation-by-plan', planId], + }), + queryClient.invalidateQueries({ + queryKey: ['conversation-messages', response.conversacionId], + }), + ]) } catch (error) { - console.error('Error en el chat:', error) - // Aquí sí podrías usar un toast o un mensaje de error temporal + console.error('Error:', error) } finally { - // 5. CRÍTICO: Detener el estado de carga SIEMPRE setIsSending(false) setOptimisticMessage(null) } @@ -666,6 +683,7 @@ function RouteComponent() {
Date: Wed, 4 Mar 2026 09:25:10 -0600 Subject: [PATCH 2/2] Ajuste de vista para las tablas fix #152 --- src/routes/planes/$planId/_detalle/iaplan.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index 35965ab..08ae97b 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -429,7 +429,7 @@ function RouteComponent() { setActiveChatId(response.conversacionId) } - // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se refresquen + // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se await Promise.all([ queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], -- 2.49.1