From 772f3b6750c08eb0d498f1024a493cb126035193 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Fri, 6 Mar 2026 13:33:38 -0600 Subject: [PATCH] Se prepara chat asignaturas para ia --- .../asignaturas/detalle/IAAsignaturaTab.tsx | 515 ++++++++++-------- 1 file changed, 282 insertions(+), 233 deletions(-) diff --git a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx index 24a78c5..a56cad0 100644 --- a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx +++ b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx @@ -1,4 +1,5 @@ -import { useParams, useRouterState } from '@tanstack/react-router' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from '@tanstack/react-router' import { Sparkles, Send, @@ -10,13 +11,15 @@ import { BookOpen, Check, X, + MessageSquarePlus, + Archive, + History, // Agregado } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' import type { IASugerencia } from '@/types/asignatura' import { Avatar, AvatarFallback } from '@/components/ui/avatar' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' @@ -25,39 +28,10 @@ import { useConversationBySubject, useMessagesBySubjectChat, useSubject, - useUpdateSubjectRecommendation, + useUpdateSubjectConversationStatus, } from '@/data' import { cn } from '@/lib/utils' -// Tipos importados de tu archivo de asignatura - -const PRESETS = [ - { - id: 'mejorar-objetivo', - label: 'Mejorar objetivo', - icon: Target, - prompt: 'Mejora la redacción del objetivo de esta asignatura...', - }, - { - id: 'contenido-tematico', - label: 'Sugerir contenido', - icon: BookOpen, - prompt: 'Genera un desglose de temas para esta asignatura...', - }, - { - id: 'actividades', - label: 'Actividades de aprendizaje', - icon: GraduationCap, - prompt: 'Sugiere actividades prácticas para los temas seleccionados...', - }, - { - id: 'bibliografia', - label: 'Actualizar bibliografía', - icon: FileText, - prompt: 'Recomienda bibliografía reciente para esta asignatura...', - }, -] - interface SelectedField { key: string label: string @@ -65,209 +39,298 @@ interface SelectedField { } interface IAAsignaturaTabProps { - asignatura: Record - - onSendMessage: (message: string, campoId?: string) => void + asignatura?: Record onAcceptSuggestion: (sugerencia: IASugerencia) => void onRejectSuggestion: (messageId: string) => void } export function IAAsignaturaTab({ - onSendMessage, onAcceptSuggestion, onRejectSuggestion, }: IAAsignaturaTabProps) { - const routerState = useRouterState() + const queryClient = useQueryClient() const { asignaturaId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) - const { data: datosGenerales, isLoading: loadingAsig } = - useSubject(asignaturaId) - // ESTADOS PRINCIPALES (Igual que en Planes) + // --- ESTADOS --- + const [activeChatId, setActiveChatId] = useState( + undefined, + ) + const [showArchived, setShowArchived] = useState(false) const [input, setInput] = useState('') const [selectedFields, setSelectedFields] = useState>([]) const [showSuggestions, setShowSuggestions] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const scrollRef = useRef(null) const [isSending, setIsSending] = useState(false) + const scrollRef = useRef(null) - const { data: conversaciones } = useConversationBySubject(asignaturaId) - const activeConversationId = conversaciones?.[0]?.id - - const { data: rawMessages } = useMessagesBySubjectChat(activeConversationId) + // --- DATA QUERIES --- + const { data: datosGenerales } = useSubject(asignaturaId) + const { data: todasConversaciones, isLoading: loadingConv } = + useConversationBySubject(asignaturaId) + const { data: rawMessages } = useMessagesBySubjectChat(activeChatId, { + enabled: !!activeChatId, + }) const { mutateAsync: sendMessage } = useAISubjectChat() - const { mutate: applySuggestion } = useUpdateSubjectRecommendation() + const { mutate: updateStatus } = useUpdateSubjectConversationStatus() + const [isCreatingNewChat, setIsCreatingNewChat] = useState(false) + const hasInitialSelected = useRef(false) + // --- AUTO-SCROLL --- + useEffect(() => { + const viewport = scrollRef.current?.querySelector( + '[data-radix-scroll-area-viewport]', + ) + if (viewport) { + viewport.scrollTop = viewport.scrollHeight + } + }, [rawMessages, isSending]) + + // --- FILTRADO DE CHATS --- + const { activeChats, archivedChats } = useMemo(() => { + const chats = todasConversaciones || [] + return { + activeChats: chats.filter((c: any) => c.estado === 'ACTIVA'), + archivedChats: chats.filter((c: any) => c.estado === 'ARCHIVADA'), + } + }, [todasConversaciones]) + + // --- PROCESAMIENTO DE MENSAJES --- const messages = useMemo(() => { - return rawMessages - ?.map((m) => ({ - id: m.id, - role: 'user', // El mensaje del usuario - content: m.mensaje, - sugerencia: null, - })) - .concat( - rawMessages?.map((m) => ({ + if (!rawMessages) return [] + return rawMessages.flatMap((m) => { + const msgs = [] + msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje }) + if (m.respuesta) { + msgs.push({ id: `${m.id}-ai`, role: 'assistant', content: m.respuesta, - sugerencia: - m.propuesta?.recommendations?.length > 0 - ? { - id: m.id, - campoKey: m.propuesta.recommendations[0].campo_afectado, - campoNombre: - m.propuesta.recommendations[0].campo_afectado.replace( - /_/g, - ' ', - ), - valorSugerido: m.propuesta.recommendations[0].texto_mejora, - aceptada: m.propuesta.recommendations[0].aplicada, - } - : null, - })), - ) - .sort((a, b) => (a.id > b.id ? 1 : -1)) // Unir y ordenar (simplificado) - - // NOTA: En producción, es mejor que tu query devuelva los mensajes ya intercalados - // o usar el campo 'conversacion_json' de la tabla padre para mayor fidelidad. + sugerencia: m.propuesta?.recommendations?.[0] + ? { + id: m.id, + campoKey: m.propuesta.recommendations[0].campo_afectado, + campoNombre: + m.propuesta.recommendations[0].campo_afectado.replace( + /_/g, + ' ', + ), + valorSugerido: m.propuesta.recommendations[0].texto_mejora, + aceptada: m.propuesta.recommendations[0].aplicada, + } + : null, + }) + } + return msgs + }) }, [rawMessages]) - // 1. Transformar datos de la asignatura para el menú - const availableFields = useMemo(() => { - if (!datosGenerales?.datos) return [] - - const estructuraProps = - datosGenerales?.estructuras_asignatura?.definicion?.properties || {} - - return Object.keys(datosGenerales.datos).map((key) => { - const estructuraCampo = estructuraProps[key] - - const labelAmigable = - estructuraCampo?.title || - key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) - - return { - key, - label: labelAmigable, - value: String(datosGenerales.datos[key] || ''), - } - }) - }, [datosGenerales]) - - // 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill) - + // Auto-selección inicial useEffect(() => { - const state = routerState.location.state as any + // Si ya hay un chat, o si el usuario ya interactuó (hasInitialSelected), abortamos. + if (activeChatId || hasInitialSelected.current) return - if (state?.prefillCampo && availableFields.length > 0) { - console.log(state?.prefillCampo) - console.log(availableFields) - - const field = availableFields.find((f) => f.key === state.prefillCampo) - - if (field && !selectedFields.find((sf) => sf.key === field.key)) { - setSelectedFields([field]) - // Sincronizamos el texto inicial con el campo pre-seleccionado - setInput(`Mejora el campo ${field.label}: `) - } + if (activeChats.length > 0 && !loadingConv) { + setActiveChatId(activeChats[0].id) + hasInitialSelected.current = true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [availableFields]) - - // Scroll automático - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - }, [messages, isLoading]) - - // 3. Lógica para el disparador ":" - const handleInputChange = (e: React.ChangeEvent) => { - const val = e.target.value - setInput(val) - setShowSuggestions(val.endsWith(':')) - } - - const toggleField = (field: SelectedField) => { - setSelectedFields((prev) => { - const isSelected = prev.find((f) => f.key === field.key) - - // 1. Si ya está seleccionado, lo quitamos (Toggle OFF) - if (isSelected) { - return prev.filter((f) => f.key !== field.key) - } - - // 2. Si no está, lo agregamos a la lista (Toggle ON) - const newSelected = [...prev, field] - - // 3. Actualizamos el texto del input para reflejar los títulos (labels) - setInput((prevText) => { - // Separamos lo que el usuario escribió antes del disparador ":" - // y lo que viene después (posibles keys/labels previos) - const parts = prevText.split(':') - const beforeColon = parts[0] - - // Creamos un string con los labels de todos los campos seleccionados - const labelsPath = newSelected.map((f) => f.label).join(', ') - - return `${beforeColon.trim()}: ${labelsPath} ` - }) - - return newSelected - }) - - // Opcional: mantener abierto si quieres que el usuario elija varios seguidos - // setShowSuggestions(false) - } - - const buildPrompt = (userInput: string) => { - if (selectedFields.length === 0) return userInput - const fieldsText = selectedFields - .map((f) => `- ${f.label}: ${f.value || '(vacio)'}`) - .join('\n') - - return `${userInput}\n\nCampos a analizar:\n${fieldsText}`.trim() - } + }, [activeChats, loadingConv]) const handleSend = async (promptOverride?: string) => { - const rawText = promptOverride || input - if (!rawText.trim() && selectedFields.length === 0) return + const text = promptOverride || input + if (!text.trim() && selectedFields.length === 0) return setIsSending(true) try { - await sendMessage({ - subjectId: asignaturaId as any, - content: rawText, + const response = await sendMessage({ + subjectId: asignaturaId as any, // Importante: se usa para crear la conv si activeChatId es undefined + content: text, campos: selectedFields.map((f) => f.key), - conversacionId: activeConversationId, + conversacionId: activeChatId, // Si es undefined, la mutación crea el chat automáticamente }) + + // IMPORTANTE: Después de la respuesta, actualizamos el ID activo con el que creó el backend + if (response.conversacionId) { + setActiveChatId(response.conversacionId) + } + setInput('') setSelectedFields([]) + + // Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo) + queryClient.invalidateQueries({ + queryKey: ['conversation-by-subject', asignaturaId], + }) + } catch (error) { + console.error('Error al enviar mensaje:', error) } finally { setIsSending(false) } } + const toggleField = (field: SelectedField) => { + setSelectedFields((prev) => + prev.find((f) => f.key === field.key) + ? prev.filter((f) => f.key !== field.key) + : [...prev, field], + ) + } + + const availableFields = useMemo(() => { + if (!datosGenerales?.datos) return [] + const estructuraProps = + datosGenerales?.estructuras_asignatura?.definicion?.properties || {} + return Object.keys(datosGenerales.datos).map((key) => ({ + key, + label: + estructuraProps[key]?.title || key.replace(/_/g, ' ').toUpperCase(), + value: String(datosGenerales.datos[key] || ''), + })) + }, [datosGenerales]) + + const createNewChat = () => { + setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend + setInput('') + setSelectedFields([]) + // Opcional: podrías forzar el foco al textarea aquí con una ref + } + + const PRESETS = [ + { + id: 'mejorar-obj', + label: 'Mejorar objetivo', + icon: Target, + prompt: 'Mejora la redacción del objetivo...', + }, + { + id: 'sugerir-cont', + label: 'Sugerir contenido', + icon: BookOpen, + prompt: 'Genera un desglose de temas...', + }, + { + id: 'actividades', + label: 'Actividades', + icon: GraduationCap, + prompt: 'Sugiere actividades prácticas...', + }, + ] + return ( -
- {/* PANEL DE CHAT PRINCIPAL */} -
- {/* Barra superior */} -
-
- - IA de Asignatura - -
+
+ {/* PANEL IZQUIERDO */} +
+
+

+ Historial +

+ +
+ + + + +
+ {(showArchived ? archivedChats : activeChats).map((chat: any) => ( +
{ + setActiveChatId(chat.id) + setIsCreatingNewChat(false) // <--- Volvemos al modo normal + }} + className={cn( + 'group relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all', + activeChatId === chat.id + ? 'bg-teal-50 font-medium text-teal-900' + : 'text-slate-600 hover:bg-slate-100', + )} + > + + + {chat.titulo || 'Conversación'} + + +
+ ))} +
+
+
+ + {/* PANEL CENTRAL */} +
+
+ + Asistente IA +
- {/* CONTENIDO DEL CHAT */}
- {messages?.map((msg) => ( + {messages.length === 0 && !isSending && ( +
+
+ +
+
+

+ Nueva Consultoría IA +

+

+ Selecciona campos con ":" o usa una acción rápida para + comenzar. +

+
+
+ )} + {messages.map((msg) => (
- {/* Renderizado de Sugerencias (Homologado con lógica de Asignatura) */} {msg.sugerencia && !msg.sugerencia.aceptada && (

- Propuesta para: {msg.sugerencia.campoNombre} + Propuesta: {msg.sugerencia.campoNombre}

{msg.sugerencia.valorSugerido} @@ -310,10 +372,8 @@ export function IAAsignaturaTab({
@@ -321,7 +381,7 @@ export function IAAsignaturaTab({ size="sm" variant="outline" onClick={() => onRejectSuggestion(msg.id)} - className="h-8 text-xs" + className="h-8" > Descartar @@ -329,44 +389,36 @@ export function IAAsignaturaTab({
)} - {msg.sugerencia?.aceptada && ( - - Sugerencia aplicada - - )}
))} - {isLoading && ( -
-
-
-
+ {isSending && ( +
+
+
+
)}
- {/* INPUT FIJO AL FONDO */} + {/* INPUT */}
- {/* MENÚ DE SUGERENCIAS FLOTANTE */} {showSuggestions && (
-
- Seleccionar campo de asignatura +
+ Campos de Asignatura
{availableFields.map((field) => (
)} - {/* CONTENEDOR DEL INPUT */}
- {/* Visualización de Tags */} {selectedFields.length > 0 && (
{selectedFields.map((field) => ( @@ -386,10 +436,10 @@ export function IAAsignaturaTab({ key={field.key} className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800" > - Campo: {field.label} + {field.label} @@ -401,27 +451,28 @@ export function IAAsignaturaTab({