/* 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, RotateCcw, Loader2, } 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 { 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 { 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 [activeChatId, setActiveChatId] = useState( undefined, ) const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) const { data: mensajesDelChat, isLoading: isLoadingMessages } = useMessagesByChat(activeChatId) 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 [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 activeChatData = useMemo(() => { return lastConversation?.find((chat: any) => chat.id === activeChatId) }, [lastConversation, activeChatId]) 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 = () => { if (scrollRef.current) { // Buscamos el viewport interno del ScrollArea de Radix const scrollContainer = scrollRef.current.querySelector( '[data-radix-scroll-area-viewport]', ) if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }) } } } 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(() => { console.log(mensajesDelChat) scrollToBottom() }, [chatMessages, isLoading]) useEffect(() => { // Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input const camposActualizados = selectedFields.filter((field) => input.includes(field.label), ) // Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos) if (camposActualizados.length !== selectedFields.length) { setSelectedFields(camposActualizados) } }, [input, selectedFields]) 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, ]) 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]) 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] setIsSending(true) setOptimisticMessage(rawText) // 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) } // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se await Promise.all([ queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }), queryClient.invalidateQueries({ queryKey: ['conversation-messages', response.conversacionId], }), ]) } catch (error) { console.error('Error:', error) } finally { setIsSending(false) setOptimisticMessage(null) } } 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 */}
{!showArchived ? ( activeChats.map((chat) => (
setActiveChatId(chat.id)} className={`group relative flex w-full cursor-pointer items-center gap-3 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' }`} > { e.stopPropagation() setEditingChatId(chat.id) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() const newTitle = e.currentTarget.textContent || '' updateTitleMutation( { id: chat.id, nombre: newTitle }, { onSuccess: () => setEditingChatId(null), }, ) } if (e.key === 'Escape') { setEditingChatId(null) e.currentTarget.textContent = chat.nombre || '' } }} onBlur={(e) => { if (editingChatId === chat.id) { const newTitle = e.currentTarget.textContent || '' if (newTitle !== chat.nombre) { updateTitleMutation({ id: chat.id, nombre: newTitle }) } setEditingChatId(null) } }} onClick={(e) => { if (editingChatId === chat.id) e.stopPropagation() }} > {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} {/* ACCIONES */}
)) ) : ( /* ... Resto del código de archivados (sin cambios) ... */

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) => (
{/* Icono opcional de advertencia si es refusal */} {msg.isRefusal && (
Aviso del Asistente
)} {msg.content} {!msg.isRefusal && msg.suggestions && msg.suggestions.length > 0 && (
removeSelectedField(key) } />
)}
))} {optimisticMessage && (
{optimisticMessage}
)} {isSending && (
Esperando respuesta...
)} )}
{/* 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 */}