Ajuste de vista para las tablas #152

This commit is contained in:
2026-03-04 09:24:27 -06:00
parent 314a96f2c5
commit 6012d0ced8
4 changed files with 162 additions and 109 deletions

View File

@@ -8,6 +8,7 @@ export const ImprovementCard = ({
suggestions, suggestions,
onApply, onApply,
planId, planId,
dbMessageId,
currentDatos, currentDatos,
activeChatId, activeChatId,
onApplySuccess, onApplySuccess,
@@ -16,6 +17,7 @@ export const ImprovementCard = ({
onApply?: (key: string, value: string) => void onApply?: (key: string, value: string) => void
planId: string planId: string
currentDatos: any currentDatos: any
dbMessageId: string
activeChatId: any activeChatId: any
onApplySuccess?: (key: string) => void onApplySuccess?: (key: string) => void
}) => { }) => {
@@ -53,9 +55,11 @@ export const ImprovementCard = ({
setLocalApplied((prev) => [...prev, key]) setLocalApplied((prev) => [...prev, key])
if (onApplySuccess) onApplySuccess(key) if (onApplySuccess) onApplySuccess(key)
if (activeChatId) {
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) {
updateAppliedStatus.mutate({ updateAppliedStatus.mutate({
conversacionId: activeChatId, conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
campoAfectado: key, campoAfectado: key,
}) })
} }

View File

@@ -100,7 +100,7 @@ export async function library_search(payload: {
export async function create_conversation(planId: string) { export async function create_conversation(planId: string) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations', 'create-chat-conversation/plan/conversations',
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
}): Promise<{ reply: string; meta?: any }> { }): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`, `create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`,
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -175,6 +175,22 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] 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( export async function update_conversation_title(
conversacionId: string, conversacionId: string,
@@ -194,45 +210,40 @@ export async function update_conversation_title(
} }
export async function update_recommendation_applied_status( export async function update_recommendation_applied_status(
conversacionId: string, mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente
campoAfectado: string, campoAfectado: string,
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
// 1. Obtener el estado actual del JSON // 1. Obtener la propuesta actual de ese mensaje específico
const { data: conv, error: fetchError } = await supabase const { data: msgData, error: fetchError } = await supabase
.from('conversaciones_plan') .from('plan_mensajes_ia')
.select('conversacion_json') .select('propuesta')
.eq('id', conversacionId) .eq('id', mensajeId)
.single() .single()
if (fetchError) throw fetchError 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 const propuestaActual = msgData.propuesta as any
// Usamos una transformación inmutable para evitar efectos secundarios
const nuevoJson = (conv.conversacion_json as Array<any>).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
})
// 3. Actualizar la base de datos con el nuevo JSON // 2. Modificar el array de recommendations dentro de la propuesta
const { data, error: updateError } = await supabase // Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto
.from('conversaciones_plan') const nuevaPropuesta = {
.update({ conversacion_json: nuevoJson }) ...propuestaActual,
.eq('id', conversacionId) recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
.select() rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
.single() ),
}
// 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 if (updateError) throw updateError
return data
return true
} }

View File

@@ -12,6 +12,7 @@ import {
update_conversation_status, update_conversation_status,
update_recommendation_applied_status, update_recommendation_applied_status,
update_conversation_title, update_conversation_title,
getMessagesByConversation,
} from '../api/ai.api' } from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol // 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() { export function useUpdateRecommendationApplied() {
const qc = useQueryClient() const qc = useQueryClient()

View File

@@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'
import { import {
useAIPlanChat, useAIPlanChat,
useConversationByPlan, useConversationByPlan,
useMessagesByChat,
useUpdateConversationStatus, useUpdateConversationStatus,
useUpdateConversationTitle, useUpdateConversationTitle,
} from '@/data' } from '@/data'
@@ -103,6 +104,8 @@ function RouteComponent() {
) )
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -154,53 +157,50 @@ function RouteComponent() {
}, [lastConversation, activeChatId]) }, [lastConversation, activeChatId])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
// 1. Si no hay ID o no hay data del chat, retornamos vacío if (!activeChatId || !mensajesDelChat) return []
if (!activeChatId || !activeChatData) return []
const json = (activeChatData.conversacion_json || // flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
[]) as unknown as Array<ChatMessageJSON> return mensajesDelChat.flatMap((msg: any) => {
const messages = []
// 2. Verificamos que 'json' sea realmente un array antes de mapear // 1. Mensaje del Usuario
if (!Array.isArray(json)) return [] 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) => { // 2. Mensaje del Asistente (si hay respuesta)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (msg.respuesta) {
if (!msg?.user) { // Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
return { const rawRecommendations = msg.propuesta?.recommendations || []
id: `err-${index}`,
messages.push({
id: `${msg.id}-ai`,
dbMessageId: msg.id,
role: 'assistant', role: 'assistant',
content: '', content: msg.respuesta,
suggestions: [], 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 messages
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,
}
})
: [],
}
}) })
}, [activeChatData, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollRef.current) { if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix // Buscamos el viewport interno del ScrollArea de Radix
@@ -226,6 +226,8 @@ function RouteComponent() {
}, [lastConversation]) }, [lastConversation])
useEffect(() => { useEffect(() => {
console.log(mensajesDelChat)
scrollToBottom() scrollToBottom()
}, [chatMessages, isLoading]) }, [chatMessages, isLoading])
@@ -242,30 +244,38 @@ function RouteComponent() {
}, [input, selectedFields]) }, [input, selectedFields])
useEffect(() => { useEffect(() => {
if (isLoadingConv || !lastConversation) return if (isLoadingConv || isSending) return
const isChatStillActive = activeChats.some( const currentChatExists = activeChats.some(
(chat) => chat.id === activeChatId, (chat) => chat.id === activeChatId,
) )
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// Caso A: El chat actual ya no es válido (fue archivado o borrado) // 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !isChatStillActive && !isCreationMode) { if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) 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) // 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if (!activeChatId && activeChats.length > 0 && !isCreationMode) { if (
!activeChatId &&
activeChats.length > 0 &&
!isCreationMode &&
chatMessages.length === 0
) {
setActiveChatId(activeChats[0].id) 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(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return if (!state?.campo_edit || availableFields.length === 0) return
@@ -352,13 +362,16 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
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 if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ') 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) => { const toggleField = (field: SelectedField) => {
@@ -388,42 +401,46 @@ function RouteComponent() {
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields)
setIsSending(true) setIsSending(true)
setOptimisticMessage(rawText) setOptimisticMessage(rawText)
setInput('')
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try {
const payload: any = {
planId: planId,
content: finalPrompt,
conversacionId: activeChatId || undefined,
}
if (currentFields.length > 0) { // Limpiar input inmediatamente para feedback visual
payload.campos = currentFields.map((f) => f.key) 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) const response = await sendChat(payload)
// 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)
} }
await queryClient.invalidateQueries({ // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se refresquen
queryKey: ['conversation-by-plan', planId], await Promise.all([
}) queryClient.invalidateQueries({
setOptimisticMessage(null) queryKey: ['conversation-by-plan', planId],
}),
queryClient.invalidateQueries({
queryKey: ['conversation-messages', response.conversacionId],
}),
])
} catch (error) { } catch (error) {
console.error('Error en el chat:', error) console.error('Error:', error)
// Aquí sí podrías usar un toast o un mensaje de error temporal
} finally { } finally {
// 5. CRÍTICO: Detener el estado de carga SIEMPRE
setIsSending(false) setIsSending(false)
setOptimisticMessage(null) setOptimisticMessage(null)
} }
@@ -666,6 +683,7 @@ function RouteComponent() {
<div className="mt-4"> <div className="mt-4">
<ImprovementCard <ImprovementCard
suggestions={msg.suggestions} suggestions={msg.suggestions}
dbMessageId={msg.dbMessageId}
planId={planId} planId={planId}
currentDatos={data?.datos} currentDatos={data?.datos}
activeChatId={activeChatId} activeChatId={activeChatId}