Merge pull request 'Mejorar experiencia de usuario #141' (#144) from issue/141-mejorar-experiencia-de-usuario into main
Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
@@ -128,16 +128,15 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const availableFields = useMemo(() => {
|
const availableFields = useMemo(() => {
|
||||||
// 1. Hacemos un cast de la definición a nuestra interfaz
|
|
||||||
const definicion = data?.estructuras_plan
|
const definicion = data?.estructuras_plan
|
||||||
?.definicion as EstructuraDefinicion
|
?.definicion as EstructuraDefinicion
|
||||||
|
|
||||||
|
// Encadenamiento opcional para evitar errores si data es null
|
||||||
if (!definicion.properties) return []
|
if (!definicion.properties) return []
|
||||||
|
|
||||||
return Object.entries(definicion.properties).map(([key, value]) => ({
|
return Object.entries(definicion.properties).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
label: value.title,
|
label: value.title,
|
||||||
// 2. Aquí value ya no es unknown, es parte de nuestra interfaz
|
|
||||||
value: String(value.description || ''),
|
value: String(value.description || ''),
|
||||||
}))
|
}))
|
||||||
}, [data])
|
}, [data])
|
||||||
@@ -146,18 +145,32 @@ function RouteComponent() {
|
|||||||
}, [lastConversation, activeChatId])
|
}, [lastConversation, activeChatId])
|
||||||
|
|
||||||
const chatMessages = useMemo(() => {
|
const chatMessages = useMemo(() => {
|
||||||
// Forzamos el cast a Array de nuestra interfaz
|
// 1. Si no hay ID o no hay data del chat, retornamos vacío
|
||||||
const json = (activeChatData?.conversacion_json ||
|
if (!activeChatId || !activeChatData) return []
|
||||||
|
|
||||||
|
const json = (activeChatData.conversacion_json ||
|
||||||
[]) as unknown as Array<ChatMessageJSON>
|
[]) as unknown as Array<ChatMessageJSON>
|
||||||
|
|
||||||
// Ahora .map() funcionará sin errores
|
// 2. Verificamos que 'json' sea realmente un array antes de mapear
|
||||||
|
if (!Array.isArray(json)) return []
|
||||||
|
|
||||||
return json.map((msg, index: number) => {
|
return json.map((msg, index: number) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (!msg?.user) {
|
||||||
|
return {
|
||||||
|
id: `err-${index}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
suggestions: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAssistant = msg.user === 'assistant'
|
const isAssistant = msg.user === 'assistant'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${activeChatId}-${index}`,
|
id: `${activeChatId}-${index}`,
|
||||||
role: isAssistant ? 'assistant' : 'user',
|
role: isAssistant ? 'assistant' : 'user',
|
||||||
content: isAssistant ? msg.message : msg.prompt,
|
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
|
||||||
isRefusal: isAssistant && msg.refusal === true,
|
isRefusal: isAssistant && msg.refusal === true,
|
||||||
suggestions:
|
suggestions:
|
||||||
isAssistant && msg.recommendations
|
isAssistant && msg.recommendations
|
||||||
@@ -178,6 +191,7 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [activeChatData, activeChatId, availableFields])
|
}, [activeChatData, activeChatId, availableFields])
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
// Buscamos el viewport interno del ScrollArea de Radix
|
// Buscamos el viewport interno del ScrollArea de Radix
|
||||||
@@ -192,25 +206,45 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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])
|
||||||
|
|
||||||
// Auto-scroll cuando cambian los mensajes o cuando la IA está cargando
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [chatMessages, isLoading])
|
}, [chatMessages, isLoading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes
|
if (isLoadingConv || !lastConversation) return
|
||||||
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
|
||||||
if (
|
|
||||||
!activeChatId &&
|
|
||||||
lastConversation &&
|
|
||||||
lastConversation.length > 0 &&
|
|
||||||
!isCreationMode
|
|
||||||
) {
|
|
||||||
setActiveChatId(lastConversation[0].id)
|
|
||||||
}
|
|
||||||
}, [lastConversation, activeChatId])
|
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const state = routerState.location.state as any
|
const state = routerState.location.state as any
|
||||||
if (!state?.campo_edit || availableFields.length === 0) return
|
if (!state?.campo_edit || availableFields.length === 0) return
|
||||||
@@ -252,6 +286,9 @@ function RouteComponent() {
|
|||||||
if (activeChatId === id) {
|
if (activeChatId === id) {
|
||||||
setActiveChatId(undefined)
|
setActiveChatId(undefined)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
|
setOptimisticMessage(null)
|
||||||
|
setInput('')
|
||||||
|
setSelectedFields([])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -331,6 +368,9 @@ function RouteComponent() {
|
|||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
setOptimisticMessage(rawText)
|
setOptimisticMessage(rawText)
|
||||||
setInput('')
|
setInput('')
|
||||||
|
setSelectedArchivoIds([])
|
||||||
|
setSelectedRepositorioIds([])
|
||||||
|
setUploadedFiles([])
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
planId: planId,
|
planId: planId,
|
||||||
@@ -370,16 +410,6 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
}, [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) => {
|
const removeSelectedField = (fieldKey: string) => {
|
||||||
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
||||||
}
|
}
|
||||||
@@ -555,11 +585,31 @@ 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">
|
||||||
|
{!activeChatId &&
|
||||||
|
chatMessages.length === 0 &&
|
||||||
|
!optimisticMessage ? (
|
||||||
|
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
|
||||||
|
<MessageSquarePlus
|
||||||
|
size={48}
|
||||||
|
className="mb-4 text-slate-300"
|
||||||
|
/>
|
||||||
|
<h3 className="text-lg font-medium text-slate-900">
|
||||||
|
No hay un chat seleccionado
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Selecciona un chat del historial o crea uno nuevo para
|
||||||
|
empezar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{chatMessages.map((msg: any) => (
|
{chatMessages.map((msg: any) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`flex max-w-[85%] flex-col ${
|
className={`flex max-w-[85%] flex-col ${
|
||||||
msg.role === 'user' ? 'ml-auto items-end' : 'items-start'
|
msg.role === 'user'
|
||||||
|
? 'ml-auto items-end'
|
||||||
|
: 'items-start'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -592,13 +642,16 @@ function RouteComponent() {
|
|||||||
planId={planId}
|
planId={planId}
|
||||||
currentDatos={data?.datos}
|
currentDatos={data?.datos}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onApplySuccess={(key) => removeSelectedField(key)}
|
onApplySuccess={(key) =>
|
||||||
|
removeSelectedField(key)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{optimisticMessage && (
|
{optimisticMessage && (
|
||||||
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
|
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
|
||||||
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
|
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
|
||||||
@@ -606,6 +659,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSending && (
|
{isSending && (
|
||||||
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
|
<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="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
@@ -622,6 +676,8 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -651,25 +707,42 @@ 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="...">
|
||||||
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
|
<div className="...">Seleccionar campo para IA</div>
|
||||||
Seleccionar campo para IA
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto p-1">
|
<div className="max-h-64 overflow-y-auto p-1">
|
||||||
{availableFields.map((field) => (
|
{availableFields.map((field) => {
|
||||||
|
// 1. Verificamos si el campo ya está en la lista de seleccionados
|
||||||
|
const isAlreadySelected = selectedFields.some(
|
||||||
|
(f) => f.key === field.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={field.key}
|
key={field.key}
|
||||||
onClick={() => toggleField(field)}
|
onClick={() => !isAlreadySelected && toggleField(field)}
|
||||||
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"
|
// 2. Aplicamos el atributo disabled
|
||||||
|
disabled={isAlreadySelected}
|
||||||
|
className={`group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
isAlreadySelected
|
||||||
|
? 'cursor-not-allowed bg-slate-50 opacity-50' // Estilo visual de deshabilitado
|
||||||
|
: 'hover:bg-teal-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-slate-700 ${!isAlreadySelected && 'group-hover:text-teal-700'}`}
|
||||||
>
|
>
|
||||||
<span className="text-slate-700 group-hover:text-teal-700">
|
|
||||||
{field.label}
|
{field.label}
|
||||||
</span>
|
</span>
|
||||||
{selectedFields.find((f) => f.key === field.key) && (
|
|
||||||
<Check size={14} className="text-teal-600" />
|
{isAlreadySelected && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-medium text-teal-600">
|
||||||
|
<Check size={12} />
|
||||||
|
<span>Seleccionado</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user