Merge branch 'main' into issue/142-creacin-de-planes-de-estudio-y-de-asignaturas-con-

This commit is contained in:
2026-02-27 12:26:16 -06:00
7 changed files with 718 additions and 334 deletions

View File

@@ -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 { useParams } from '@tanstack/react-router'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react' import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -31,7 +34,12 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' 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' import { cn } from '@/lib/utils'
// --- Interfaces --- // --- Interfaces ---
@@ -50,9 +58,16 @@ export function BibliographyItem() {
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { data: bibliografia2, isLoading: loadinasignatura } = // --- 1. Única fuente de verdad: La Query ---
const { data: bibliografia = [], isLoading } =
useSubjectBibliografia(asignaturaId) useSubjectBibliografia(asignaturaId)
const [entries, setEntries] = useState<Array<BibliografiaEntry>>([])
// --- 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 [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -61,29 +76,27 @@ export function BibliographyItem() {
'BASICA', 'BASICA',
) )
useEffect(() => { console.log('Datos actuales en el front:', bibliografia)
console.log(entries) // --- 4. Derivación de datos (Se calculan en cada render) ---
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
if (bibliografia2 && Array.isArray(bibliografia2)) { const complementariaEntries = bibliografia.filter(
setEntries(bibliografia2)
}
}, [bibliografia2])
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = entries.filter(
(e) => e.tipo === 'COMPLEMENTARIA', (e) => e.tipo === 'COMPLEMENTARIA',
) )
console.log(bibliografia2)
// --- Handlers Conectados a la Base de Datos ---
const handleAddManual = (cita: string) => { const handleAddManual = (cita: string) => {
const newEntry: BibliografiaEntry = { crearBibliografia(
id: `manual-${Date.now()}`, {
tipo: newEntryType, asignatura_id: asignaturaId,
cita, tipo: newEntryType,
} cita,
setEntries([...entries, newEntry]) tipo_fuente: 'MANUAL',
setIsAddDialogOpen(false) },
// toast.success('Referencia manual añadida'); {
onSuccess: () => setIsAddDialogOpen(false),
},
)
} }
const handleAddFromLibrary = ( const handleAddFromLibrary = (
@@ -91,22 +104,43 @@ export function BibliographyItem() {
tipo: 'BASICA' | 'COMPLEMENTARIA', tipo: 'BASICA' | 'COMPLEMENTARIA',
) => { ) => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.` const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
const newEntry: BibliografiaEntry = { crearBibliografia(
id: `lib-ref-${Date.now()}`, {
tipo, asignatura_id: asignaturaId,
cita, tipo,
fuenteBibliotecaId: resource.id, cita,
fuenteBiblioteca: resource, tipo_fuente: 'BIBLIOTECA',
} biblioteca_item_id: resource.id,
setEntries([...entries, newEntry]) },
setIsLibraryDialogOpen(false) {
// toast.success('Añadido desde biblioteca'); onSuccess: () => setIsLibraryDialogOpen(false),
},
)
} }
const handleUpdateCita = (id: string, cita: string) => { const handleUpdateCita = (id: string, nuevaCita: string) => {
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e))) actualizarBibliografia(
{
id,
updates: { cita: nuevaCita },
},
{
onSuccess: () => setEditingId(null),
},
)
} }
const onConfirmDelete = () => {
if (deleteId) {
eliminarBibliografia(deleteId, {
onSuccess: () => setDeleteId(null),
})
}
}
if (isLoading)
return <div className="p-10 text-center">Cargando bibliografía...</div>
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500"> <div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
@@ -134,9 +168,13 @@ export function BibliographyItem() {
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<LibrarySearchDialog <LibrarySearchDialog
resources={bibliografia2 || []} // CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
onSelect={handleAddFromLibrary} onSelect={handleAddFromLibrary}
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')} // CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
existingIds={bibliografia.map(
(e) => e.biblioteca_item_id || '',
)}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -216,13 +254,7 @@ export function BibliographyItem() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
onClick={() => {
setEntries(entries.filter((e) => e.id !== deleteId))
setDeleteId(null)
}}
className="bg-red-600"
>
Eliminar Eliminar
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -412,7 +444,7 @@ function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
</Select> </Select>
</div> </div>
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2"> <div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map((res) => ( {filtered.map((res: any) => (
<div <div
key={res.id} key={res.id}
onClick={() => onSelect(res, tipo)} onClick={() => onSelect(res, tipo)}

View File

@@ -2,26 +2,29 @@ import { Check, Loader2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useUpdatePlanFields } from '@/data' // Tu hook existente import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
export const ImprovementCard = ({ export const ImprovementCard = ({
suggestions, suggestions,
onApply, onApply,
planId, // Necesitamos el ID planId,
currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON currentDatos,
activeChatId,
onApplySuccess,
}: { }: {
suggestions: Array<any> suggestions: Array<any>
onApply?: (key: string, value: string) => void onApply?: (key: string, value: string) => void
planId: string planId: string
currentDatos: any currentDatos: any
activeChatId: any
onApplySuccess?: (key: string) => void
}) => { }) => {
const [appliedFields, setAppliedFields] = useState<Array<string>>([]) const [localApplied, setLocalApplied] = useState<Array<string>>([])
const updatePlan = useUpdatePlanFields() const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const handleApply = (key: string, newValue: string) => { const handleApply = (key: string, newValue: string) => {
if (!currentDatos) return if (!currentDatos) return
// 1. Lógica para preparar el valor (idéntica a tu handleSave original)
const currentValue = currentDatos[key] const currentValue = currentDatos[key]
let finalValue: any let finalValue: any
@@ -35,13 +38,11 @@ export const ImprovementCard = ({
finalValue = newValue finalValue = newValue
} }
// 2. Construir el nuevo objeto 'datos' manteniendo lo que ya existía
const datosActualizados = { const datosActualizados = {
...currentDatos, ...currentDatos,
[key]: finalValue, [key]: finalValue,
} }
// 3. Ejecutar la mutación directamente aquí
updatePlan.mutate( updatePlan.mutate(
{ {
planId: planId as any, planId: planId as any,
@@ -49,9 +50,17 @@ export const ImprovementCard = ({
}, },
{ {
onSuccess: () => { 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) if (onApply) onApply(key, newValue)
console.log(`Campo ${key} guardado exitosamente`)
}, },
}, },
) )
@@ -60,7 +69,7 @@ export const ImprovementCard = ({
return ( return (
<div className="mt-2 flex w-full flex-col gap-4"> <div className="mt-2 flex w-full flex-col gap-4">
{suggestions.map((sug) => { {suggestions.map((sug) => {
const isApplied = appliedFields.includes(sug.key) const isApplied = sug.applied === true || localApplied.includes(sug.key)
const isUpdating = const isUpdating =
updatePlan.isPending && updatePlan.isPending &&
updatePlan.variables.patch.datos?.[sug.key] !== undefined updatePlan.variables.patch.datos?.[sug.key] !== undefined

View File

@@ -111,12 +111,6 @@ export async function create_conversation(planId: string) {
) )
if (error) throw error 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 return data
} }
@@ -181,3 +175,64 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] 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<any>).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
}

View File

@@ -462,3 +462,51 @@ export async function lineas_delete(lineaId: string) {
if (error) throw error if (error) throw error
return lineaId 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
}

View File

@@ -10,6 +10,8 @@ import {
getConversationByPlan, getConversationByPlan,
library_search, library_search,
update_conversation_status, update_conversation_status,
update_recommendation_applied_status,
update_conversation_title,
} from '../api/ai.api' } from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol // 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 // CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
currentId = response.conversation_plan.id currentId = response.conversation_plan.id
console.log('Nuevo ID extraído:', currentId)
} }
// 2. Ahora enviamos el mensaje con el ID garantizado // 2. Ahora enviamos el mensaje con el ID garantizado
@@ -56,11 +56,8 @@ export function useChatHistory(conversacionId?: string) {
return useQuery({ return useQuery({
queryKey: ['chat-history', conversacionId], queryKey: ['chat-history', conversacionId],
queryFn: async () => { queryFn: async () => {
console.log('--- EJECUTANDO QUERY FN ---')
console.log('ID RECIBIDO:', conversacionId)
return get_chat_history(conversacionId!) return get_chat_history(conversacionId!)
}, },
// Simplificamos el enabled para probar
enabled: Boolean(conversacionId), 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() { export function useAISubjectImprove() {
return useMutation({ mutationFn: ai_subject_improve }) return useMutation({ mutationFn: ai_subject_improve })
} }
@@ -102,3 +124,16 @@ export function useAISubjectChat() {
export function useLibrarySearch() { export function useLibrarySearch() {
return useMutation({ mutationFn: library_search }) 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'] })
},
})
}

View File

@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ai_generate_subject, ai_generate_subject,
asignaturas_update, asignaturas_update,
bibliografia_delete,
bibliografia_insert,
bibliografia_update,
lineas_insert, lineas_insert,
lineas_update, lineas_update,
subjects_bibliografia_list, 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),
})
},
})
}

View File

@@ -14,6 +14,7 @@ import {
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
RotateCcw, RotateCcw,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from '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 { Textarea } from '@/components/ui/textarea'
import { import {
useAIPlanChat, useAIPlanChat,
useChatHistory,
useConversationByPlan, useConversationByPlan,
useUpdateConversationStatus, useUpdateConversationStatus,
useUpdateConversationTitle,
} from '@/data' } from '@/data'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
@@ -66,7 +67,25 @@ interface SelectedField {
label: string label: string
value: 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')({ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent, component: RouteComponent,
}) })
@@ -76,19 +95,14 @@ function RouteComponent() {
const { data } = usePlan(planId) const { data } = usePlan(planId)
const routerState = useRouterState() const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false) const [openIA, setOpenIA] = useState(false)
const [conversacionId, setConversacionId] = useState<string | null>(null) const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
const { mutateAsync: sendChat, isLoading } = useAIPlanChat()
const { mutate: updateStatusMutation } = useUpdateConversationStatus() const { mutate: updateStatusMutation } = useUpdateConversationStatus()
const [activeChatId, setActiveChatId] = useState<string | undefined>( const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined, undefined,
) )
const { data: historyMessages, isLoading: isLoadingHistory } =
useChatHistory(activeChatId)
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
// archivos
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -104,76 +118,167 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null)
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
const [isSending, setIsSending] = useState(false)
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
null,
)
const [filterQuery, setFilterQuery] = useState('')
const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion
useEffect(() => { // Encadenamiento opcional para evitar errores si data es null
// 1. Si no hay ID o está cargando el historial, no hacemos nada if (!definicion.properties) return []
if (!activeChatId || isLoadingHistory) 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 filteredFields = useMemo(() => {
const flattened = messagesFromApi.map((msg) => { return availableFields.filter(
let content = msg.content (field) =>
let suggestions: Array<any> = [] 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) { const activeChatData = useMemo(() => {
suggestions = Object.entries(content) return lastConversation?.find((chat: any) => chat.id === activeChatId)
.filter(([key]) => key !== 'ai-message') }, [lastConversation, activeChatId])
.map(([key, value]) => ({
key,
label: key.replace(/_/g, ' '),
newValue: value as string,
}))
content = content['ai-message'] || JSON.stringify(content) const chatMessages = useMemo(() => {
} // 1. Si no hay ID o no hay data del chat, retornamos vacío
// Si el content es un string que parece JSON (caso común en respuestas RAW) if (!activeChatId || !activeChatData) return []
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 json = (activeChatData.conversacion_json ||
[]) as unknown as Array<ChatMessageJSON>
// 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 { return {
...msg, id: `err-${index}`,
content, role: 'assistant',
suggestions, content: '',
type: suggestions.length > 0 ? 'improvement-card' : 'text', suggestions: [],
} }
}) }
// Solo actualizamos si no estamos esperando la respuesta de un POST const isAssistant = msg.user === 'assistant'
// para evitar saltos visuales
if (!isLoading) { return {
setMessages(flattened.reverse()) 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(() => { useEffect(() => {
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes scrollToBottom()
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' }, [chatMessages, isLoading])
if (
!activeChatId && useEffect(() => {
lastConversation && // Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
lastConversation.length > 0 && const camposActualizados = selectedFields.filter((field) =>
!isCreationMode input.includes(field.label),
) { )
setActiveChatId(lastConversation[0].id)
// 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 = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
@@ -202,6 +307,9 @@ function RouteComponent() {
if (activeChatId === id) { if (activeChatId === id) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
setOptimisticMessage(null)
setInput('')
setSelectedFields([])
} }
}, },
}, },
@@ -214,8 +322,6 @@ function RouteComponent() {
{ id, estado: 'ACTIVA' }, { id, estado: 'ACTIVA' },
{ {
onSuccess: () => { onSuccess: () => {
// Al invalidar la query, React Query traerá la lista fresca
// y el chat se moverá solo de "archivados" a "activos"
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], 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<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value const val = e.target.value
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
setInput(val) 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 = ( const injectFieldsIntoInput = (
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
// Quita cualquier bloque previo de campos
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
if (fields.length === 0) return cleaned if (fields.length === 0) return cleaned
@@ -277,60 +362,42 @@ function RouteComponent() {
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
let isAdding = false // 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => { setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key) const isSelected = prev.find((f) => f.key === field.key)
if (isSelected) { return isSelected ? prev : [...prev, field]
return prev.filter((f) => f.key !== field.key)
} else {
isAdding = true
return [...prev, field]
}
}) })
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
setInput((prev) => { setInput((prev) => {
// 1. Eliminamos TODOS los ":" que existan en el texto actual // Reemplaza el último ":" y cualquier texto de filtro por el label del campo
// 2. Quitamos espacios en blanco extra al final const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
const cleanPrev = prev.replace(/:/g, '').trim() return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
// 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} `
}) })
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('')
} }
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => { const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
// Si no hay campos, enviamos el texto tal cual
if (fields.length === 0) return userInput if (fields.length === 0) return userInput
return `Instrucción del usuario: ${userInput}` return ` ${userInput}`
} }
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields) const finalPrompt = buildPrompt(rawText, currentFields)
setIsSending(true)
const userMsg = { setOptimisticMessage(rawText)
id: Date.now().toString(),
role: 'user',
content: rawText,
}
setMessages((prev) => [...prev, userMsg])
setInput('') setInput('')
// setSelectedFields([]) setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try { try {
const payload: any = { const payload: any = {
planId: planId, planId: planId,
@@ -346,58 +413,19 @@ function RouteComponent() {
if (response.conversacionId && response.conversacionId !== activeChatId) { if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId) 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 --- await queryClient.invalidateQueries({
let aiText = 'Sin respuesta del asistente' queryKey: ['conversation-by-plan', planId],
let suggestions: Array<any> = [] })
setOptimisticMessage(null)
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,
},
])
} catch (error) { } catch (error) {
console.error('Error en el chat:', error) console.error('Error en el chat:', error)
setMessages((prev) => [ // Aquí sí podrías usar un toast o un mensaje de error temporal
...prev, } finally {
{ // 5. CRÍTICO: Detener el estado de carga SIEMPRE
id: 'error', setIsSending(false)
role: 'assistant', setOptimisticMessage(null)
content: 'Lo siento, hubo un error al procesar tu solicitud.',
},
])
} }
} }
@@ -409,15 +437,9 @@ function RouteComponent() {
) )
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const { activeChats, archivedChats } = useMemo(() => { const removeSelectedField = (fieldKey: string) => {
const allChats = lastConversation || [] setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
return { }
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
@@ -454,7 +476,6 @@ function RouteComponent() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1"> <div className="space-y-1">
{!showArchived ? ( {!showArchived ? (
// --- LISTA DE CHATS ACTIVOS ---
activeChats.map((chat) => ( activeChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
@@ -466,21 +487,77 @@ function RouteComponent() {
}`} }`}
> >
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
{/* Usamos el primer mensaje o un título por defecto */}
<span className="truncate pr-8"> <span
{chat.title || `Chat ${chat.creado_en.split('T')[0]}`} ref={editingChatId === chat.id ? editableRef : null}
</span> contentEditable={editingChatId === chat.id}
<button suppressContentEditableWarning={true}
onClick={(e) => archiveChat(e, chat.id)} className={`truncate pr-14 transition-all outline-none ${
className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600" editingChatId === chat.id
title="Archivar" ? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
: 'cursor-pointer'
}`}
onDoubleClick={(e) => {
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()
}}
> >
<Archive size={14} /> {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
</button> </span>
{/* ACCIONES */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50)
}}
className="p-1 text-slate-400 hover:text-teal-600"
>
<Send size={12} className="rotate-45" />
</button>
<button
onClick={(e) => archiveChat(e, chat.id)}
className="p-1 text-slate-400 hover:text-amber-600"
>
<Archive size={14} />
</button>
</div>
</div> </div>
)) ))
) : ( ) : (
// --- LISTA DE CHATS ARCHIVADOS --- /* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2"> <div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
@@ -492,25 +569,17 @@ function RouteComponent() {
> >
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8"> <span className="truncate pr-8">
{chat.title || {chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`} `Archivado ${chat.creado_en.split('T')[0]}`}
</span> </span>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600" className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
title="Desarchivar"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
</div> </div>
))} ))}
{archivedChats.length === 0 && (
<div className="px-2 py-4 text-center">
<p className="text-xs text-slate-400 italic">
No hay archivados
</p>
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -543,46 +612,98 @@ function RouteComponent() {
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => ( {!activeChatId &&
<div chatMessages.length === 0 &&
key={msg.id} !optimisticMessage ? (
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'ml-auto items-end' : 'items-start'}`} <div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
> <MessageSquarePlus
<div size={48}
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${ className="mb-4 text-slate-300"
msg.role === 'user' />
? 'rounded-tr-none bg-teal-600 text-white' <h3 className="text-lg font-medium text-slate-900">
: 'rounded-tl-none border bg-white text-slate-700' No hay un chat seleccionado
}`} </h3>
> <p className="text-sm text-slate-500">
{/* Contenido de texto normal */} Selecciona un chat del historial o crea uno nuevo para
{msg.content} empezar.
</p>
</div>
) : (
<>
{chatMessages.map((msg: any) => (
<div
key={msg.id}
className={`flex max-w-[85%] flex-col ${
msg.role === 'user'
? 'ml-auto items-end'
: 'items-start'
}`}
>
<div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: `rounded-tl-none border bg-white text-slate-700 ${
// --- LÓGICA DE REFUSAL ---
msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
}`
}`}
>
{/* Icono opcional de advertencia si es refusal */}
{msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span>
</div>
)}
{/* Si el mensaje tiene sugerencias (ImprovementCard) */} {msg.content}
{msg.suggestions && msg.suggestions.length > 0 && (
<div className="mt-4"> {!msg.isRefusal &&
<ImprovementCard msg.suggestions &&
suggestions={msg.suggestions} msg.suggestions.length > 0 && (
planId={planId} // Del useParams() <div className="mt-4">
currentDatos={data?.datos} // De tu query usePlan(planId) <ImprovementCard
onApply={(key, val) => { suggestions={msg.suggestions}
// Esto es opcional, si quieres hacer algo más en la UI del chat planId={planId}
console.log( currentDatos={data?.datos}
'Evento onApply disparado desde el chat', activeChatId={activeChatId}
) onApplySuccess={(key) =>
}} removeSelectedField(key)
/> }
/>
</div>
)}
</div> </div>
)} </div>
</div> ))}
</div>
))} {optimisticMessage && (
{isLoading && ( <div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
<div className="flex gap-2 p-4"> <div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" /> {optimisticMessage}
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" /> </div>
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" /> </div>
</div> )}
{isSending && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
Esperando respuesta...
</span>
</div>
</div>
</div>
)}
</>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -613,25 +734,35 @@ function RouteComponent() {
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */} {/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
Seleccionar campo para IA Resultados para "{filterQuery}"
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => ( {filteredFields.length > 0 ? (
<button filteredFields.map((field, index) => (
key={field.key} <button
onClick={() => toggleField(field)} key={field.key}
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50" onClick={() => toggleField(field)}
> className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
<span className="text-slate-700 group-hover:text-teal-700"> index === 0
{field.label} ? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
</span> : 'hover:bg-slate-50'
{selectedFields.find((f) => f.key === field.key) && ( }`}
<Check size={14} className="text-teal-600" /> >
)} <span>{field.label}</span>
</button> {index === 0 && (
))} <span className="font-mono text-[10px] opacity-50">
TAB
</span>
)}
</button>
))
) : (
<div className="p-3 text-center text-xs text-slate-400">
No hay coincidencias
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -663,6 +794,35 @@ function RouteComponent() {
<Textarea <Textarea
value={input} value={input}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={(e) => {
if (showSuggestions) {
if (e.key === 'Tab' || e.key === 'Enter') {
if (filteredFields.length > 0) {
e.preventDefault()
toggleField(filteredFields[0])
}
}
if (e.key === 'Escape') {
e.preventDefault()
setShowSuggestions(false)
setFilterQuery('')
}
} else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if (
e.key === 'Backspace' &&
input === '' &&
selectedFields.length > 0
) {
setSelectedFields((prev) => prev.slice(0, -1))
}
}
if (e.key === 'Enter' && !e.shiftKey && !showSuggestions) {
e.preventDefault()
if (!isSending) handleSend()
}
}}
placeholder={ placeholder={
selectedFields.length > 0 selectedFields.length > 0
? 'Escribe instrucciones adicionales...' ? 'Escribe instrucciones adicionales...'
@@ -673,12 +833,16 @@ function RouteComponent() {
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={
(!input.trim() && selectedFields.length === 0) || isLoading isSending || (!input.trim() && selectedFields.length === 0)
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700" className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
> >
<Send size={16} className="text-white" /> {isSending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</Button> </Button>
</div> </div>
</div> </div>