diff --git a/src/components/asignaturas/detalle/BibliographyItem.tsx b/src/components/asignaturas/detalle/BibliographyItem.tsx index acec679..2067498 100644 --- a/src/components/asignaturas/detalle/BibliographyItem.tsx +++ b/src/components/asignaturas/detalle/BibliographyItem.tsx @@ -1,6 +1,9 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ import { useParams } from '@tanstack/react-router' import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { AlertDialog, @@ -31,7 +34,12 @@ import { SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { useSubjectBibliografia } from '@/data/hooks/useSubjects' +import { + useCreateBibliografia, + useDeleteBibliografia, + useSubjectBibliografia, + useUpdateBibliografia, +} from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' // --- Interfaces --- @@ -50,9 +58,16 @@ export function BibliographyItem() { from: '/planes/$planId/asignaturas/$asignaturaId', }) - const { data: bibliografia2, isLoading: loadinasignatura } = + // --- 1. Única fuente de verdad: La Query --- + const { data: bibliografia = [], isLoading } = useSubjectBibliografia(asignaturaId) - const [entries, setEntries] = useState>([]) + + // --- 2. Mutaciones --- + const { mutate: crearBibliografia } = useCreateBibliografia() + const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId) + const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId) + + // --- 3. Estados de UI (Solo para diálogos y edición) --- const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [deleteId, setDeleteId] = useState(null) @@ -61,29 +76,27 @@ export function BibliographyItem() { 'BASICA', ) - useEffect(() => { - console.log(entries) - - if (bibliografia2 && Array.isArray(bibliografia2)) { - setEntries(bibliografia2) - } - }, [bibliografia2]) - - const basicaEntries = entries.filter((e) => e.tipo === 'BASICA') - const complementariaEntries = entries.filter( + console.log('Datos actuales en el front:', bibliografia) + // --- 4. Derivación de datos (Se calculan en cada render) --- + const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA') + const complementariaEntries = bibliografia.filter( (e) => e.tipo === 'COMPLEMENTARIA', ) - console.log(bibliografia2) + + // --- Handlers Conectados a la Base de Datos --- const handleAddManual = (cita: string) => { - const newEntry: BibliografiaEntry = { - id: `manual-${Date.now()}`, - tipo: newEntryType, - cita, - } - setEntries([...entries, newEntry]) - setIsAddDialogOpen(false) - // toast.success('Referencia manual añadida'); + crearBibliografia( + { + asignatura_id: asignaturaId, + tipo: newEntryType, + cita, + tipo_fuente: 'MANUAL', + }, + { + onSuccess: () => setIsAddDialogOpen(false), + }, + ) } const handleAddFromLibrary = ( @@ -91,22 +104,43 @@ export function BibliographyItem() { tipo: 'BASICA' | 'COMPLEMENTARIA', ) => { const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.` - const newEntry: BibliografiaEntry = { - id: `lib-ref-${Date.now()}`, - tipo, - cita, - fuenteBibliotecaId: resource.id, - fuenteBiblioteca: resource, - } - setEntries([...entries, newEntry]) - setIsLibraryDialogOpen(false) - // toast.success('Añadido desde biblioteca'); + crearBibliografia( + { + asignatura_id: asignaturaId, + tipo, + cita, + tipo_fuente: 'BIBLIOTECA', + biblioteca_item_id: resource.id, + }, + { + onSuccess: () => setIsLibraryDialogOpen(false), + }, + ) } - const handleUpdateCita = (id: string, cita: string) => { - setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e))) + const handleUpdateCita = (id: string, nuevaCita: string) => { + actualizarBibliografia( + { + id, + updates: { cita: nuevaCita }, + }, + { + onSuccess: () => setEditingId(null), + }, + ) } + const onConfirmDelete = () => { + if (deleteId) { + eliminarBibliografia(deleteId, { + onSuccess: () => setDeleteId(null), + }) + } + } + + if (isLoading) + return
Cargando bibliografía...
+ return (
@@ -134,9 +168,13 @@ export function BibliographyItem() { e.fuenteBibliotecaId || '')} + // CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries' + existingIds={bibliografia.map( + (e) => e.biblioteca_item_id || '', + )} /> @@ -216,13 +254,7 @@ export function BibliographyItem() { Cancelar - { - setEntries(entries.filter((e) => e.id !== deleteId)) - setDeleteId(null) - }} - className="bg-red-600" - > + Eliminar @@ -412,7 +444,7 @@ function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
- {filtered.map((res) => ( + {filtered.map((res: any) => (
onSelect(res, tipo)} diff --git a/src/components/planes/detalle/Ia/ImprovementCard.tsx b/src/components/planes/detalle/Ia/ImprovementCard.tsx index 67114b2..64bda36 100644 --- a/src/components/planes/detalle/Ia/ImprovementCard.tsx +++ b/src/components/planes/detalle/Ia/ImprovementCard.tsx @@ -2,26 +2,29 @@ import { Check, Loader2 } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' -import { useUpdatePlanFields } from '@/data' // Tu hook existente +import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data' export const ImprovementCard = ({ suggestions, onApply, - planId, // Necesitamos el ID - currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON + planId, + currentDatos, + activeChatId, + onApplySuccess, }: { suggestions: Array onApply?: (key: string, value: string) => void planId: string currentDatos: any + activeChatId: any + onApplySuccess?: (key: string) => void }) => { - const [appliedFields, setAppliedFields] = useState>([]) + const [localApplied, setLocalApplied] = useState>([]) const updatePlan = useUpdatePlanFields() + const updateAppliedStatus = useUpdateRecommendationApplied() const handleApply = (key: string, newValue: string) => { if (!currentDatos) return - - // 1. Lógica para preparar el valor (idéntica a tu handleSave original) const currentValue = currentDatos[key] let finalValue: any @@ -35,13 +38,11 @@ export const ImprovementCard = ({ finalValue = newValue } - // 2. Construir el nuevo objeto 'datos' manteniendo lo que ya existía const datosActualizados = { ...currentDatos, [key]: finalValue, } - // 3. Ejecutar la mutación directamente aquí updatePlan.mutate( { planId: planId as any, @@ -49,9 +50,17 @@ export const ImprovementCard = ({ }, { onSuccess: () => { - setAppliedFields((prev) => [...prev, key]) + setLocalApplied((prev) => [...prev, key]) + + if (onApplySuccess) onApplySuccess(key) + if (activeChatId) { + updateAppliedStatus.mutate({ + conversacionId: activeChatId, + campoAfectado: key, + }) + } + if (onApply) onApply(key, newValue) - console.log(`Campo ${key} guardado exitosamente`) }, }, ) @@ -60,7 +69,7 @@ export const ImprovementCard = ({ return (
{suggestions.map((sug) => { - const isApplied = appliedFields.includes(sug.key) + const isApplied = sug.applied === true || localApplied.includes(sug.key) const isUpdating = updatePlan.isPending && updatePlan.variables.patch.datos?.[sug.key] !== undefined diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index ca0c080..09602d6 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -111,12 +111,6 @@ export async function create_conversation(planId: string) { ) if (error) throw error - - // LOG de depuración: Mira qué estructura trae 'data' - console.log('Respuesta creación conv:', data) - - // Si data es { id: "..." }, devolvemos data. - // Si data viene envuelto, asegúrate de retornar el objeto con el id. return data } @@ -181,3 +175,64 @@ export async function getConversationByPlan(planId: string) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return data ?? [] } + +export async function update_conversation_title( + conversacionId: string, + nuevoTitulo: string, +) { + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('conversaciones_plan') + .update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre' + .eq('id', conversacionId) + .select() + .single() + + if (error) throw error + return data +} + +export async function update_recommendation_applied_status( + conversacionId: string, + campoAfectado: string, +) { + const supabase = supabaseBrowser() + + // 1. Obtener el estado actual del JSON + const { data: conv, error: fetchError } = await supabase + .from('conversaciones_plan') + .select('conversacion_json') + .eq('id', conversacionId) + .single() + + if (fetchError) throw fetchError + if (!conv.conversacion_json) throw new Error('No se encontró la conversación') + + // 2. Transformar el JSON para marcar como aplicada la recomendación específica + // Usamos una transformación inmutable para evitar efectos secundarios + const nuevoJson = (conv.conversacion_json as Array).map((msg) => { + if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) { + return { + ...msg, + recommendations: msg.recommendations.map((rec: any) => + rec.campo_afectado === campoAfectado + ? { ...rec, aplicada: true } + : rec, + ), + } + } + return msg + }) + + // 3. Actualizar la base de datos con el nuevo JSON + const { data, error: updateError } = await supabase + .from('conversaciones_plan') + .update({ conversacion_json: nuevoJson }) + .eq('id', conversacionId) + .select() + .single() + + if (updateError) throw updateError + return data +} diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 0659c0f..9cbc0cc 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -462,3 +462,51 @@ export async function lineas_delete(lineaId: string) { if (error) throw error return lineaId } + +export async function bibliografia_insert(entry: { + asignatura_id: string + tipo: 'BASICA' | 'COMPLEMENTARIA' + cita: string + tipo_fuente: 'MANUAL' | 'BIBLIOTECA' + biblioteca_item_id?: string | null +}) { + const supabase = supabaseBrowser() + const { data, error } = await supabase + .from('bibliografia_asignatura') + .insert([entry]) + .select() + .single() + + if (error) throw error + return data +} + +export async function bibliografia_update( + id: string, + updates: { + cita?: string + tipo?: 'BASICA' | 'COMPLEMENTARIA' + }, +) { + const supabase = supabaseBrowser() + const { data, error } = await supabase + .from('bibliografia_asignatura') + .update(updates) // Ahora 'updates' es compatible con lo que espera Supabase + .eq('id', id) + .select() + .single() + + if (error) throw error + return data +} + +export async function bibliografia_delete(id: string) { + const supabase = supabaseBrowser() + const { error } = await supabase + .from('bibliografia_asignatura') + .delete() + .eq('id', id) + + if (error) throw error + return id +} diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index 5338a52..b3b5dbb 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -10,6 +10,8 @@ import { getConversationByPlan, library_search, update_conversation_status, + update_recommendation_applied_status, + update_conversation_title, } from '../api/ai.api' // eslint-disable-next-line node/prefer-node-protocol @@ -35,8 +37,6 @@ export function useAIPlanChat() { // CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola currentId = response.conversation_plan.id - - console.log('Nuevo ID extraído:', currentId) } // 2. Ahora enviamos el mensaje con el ID garantizado @@ -56,11 +56,8 @@ export function useChatHistory(conversacionId?: string) { return useQuery({ queryKey: ['chat-history', conversacionId], queryFn: async () => { - console.log('--- EJECUTANDO QUERY FN ---') - console.log('ID RECIBIDO:', conversacionId) return get_chat_history(conversacionId!) }, - // Simplificamos el enabled para probar enabled: Boolean(conversacionId), }) } @@ -91,6 +88,31 @@ export function useConversationByPlan(planId: string | null) { }) } +export function useUpdateRecommendationApplied() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ + conversacionId, + campoAfectado, + }: { + conversacionId: string + campoAfectado: string + }) => update_recommendation_applied_status(conversacionId, campoAfectado), + + onSuccess: (_, variables) => { + // Invalidamos la query para que useConversationByPlan refresque el JSON + qc.invalidateQueries({ queryKey: ['conversation-by-plan'] }) + console.log( + `Recomendación ${variables.campoAfectado} marcada como aplicada.`, + ) + }, + onError: (error) => { + console.error('Error al actualizar el estado de la recomendación:', error) + }, + }) +} + export function useAISubjectImprove() { return useMutation({ mutationFn: ai_subject_improve }) } @@ -102,3 +124,16 @@ export function useAISubjectChat() { export function useLibrarySearch() { return useMutation({ mutationFn: library_search }) } + +export function useUpdateConversationTitle() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, nombre }: { id: string; nombre: string }) => + update_conversation_title(id, nombre), + onSuccess: (_, variables) => { + // Invalidamos para que la lista de chats se refresque + qc.invalidateQueries({ queryKey: ['conversation-by-plan'] }) + }, + }) +} diff --git a/src/data/hooks/useSubjects.ts b/src/data/hooks/useSubjects.ts index 9975763..29dba1e 100644 --- a/src/data/hooks/useSubjects.ts +++ b/src/data/hooks/useSubjects.ts @@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ai_generate_subject, asignaturas_update, + bibliografia_delete, + bibliografia_insert, + bibliografia_update, lineas_insert, lineas_update, subjects_bibliografia_list, @@ -276,3 +279,41 @@ export function useUpdateLinea() { }, }) } + +export function useCreateBibliografia() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: bibliografia_insert, + onSuccess: (data) => { + // USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA + queryClient.invalidateQueries({ + queryKey: qk.asignaturaBibliografia(data.asignatura_id), + }) + }, + }) +} + +export function useUpdateBibliografia(asignaturaId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, updates }: { id: string; updates: any }) => + bibliografia_update(id, updates), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: qk.asignaturaBibliografia(asignaturaId), + }) + }, + }) +} + +export function useDeleteBibliografia(asignaturaId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => bibliografia_delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: qk.asignaturaBibliografia(asignaturaId), + }) + }, + }) +} diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index a8d6049..283d560 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -14,6 +14,7 @@ import { MessageSquarePlus, Archive, RotateCcw, + Loader2, } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' @@ -27,9 +28,9 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { useAIPlanChat, - useChatHistory, useConversationByPlan, useUpdateConversationStatus, + useUpdateConversationTitle, } from '@/data' import { usePlan } from '@/data/hooks/usePlans' @@ -66,7 +67,25 @@ interface SelectedField { 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, }) @@ -76,19 +95,14 @@ function RouteComponent() { const { data } = usePlan(planId) const routerState = useRouterState() const [openIA, setOpenIA] = useState(false) - const [conversacionId, setConversacionId] = useState(null) - const { mutateAsync: sendChat, isLoading } = useAIPlanChat() + const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat() const { mutate: updateStatusMutation } = useUpdateConversationStatus() const [activeChatId, setActiveChatId] = useState( undefined, ) - - const { data: historyMessages, isLoading: isLoadingHistory } = - useChatHistory(activeChatId) const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) - // archivos const [selectedArchivoIds, setSelectedArchivoIds] = useState>( [], ) @@ -104,76 +118,167 @@ function RouteComponent() { 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 - useEffect(() => { - // 1. Si no hay ID o está cargando el historial, no hacemos nada - if (!activeChatId || isLoadingHistory) return + // Encadenamiento opcional para evitar errores si data es null + if (!definicion.properties) return [] - const messagesFromApi = historyMessages?.items || historyMessages + return Object.entries(definicion.properties).map(([key, value]) => ({ + key, + label: value.title, + value: String(value.description || ''), + })) + }, [data]) - if (Array.isArray(messagesFromApi)) { - const flattened = messagesFromApi.map((msg) => { - let content = msg.content - let suggestions: Array = [] + 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]) - if (typeof content === 'object' && content !== null) { - suggestions = Object.entries(content) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) + const activeChatData = useMemo(() => { + return lastConversation?.find((chat: any) => chat.id === activeChatId) + }, [lastConversation, activeChatId]) - content = content['ai-message'] || JSON.stringify(content) - } - // Si el content es un string que parece JSON (caso común en respuestas RAW) - else if (typeof content === 'string' && content.startsWith('{')) { - try { - const parsed = JSON.parse(content) - suggestions = Object.entries(parsed) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) - content = parsed['ai-message'] || content - } catch (e) { - /* no es json */ - } - } + const chatMessages = useMemo(() => { + // 1. Si no hay ID o no hay data del chat, retornamos vacío + if (!activeChatId || !activeChatData) return [] + const json = (activeChatData.conversacion_json || + []) as unknown as Array + + // 2. Verificamos que 'json' sea realmente un array antes de mapear + if (!Array.isArray(json)) return [] + + return json.map((msg, index: number) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!msg?.user) { return { - ...msg, - content, - suggestions, - type: suggestions.length > 0 ? 'improvement-card' : 'text', + id: `err-${index}`, + role: 'assistant', + content: '', + suggestions: [], } - }) + } - // Solo actualizamos si no estamos esperando la respuesta de un POST - // para evitar saltos visuales - if (!isLoading) { - setMessages(flattened.reverse()) + const isAssistant = msg.user === 'assistant' + + return { + id: `${activeChatId}-${index}`, + role: isAssistant ? 'assistant' : 'user', + content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío + isRefusal: isAssistant && msg.refusal === true, + suggestions: + isAssistant && msg.recommendations + ? msg.recommendations.map((rec) => { + 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, + } + }) + : [], + } + }) + }, [activeChatData, 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', + }) } } - }, [historyMessages, activeChatId, isLoadingHistory, isLoading]) + } + 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(() => { - // Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes - const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' - if ( - !activeChatId && - lastConversation && - lastConversation.length > 0 && - !isCreationMode - ) { - setActiveChatId(lastConversation[0].id) + 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) } - }, [lastConversation, activeChatId]) + }, [input, selectedFields]) + + useEffect(() => { + if (isLoadingConv || !lastConversation) return + + const isChatStillActive = activeChats.some( + (chat) => chat.id === activeChatId, + ) + const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' + + // Caso A: El chat actual ya no es válido (fue archivado o borrado) + if (activeChatId && !isChatStillActive && !isCreationMode) { + setActiveChatId(undefined) + setMessages([]) + return // Salimos para evitar ejecuciones extra en este render + } + + // Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar) + if (!activeChatId && activeChats.length > 0 && !isCreationMode) { + setActiveChatId(activeChats[0].id) + } + + // Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso + if (activeChats.length === 0 && activeChatId && !isCreationMode) { + setActiveChatId(undefined) + } + }, [activeChats, activeChatId, isLoadingConv, messages.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 @@ -202,6 +307,9 @@ function RouteComponent() { if (activeChatId === id) { setActiveChatId(undefined) setMessages([]) + setOptimisticMessage(null) + setInput('') + setSelectedFields([]) } }, }, @@ -214,8 +322,6 @@ function RouteComponent() { { id, estado: 'ACTIVA' }, { onSuccess: () => { - // Al invalidar la query, React Query traerá la lista fresca - // y el chat se moverá solo de "archivados" a "activos" queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }) @@ -224,49 +330,28 @@ function RouteComponent() { ) } - // 1. Transformar datos de la API para el menú de selección - const availableFields = useMemo(() => { - if (!data?.estructuras_plan?.definicion?.properties) return [] - return Object.entries(data.estructuras_plan.definicion.properties).map( - ([key, value]) => ({ - key, - label: value.title, - value: String(value.description || ''), - }), - ) - }, [data]) - - // 2. Manejar el estado inicial si viene de "Datos Generales" - 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]) - - // 3. Lógica para el disparador ":" const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value + const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario setInput(val) - // Solo abrir si termina en ":" - setShowSuggestions(val.endsWith(':')) + + // 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, ) => { - // Quita cualquier bloque previo de campos const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() if (fields.length === 0) return cleaned @@ -277,60 +362,42 @@ function RouteComponent() { } const toggleField = (field: SelectedField) => { - let isAdding = false - + // 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) - if (isSelected) { - return prev.filter((f) => f.key !== field.key) - } else { - isAdding = true - return [...prev, field] - } + return isSelected ? prev : [...prev, field] }) + // 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":" setInput((prev) => { - // 1. Eliminamos TODOS los ":" que existan en el texto actual - // 2. Quitamos espacios en blanco extra al final - const cleanPrev = prev.replace(/:/g, '').trim() - - // 3. Si el input resultante está vacío, solo ponemos la frase - if (cleanPrev === '') { - return `${field.label} ` - } - - // 4. Si ya había algo, lo concatenamos con un espacio - // Usamos un espacio simple al final para que el usuario pueda seguir escribiendo - return `${cleanPrev} ${field.label} ` + // 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) => { - // Si no hay campos, enviamos el texto tal cual if (fields.length === 0) return userInput - return `Instrucción del usuario: ${userInput}` + return ` ${userInput}` } const handleSend = async (promptOverride?: string) => { const rawText = promptOverride || input if (!rawText.trim() && selectedFields.length === 0) return - + if (isSending || (!rawText.trim() && selectedFields.length === 0)) return const currentFields = [...selectedFields] const finalPrompt = buildPrompt(rawText, currentFields) - - const userMsg = { - id: Date.now().toString(), - role: 'user', - content: rawText, - } - - setMessages((prev) => [...prev, userMsg]) + setIsSending(true) + setOptimisticMessage(rawText) setInput('') - // setSelectedFields([]) - + setSelectedArchivoIds([]) + setSelectedRepositorioIds([]) + setUploadedFiles([]) try { const payload: any = { planId: planId, @@ -346,58 +413,19 @@ function RouteComponent() { if (response.conversacionId && response.conversacionId !== activeChatId) { setActiveChatId(response.conversacionId) - - // Esto obliga a 'useConversationByPlan' a buscar en la DB el nuevo chat creado - queryClient.invalidateQueries({ - queryKey: ['conversation-by-plan', planId], - }) } - // --- NUEVA LÓGICA DE PARSEO --- - let aiText = 'Sin respuesta del asistente' - let suggestions: Array = [] - - if (response.raw) { - try { - const rawData = JSON.parse(response.raw) - - // Extraemos el mensaje conversacional - aiText = rawData['ai-message'] || 'Cambios aplicados con éxito.' - - // Filtramos todo lo que no sea el mensaje para crear las sugerencias - suggestions = Object.entries(rawData) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) - } catch (e) { - console.error('Error parseando el campo raw:', e) - aiText = response.raw // Fallback si no es JSON - } - } - - setMessages((prev) => [ - ...prev, - { - id: Date.now().toString(), - role: 'assistant', - content: aiText, - type: suggestions.length > 0 ? 'improvement-card' : 'text', - suggestions: suggestions, - }, - ]) + await queryClient.invalidateQueries({ + queryKey: ['conversation-by-plan', planId], + }) + setOptimisticMessage(null) } catch (error) { console.error('Error en el chat:', error) - setMessages((prev) => [ - ...prev, - { - id: 'error', - role: 'assistant', - content: 'Lo siento, hubo un error al procesar tu solicitud.', - }, - ]) + // Aquí sí podrías usar un toast o un mensaje de error temporal + } finally { + // 5. CRÍTICO: Detener el estado de carga SIEMPRE + setIsSending(false) + setOptimisticMessage(null) } } @@ -409,15 +437,9 @@ function RouteComponent() { ) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) - 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]) + const removeSelectedField = (fieldKey: string) => { + setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey)) + } return (
@@ -454,7 +476,6 @@ function RouteComponent() {
{!showArchived ? ( - // --- LISTA DE CHATS ACTIVOS --- activeChats.map((chat) => (
- {/* Usamos el primer mensaje o un título por defecto */} - - {chat.title || `Chat ${chat.creado_en.split('T')[0]}`} - - + {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} + + + {/* ACCIONES */} +
+ + +
)) ) : ( - // --- LISTA DE CHATS ARCHIVADOS --- + /* ... Resto del código de archivados (sin cambios) ... */

Archivados @@ -492,25 +569,17 @@ function RouteComponent() { > - {chat.title || + {chat.nombre || `Archivado ${chat.creado_en.split('T')[0]}`}

))} - {archivedChats.length === 0 && ( -
-

- No hay archivados -

-
- )}
)}
@@ -543,46 +612,98 @@ function RouteComponent() {
- {messages.map((msg) => ( -
-
- {/* Contenido de texto normal */} - {msg.content} + {!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 +
+ )} - {/* Si el mensaje tiene sugerencias (ImprovementCard) */} - {msg.suggestions && msg.suggestions.length > 0 && ( -
- { - // Esto es opcional, si quieres hacer algo más en la UI del chat - console.log( - 'Evento onApply disparado desde el chat', - ) - }} - /> + {msg.content} + + {!msg.isRefusal && + msg.suggestions && + msg.suggestions.length > 0 && ( +
+ + removeSelectedField(key) + } + /> +
+ )}
- )} -
-
- ))} - {isLoading && ( -
-
-
-
-
+
+ ))} + + {optimisticMessage && ( +
+
+ {optimisticMessage} +
+
+ )} + + {isSending && ( +
+
+
+
+ + + +
+ + Esperando respuesta... + +
+
+
+ )} + )}
@@ -613,25 +734,35 @@ function RouteComponent() {
{/* MENÚ DE SUGERENCIAS FLOTANTE */} {showSuggestions && ( -
-
- Seleccionar campo para IA +
+
+ Resultados para "{filterQuery}"
- {availableFields.map((field) => ( - - ))} + {filteredFields.length > 0 ? ( + filteredFields.map((field, index) => ( + + )) + ) : ( +
+ No hay coincidencias +
+ )}
)} @@ -663,6 +794,35 @@ function RouteComponent() {