/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions */ import { useQueryClient } from '@tanstack/react-query' import { createFileRoute, useRouterState } from '@tanstack/react-router' import { Send, Target, Lightbulb, FileText, GraduationCap, BookOpen, Check, X, MessageSquarePlus, Archive, Loader2, Sparkles, RotateCcw, } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone' import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Drawer, DrawerContent } from '@/components/ui/drawer' import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { useAIPlanChat, useConversationByPlan, useMessagesByChat, useUpdateConversationStatus, useUpdateConversationTitle, } from '@/data' import { usePlan } from '@/data/hooks/usePlans' const PRESETS = [ { id: 'objetivo', label: 'Mejorar objetivo general', icon: Target, prompt: 'Mejora la redacción del objetivo general...', }, { id: 'perfil-egreso', label: 'Redactar perfil de egreso', icon: GraduationCap, prompt: 'Genera un perfil de egreso detallado...', }, { id: 'competencias', label: 'Sugerir competencias', icon: BookOpen, prompt: 'Genera una lista de competencias...', }, { id: 'pertinencia', label: 'Justificar pertinencia', icon: FileText, prompt: 'Redacta una justificación de pertinencia...', }, ] // --- Tipado y Helpers --- interface SelectedField { key: string label: string value: string } interface EstructuraDefinicion { properties?: { [key: string]: { title: string description?: string } } } interface ChatMessageJSON { user: 'user' | 'assistant' message?: string prompt?: string refusal?: boolean recommendations?: Array<{ campo_afectado: string texto_mejora: string aplicada: boolean }> } export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({ component: RouteComponent, }) function RouteComponent() { const { planId } = Route.useParams() const { data } = usePlan(planId) const routerState = useRouterState() 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 ?? null) // Si es undefined, pasa null const [selectedArchivoIds, setSelectedArchivoIds] = useState>( [], ) const [selectedRepositorioIds, setSelectedRepositorioIds] = useState< Array >([]) const [uploadedFiles, setUploadedFiles] = useState>([]) const [messages, setMessages] = useState>([]) const [input, setInput] = useState('') const [selectedFields, setSelectedFields] = useState>([]) const [showSuggestions, setShowSuggestions] = useState(false) const [pendingSuggestion, setPendingSuggestion] = useState(null) const queryClient = useQueryClient() const scrollRef = useRef(null) const isInitialLoad = useRef(true) const [showArchived, setShowArchived] = useState(false) const [editingChatId, setEditingChatId] = useState(null) const editableRef = useRef(null) const { mutate: updateTitleMutation } = useUpdateConversationTitle() const [isSending, setIsSending] = useState(false) const [optimisticMessage, setOptimisticMessage] = useState( null, ) const [filterQuery, setFilterQuery] = useState('') const availableFields = useMemo(() => { const definicion = data?.estructuras_plan ?.definicion as EstructuraDefinicion // Encadenamiento opcional para evitar errores si data es null if (!definicion.properties) return [] return Object.entries(definicion.properties).map(([key, value]) => ({ key, label: value.title, value: String(value.description || ''), })) }, [data]) const filteredFields = useMemo(() => { return availableFields.filter( (field) => field.label.toLowerCase().includes(filterQuery.toLowerCase()) && !selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados ) }, [availableFields, filterQuery, selectedFields]) const chatMessages = useMemo(() => { if (!activeChatId || !mensajesDelChat) return [] // flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD return mensajesDelChat.flatMap((msg: any) => { const messages = [] // 1. Mensaje del Usuario messages.push({ id: `${msg.id}-user`, role: 'user', content: msg.mensaje, selectedFields: msg.campos || [], // Aquí están tus campos }) // 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: 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, } }), }) } return messages }) }, [mensajesDelChat, activeChatId, availableFields]) const scrollToBottom = (behavior = 'smooth') => { if (scrollRef.current) { const scrollContainer = scrollRef.current.querySelector( '[data-radix-scroll-area-viewport]', ) if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos }) } } } const { activeChats, archivedChats } = useMemo(() => { const allChats = lastConversation || [] return { activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'), archivedChats: allChats.filter( (chat: any) => chat.estado === 'ARCHIVADA', ), } }, [lastConversation]) useEffect(() => { if (chatMessages.length > 0) { if (isInitialLoad.current) { // Si es el primer render con mensajes, vamos al final al instante scrollToBottom('instant') isInitialLoad.current = false } else { // Si ya estaba cargado y llegan nuevos, hacemos el smooth scrollToBottom('smooth') } } }, [chatMessages]) // 2. Resetear el flag cuando cambies de chat activo useEffect(() => { isInitialLoad.current = true }, [activeChatId]) useEffect(() => { if (isLoadingConv || isSending) return const currentChatExists = activeChats.some( (chat) => chat.id === activeChatId, ) const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' // 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó) if (activeChatId && !currentChatExists && !isCreationMode) { setActiveChatId(undefined) setMessages([]) return } // 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, messages, ]) useEffect(() => { const state = routerState.location.state as any if (!state?.campo_edit || availableFields.length === 0) return const field = availableFields.find( (f) => f.value === state.campo_edit.label || f.key === state.campo_edit.clave, ) if (!field) return setSelectedFields([field]) setInput((prev) => injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), ) }, [availableFields, routerState.location.state]) const createNewChat = () => { setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo setMessages([ { id: 'welcome', role: 'assistant', content: 'Iniciando una nueva conversación. ¿En qué puedo ayudarte?', }, ]) setInput('') // setSelectedFields([]) } const archiveChat = (e: React.MouseEvent, id: string) => { e.stopPropagation() updateStatusMutation( { id, estado: 'ARCHIVADA' }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }) if (activeChatId === id) { setActiveChatId(undefined) setMessages([]) setOptimisticMessage(null) setInput('') setSelectedFields([]) } }, }, ) } const unarchiveChat = (e: React.MouseEvent, id: string) => { e.stopPropagation() updateStatusMutation( { id, estado: 'ACTIVA' }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }) }, }, ) } const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario setInput(val) // Busca un ":" seguido de letras justo antes del cursor const textBeforeCursor = val.slice(0, cursorPosition) const match = textBeforeCursor.match(/:(\w*)$/) if (match) { setShowSuggestions(true) setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado } else { setShowSuggestions(false) setFilterQuery('') } } const injectFieldsIntoInput = ( input: string, fields: Array, ) => { // 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(', ') // 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo" return `${cleaned}: ${fieldLabels}` } const toggleField = (field: SelectedField) => { // 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar) setSelectedFields((prev) => { const isSelected = prev.find((f) => f.key === field.key) return isSelected ? prev : [...prev, field] }) // 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":" setInput((prev) => { // Reemplaza el último ":" y cualquier texto de filtro por el label del campo const nuevoTexto = prev.replace(/:(\w*)$/, field.label) return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo }) // 3. Limpiamos estados de búsqueda setShowSuggestions(false) setFilterQuery('') } const buildPrompt = (userInput: string, fields: Array) => { if (fields.length === 0) return userInput return ` ${userInput}` } const handleSend = async (promptOverride?: string) => { const rawText = promptOverride || input if (isSending || (!rawText.trim() && selectedFields.length === 0)) return const currentFields = [...selectedFields] const finalContent = buildPrompt(rawText, currentFields) setIsSending(true) setOptimisticMessage(finalContent) setInput('') // setSelectedFields([]) try { const payload = { planId: planId as any, content: finalContent, conversacionId: activeChatId, campos: currentFields.length > 0 ? currentFields.map((f) => f.key) : undefined, } const response = await sendChat(payload) setIsSyncing(true) if (response.conversacionId && response.conversacionId !== activeChatId) { setActiveChatId(response.conversacionId) } // ESPERAMOS a que la caché se actualice antes de quitar el "isSending" await Promise.all([ queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }), queryClient.invalidateQueries({ queryKey: ['conversation-messages', response.conversacionId], }), ]) } catch (error) { console.error('Error:', error) 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 + selectedRepositorioIds.length + uploadedFiles.length ) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) const removeSelectedField = (fieldKey: string) => { setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey)) } return (
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}

Chats

{/* Botón de toggle archivados movido aquí arriba */}
{' '} {/* Agregamos un pr-2 para que el scrollbar no tape botones */} {!showArchived ? ( activeChats.map((chat) => (
setActiveChatId(chat.id)} className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${ activeChatId === chat.id ? 'bg-slate-100 font-medium text-slate-900' : 'text-slate-600 hover:bg-slate-50' }`} > {/* LADO IZQUIERDO: Icono + Texto */}
{/* pr-12 reserva espacio para los botones absolutos */}
{ e.stopPropagation() setEditingChatId(chat.id) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() e.currentTarget.blur() } if (e.key === 'Escape') { setEditingChatId(null) e.currentTarget.textContent = chat.nombre || '' } }} onBlur={(e) => { if (editingChatId === chat.id) { const newTitle = e.currentTarget.textContent?.trim() || '' if (newTitle && newTitle !== chat.nombre) { updateTitleMutation({ id: chat.id, nombre: newTitle, }) } setEditingChatId(null) } }} > {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
{editingChatId !== chat.id && ( {chat.nombre || 'Conversación'} )}
{/* LADO DERECHO: Acciones ABSOLUTAS */}
)) ) : ( /* Sección de archivados (Simplificada para mantener consistencia) */

Archivados

{archivedChats.map((chat) => (
{chat.nombre || `Archivado ${chat.creado_en.split('T')[0]}`}
))}
)}
{/* PANEL DE CHAT PRINCIPAL */}
{/* NUEVO: Barra superior de campos seleccionados */}
Mejorar con IA
{/* CONTENIDO DEL CHAT */}
{!activeChatId && chatMessages.length === 0 && !optimisticMessage ? (

No hay un chat seleccionado

Selecciona un chat del historial o crea uno nuevo para empezar.

) : ( <> {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 (
{/* Aviso de Refusal */} {msg.isRefusal && (
Aviso del Asistente
)} {/* CONTENIDO CORRECTO: Usamos msg.content */} {isAI && isProcessing ? (
) : ( msg.content // <--- CAMBIO CLAVE )} {/* Recomendaciones */} {isAI && msg.suggestions?.length > 0 && (
removeSelectedField(key) } />
)}
) })} {(isSending || isSyncing) && (
La IA está analizando tu solicitud...
)} )}
{/* Botones flotantes de aplicación */} {pendingSuggestion && !isLoading && (
)}
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
{/* MENÚ DE SUGERENCIAS FLOTANTE */} {showSuggestions && (
Resultados para "{filterQuery}"
{filteredFields.length > 0 ? ( filteredFields.map((field, index) => ( )) ) : (
No hay coincidencias
)}
)} {/* CONTENEDOR DEL INPUT TRANSFORMADO */}
{/* 1. Visualización de campos dentro del input ) */} {selectedFields.length > 0 && (
{selectedFields.map((field) => (
Campo: {field.label}
))}
)} {/* 2. Área de escritura */}