/* 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, useUpdatePlanFields, useUpdateRecommendationApplied, } 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) 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 [isHistoryOpen, setIsHistoryOpen] = useState(false) const [isActionsOpen, setIsActionsOpen] = useState(false) const [selectedImprovements, setSelectedImprovements] = useState< Array >([]) const updatePlan = useUpdatePlanFields() const updateAppliedStatus = useUpdateRecommendationApplied() const availableFields = useMemo(() => { const definicion = data?.estructuras_plan ?.definicion as EstructuraDefinicion 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), ) }, [availableFields, filterQuery, selectedFields]) const chatMessages = useMemo(() => { if (!activeChatId || !mensajesDelChat) return [] return mensajesDelChat.flatMap((msg: any) => { const messages = [] messages.push({ id: `${msg.id}-user`, role: 'user', content: msg.mensaje, selectedFields: msg.campos || [], }) if (msg.respuesta) { 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 handleApplyMultiple = async ( sugerencias: Array, dbMessageId: string, ) => { if (!planId || !data?.datos || sugerencias.length === 0) return setIsSending(true) try { const datosActualizados = { ...data.datos } for (const sug of sugerencias) { const key = sug.key const newValue = sug.newValue const currentValue = datosActualizados[key] if ( typeof currentValue === 'object' && currentValue !== null && 'description' in currentValue ) { datosActualizados[key] = { ...currentValue, description: newValue } } else { datosActualizados[key] = newValue } } await updatePlan.mutateAsync({ planId: planId as any, patch: { datos: datosActualizados }, }) for (const sug of sugerencias) { try { await updateAppliedStatus.mutateAsync({ conversacionId: dbMessageId, campoAfectado: sug.key, }) removeSelectedField(sug.key) } catch (err) { console.error( `Error al marcar aplicada la sugerencia: ${sug.key}`, err, ) } } setSelectedImprovements([]) await Promise.all([ queryClient.invalidateQueries({ queryKey: ['plan', planId] }), queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }), ]) } catch (error) { console.error('Error crítico en aplicación masiva:', error) } finally { setIsSending(false) } } const toggleImprovementSelection = (sugKey: string) => { setSelectedImprovements((prev) => prev.includes(sugKey) ? prev.filter((key) => key !== sugKey) : [...prev, sugKey], ) } const toggleAllFromMessage = (suggestions: Array) => { const pending = suggestions.filter((s) => !s.applied) const allKeys = pending.map((s) => s.key) const allSelected = allKeys.every((key) => selectedImprovements.includes(key), ) if (allSelected) { setSelectedImprovements((prev) => prev.filter((key) => !allKeys.includes(key)), ) } else { setSelectedImprovements((prev) => Array.from(new Set([...prev, ...allKeys])), ) } } const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { if (scrollRef.current) { const scrollContainer = scrollRef.current.querySelector( '[data-radix-scroll-area-viewport]', ) if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior, }) } } } 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) { scrollToBottom('instant') isInitialLoad.current = false } else { scrollToBottom('smooth') } } }, [chatMessages]) 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' if (activeChatId && !currentChatExists && !isCreationMode) { setActiveChatId(undefined) setMessages([]) return } 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) setMessages([ { id: 'welcome', role: 'assistant', content: 'Iniciando una nueva conversación. ¿En qué puedo ayudarte?', }, ]) setInput('') } 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 setInput(val) const textBeforeCursor = val.slice(0, cursorPosition) const match = textBeforeCursor.match(/:(\w*)$/) if (match) { setShowSuggestions(true) setFilterQuery(match[1]) } else { setShowSuggestions(false) setFilterQuery('') } } const injectFieldsIntoInput = ( input: string, fields: Array, ) => { const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim() if (fields.length === 0) return cleaned const fieldLabels = fields.map((f) => f.label).join(', ') return `${cleaned}: ${fieldLabels}` } const toggleField = (field: SelectedField) => { setSelectedFields((prev) => { const isSelected = prev.find((f) => f.key === field.key) return isSelected ? prev : [...prev, field] }) setInput((prev) => { const nuevoTexto = prev.replace(/:(\w*)$/, field.label) return nuevoTexto + ' ' }) 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('') 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) } 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 { setIsSending(false) } } useEffect(() => { if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any 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 (
{/* --- HEADER MÓVIL --- */}
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}

Chats

{!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-accent text-foreground font-medium' : 'text-muted-foreground hover:bg-accent/50' }`} >
{ 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'} )}
)) ) : (

Archivados

{archivedChats.map((chat) => (
{chat.nombre || `Archivado ${chat.creado_en.split('T')[0]}`}
))}
)}
{/* --- PANEL DE CHAT PRINCIPAL --- */}
Mejorar con IA
{!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' const isProcessing = msg.isProcessing return (
{msg.isRefusal && (
Aviso del Asistente
)} {isAI && isProcessing ? (
) : ( msg.content )} {isAI && msg.suggestions?.length > 0 && (
Sugerencias de mejora {msg.suggestions.some( (s: any) => !s.applied, ) && ( )}
{msg.suggestions.map((sug: any) => (
{!sug.applied && ( toggleImprovementSelection(sug.key) } /> )}
removeSelectedField(key) } />
))} {msg.suggestions.some((s: any) => selectedImprovements.includes(s.key), ) && ( )}
)}
) })} {(isSending || isSyncing) && (
La IA está analizando tu solicitud...
)} )}
{pendingSuggestion && !isLoading && (
)}
{/* INPUT FIJO AL FONDO */}
{showSuggestions && (
Resultados para "{filterQuery}"
{filteredFields.length > 0 ? ( filteredFields.map((field, index) => ( )) ) : (
No hay coincidencias
)}
)}
{selectedFields.length > 0 && (
{selectedFields.map((field) => (
Campo: {field.label}
))}
)}