diff --git a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx index bdb7bd9..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,48 +11,27 @@ import { BookOpen, Check, X, + MessageSquarePlus, + Archive, + History, // Agregado } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' -import type { IAMessage, IASugerencia } from '@/types/asignatura' +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' -import { useSubject } from '@/data' +import { + useAISubjectChat, + useConversationBySubject, + useMessagesBySubjectChat, + useSubject, + 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 @@ -59,165 +39,298 @@ interface SelectedField { } interface IAAsignaturaTabProps { - asignatura: Record - messages: Array - onSendMessage: (message: string, campoId?: string) => void + asignatura?: Record onAcceptSuggestion: (sugerencia: IASugerencia) => void onRejectSuggestion: (messageId: string) => void } export function IAAsignaturaTab({ - messages, - 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 [isSending, setIsSending] = useState(false) const scrollRef = useRef(null) - // 1. Transformar datos de la asignatura para el menú - const availableFields = useMemo(() => { - if (!datosGenerales?.datos) return [] + // --- 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: updateStatus } = useUpdateSubjectConversationStatus() + const [isCreatingNewChat, setIsCreatingNewChat] = useState(false) + const hasInitialSelected = useRef(false) - const estructuraProps = - datosGenerales?.estructuras_asignatura?.definicion?.properties || {} + // --- AUTO-SCROLL --- + useEffect(() => { + const viewport = scrollRef.current?.querySelector( + '[data-radix-scroll-area-viewport]', + ) + if (viewport) { + viewport.scrollTop = viewport.scrollHeight + } + }, [rawMessages, isSending]) - return Object.keys(datosGenerales.datos).map((key) => { - const estructuraCampo = estructuraProps[key] + // --- 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]) - const labelAmigable = - estructuraCampo?.title || - key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) - - return { - key, - label: labelAmigable, - value: String(datosGenerales.datos[key] || ''), + // --- PROCESAMIENTO DE MENSAJES --- + const messages = useMemo(() => { + 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?.[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 }) - }, [datosGenerales]) - - // 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill) + }, [rawMessages]) + // 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) + if (activeChats.length > 0 && !loadingConv) { + setActiveChatId(activeChats[0].id) + hasInitialSelected.current = true + } + }, [activeChats, loadingConv]) - const field = availableFields.find((f) => f.key === state.prefillCampo) + const handleSend = async (promptOverride?: string) => { + const text = promptOverride || input + if (!text.trim() && selectedFields.length === 0) return - 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}: `) + setIsSending(true) + try { + 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: 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) } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [availableFields]) - // Scroll automático - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - }, [messages, isLoading]) + setInput('') + setSelectedFields([]) - // 3. Lógica para el disparador ":" - const handleInputChange = (e: React.ChangeEvent) => { - const val = e.target.value - setInput(val) - setShowSuggestions(val.endsWith(':')) + // 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) => { - 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) + setSelectedFields((prev) => + prev.find((f) => f.key === field.key) + ? prev.filter((f) => f.key !== field.key) + : [...prev, field], + ) } - 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() - } - - const handleSend = async (promptOverride?: string) => { - const rawText = promptOverride || input - if (!rawText.trim() && selectedFields.length === 0) return - - const finalPrompt = buildPrompt(rawText) - - setIsLoading(true) - // Llamamos a la función que viene por props - onSendMessage(finalPrompt, selectedFields[0]?.key) + 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([]) - - // Simular carga local para el feedback visual - setTimeout(() => setIsLoading(false), 1200) + // 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} @@ -260,10 +372,8 @@ export function IAAsignaturaTab({
@@ -271,7 +381,7 @@ export function IAAsignaturaTab({ size="sm" variant="outline" onClick={() => onRejectSuggestion(msg.id)} - className="h-8 text-xs" + className="h-8" > Descartar @@ -279,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) => ( @@ -336,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} @@ -351,27 +451,28 @@ export function IAAsignaturaTab({