Add AI progress loader and enhance suggestion generation logic

- Introduced AIProgressLoader component to display loading progress and messages during suggestion generation.
- Updated PasoSugerenciasForm to manage loading state and display tooltip for preserved suggestions.
- Adjusted suggestion limits and removed unused ciclo input from state.
This commit is contained in:
2026-02-11 16:03:52 -06:00
parent ded54c18dd
commit 07d08e1b57
5 changed files with 237 additions and 67 deletions

View File

@@ -1,4 +1,5 @@
import { RefreshCw, Sparkles } from 'lucide-react'
import { RefreshCw, Sparkles, X } from 'lucide-react'
import { useState } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Dispatch, SetStateAction } from 'react'
@@ -7,7 +8,14 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { generate_subject_suggestions, usePlan } from '@/data'
import { AIProgressLoader } from '@/features/asignaturas/nueva/AIProgressLoader'
import { cn } from '@/lib/utils'
export default function PasoSugerenciasForm({
@@ -17,9 +25,11 @@ export default function PasoSugerenciasForm({
wizard: NewSubjectWizardState
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
}) {
const ciclo = wizard.iaMultiple?.ciclo ?? ''
const enfoque = wizard.iaMultiple?.enfoque ?? ''
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
const isLoading = wizard.iaMultiple?.isLoading ?? false
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
const setIaMultiple = (
patch: Partial<NonNullable<NewSubjectWizardState['iaMultiple']>>,
@@ -28,9 +38,9 @@ export default function PasoSugerenciasForm({
(w): NewSubjectWizardState => ({
...w,
iaMultiple: {
ciclo: w.iaMultiple?.ciclo ?? null,
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: w.iaMultiple?.isLoading ?? false,
...patch,
},
}),
@@ -48,32 +58,31 @@ export default function PasoSugerenciasForm({
}
const onGenerarSugerencias = async () => {
const hadNoSugerenciasBefore = wizard.sugerencias.length === 0
const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected)
onChange((w) => ({
...w,
isLoading: true,
errorMessage: null,
sugerencias: sugerenciasConservadas,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: true,
},
}))
try {
const numeroCiclo = wizard.iaMultiple?.ciclo
if (!numeroCiclo || !Number.isFinite(numeroCiclo) || numeroCiclo <= 0) {
onChange((w) => ({
...w,
isLoading: false,
errorMessage: 'Ingresa un número de ciclo válido.',
}))
return
}
const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 50) {
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 15) {
onChange((w) => ({
...w,
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 15.',
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 50.',
},
}))
return
}
@@ -82,7 +91,6 @@ export default function PasoSugerenciasForm({
const nuevasSugerencias = await generate_subject_suggestions({
plan_estudio_id: wizard.plan_estudio_id,
numero_de_ciclo: numeroCiclo,
enfoque: enfoqueTrim ? enfoqueTrim : undefined,
cantidad_de_sugerencias: cantidad,
sugerencias_conservadas: sugerenciasConservadas.map((s) => ({
@@ -91,11 +99,19 @@ export default function PasoSugerenciasForm({
})),
})
if (hadNoSugerenciasBefore && nuevasSugerencias.length > 0) {
setShowConservacionTooltip(true)
}
onChange(
(w): NewSubjectWizardState => ({
...w,
isLoading: false,
sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas],
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}),
)
} catch (err) {
@@ -104,8 +120,12 @@ export default function PasoSugerenciasForm({
onChange(
(w): NewSubjectWizardState => ({
...w,
isLoading: false,
errorMessage: message,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}),
)
}
@@ -122,48 +142,23 @@ export default function PasoSugerenciasForm({
</span>
</div>
<div className="flex flex-col items-end gap-3 md:flex-row">
{/* Input Ciclo */}
<div className="w-full md:w-36">
<Label className="text-muted-foreground mb-1 block text-xs">
Número de ciclo
</Label>
<Input
placeholder="Ej. 3"
value={ciclo}
type="number"
min={1}
max={999}
onChange={(e) => {
const raw = e.target.value
if (raw === '') {
setIaMultiple({ ciclo: null })
return
}
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
setIaMultiple({ ciclo: capped })
}}
/>
</div>
{/* Input Enfoque */}
<div className="w-full flex-1">
<div className="flex flex-col gap-3">
<div className="w-full">
<Label className="text-muted-foreground mb-1 block text-xs">
Enfoque (opcional)
</Label>
<Input
<Textarea
placeholder="Ej. Enfocado en normativa mexicana y tecnología"
value={enfoque}
maxLength={7000}
rows={4}
onChange={(e) => setIaMultiple({ enfoque: e.target.value })}
/>
</div>
</div>
<div className="mt-3 flex w-full flex-col items-end gap-3 md:flex-row">
<div className="w-full md:w-44">
<div className="mt-3 flex w-full flex-col items-end justify-between gap-3 sm:flex-row">
<div className="w-full sm:w-44">
<Label className="text-muted-foreground mb-1 block text-xs">
Cantidad de sugerencias
</Label>
@@ -172,7 +167,7 @@ export default function PasoSugerenciasForm({
value={cantidadDeSugerencias}
type="number"
min={1}
max={50}
max={15}
step={1}
inputMode="numeric"
onKeyDown={(e) => {
@@ -186,7 +181,7 @@ export default function PasoSugerenciasForm({
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 50)
const capped = Math.min(n >= 1 ? n : 1, 15)
setIaMultiple({ cantidadDeSugerencias: capped })
}}
/>
@@ -197,19 +192,21 @@ export default function PasoSugerenciasForm({
variant="outline"
className="h-9 gap-1.5"
onClick={onGenerarSugerencias}
disabled={wizard.isLoading}
disabled={isLoading}
>
<RefreshCw className="h-3.5 w-3.5" />
Generar sugerencias
{wizard.sugerencias.length > 0
? 'Generar más sugerencias'
: 'Generar sugerencias'}
</Button>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Al generar más sugerencias, solo se conservarán las asignaturas que
hayas seleccionado.
</p>
</div>
<AIProgressLoader
isLoading={isLoading}
cantidadDeSugerencias={cantidadDeSugerencias}
/>
{/* --- HEADER LISTA --- */}
<div className="mb-3 flex items-center justify-between">
<div>
@@ -221,9 +218,32 @@ export default function PasoSugerenciasForm({
{plan ? `${plan.nivel} en ${plan.nombre}` : '...'}
</p>
</div>
<div className="bg-muted text-foreground inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-semibold">
{wizard.sugerencias.filter((s) => s.selected).length} seleccionadas
<Tooltip open={showConservacionTooltip}>
<TooltipTrigger asChild>
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
<span aria-hidden>📌</span>
{wizard.sugerencias.filter((s) => s.selected).length}{' '}
seleccionadas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm">
Al generar más sugerencias, se conservarán las asignaturas
seleccionadas.
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setShowConservacionTooltip(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</TooltipContent>
</Tooltip>
</div>
{/* --- LISTA DE ASIGNATURAS --- */}

View File

@@ -140,7 +140,6 @@ export type AIGenerateSubjectInput = {
export type GenerateSubjectSuggestionsInput = {
plan_estudio_id: UUID
numero_de_ciclo: number
enfoque?: string
cantidad_de_sugerencias: number
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>

View File

@@ -0,0 +1,151 @@
import React, { useState, useEffect, useMemo } from 'react'
// --- DEFINICIÓN DE MENSAJES ---
const MENSAJES_CORTOS = [
// Hasta 5 sugerencias (6 mensajes)
'Analizando el plan de estudios...',
'Identificando áreas de oportunidad...',
'Consultando bases de datos académicas...',
'Redactando competencias específicas...',
'Calculando créditos y horas...',
'Afinando los últimos detalles...',
]
const MENSAJES_MEDIOS = [
// Hasta 10 sugerencias (10 mensajes)
'Conectando con el motor de IA...',
'Analizando estructura curricular...',
'Buscando asignaturas compatibles...',
'Verificando prerrequisitos...',
'Generando descripciones detalladas...',
'Balanceando cargas académicas...',
'Asignando horas independientes...',
'Validando coherencia temática...',
'Formateando resultados...',
'Finalizando generación...',
]
const MENSAJES_LARGOS = [
// Más de 10 sugerencias (14 mensajes)
'Iniciando procesamiento masivo...',
'Escaneando retícula completa...',
'Detectando líneas de investigación...',
'Generando primer bloque de asignaturas...',
'Evaluando pertinencia académica...',
'Optimizando créditos por ciclo...',
'Redactando objetivos de aprendizaje...',
'Generando segundo bloque...',
'Revisando duplicidad de contenidos...',
'Ajustando tiempos teóricos y prácticos...',
'Verificando normatividad...',
'Compilando sugerencias...',
'Aplicando formato final...',
'Casi listo, gracias por tu paciencia...',
]
interface AIProgressLoaderProps {
isLoading: boolean
cantidadDeSugerencias: number
}
export const AIProgressLoader: React.FC<AIProgressLoaderProps> = ({
isLoading,
cantidadDeSugerencias,
}) => {
const [progress, setProgress] = useState(0)
const [currentMessageIndex, setCurrentMessageIndex] = useState(0)
// 1. Seleccionar el grupo de mensajes según la cantidad
const messages = useMemo(() => {
if (cantidadDeSugerencias <= 5) return MENSAJES_CORTOS
if (cantidadDeSugerencias <= 10) return MENSAJES_MEDIOS
return MENSAJES_LARGOS
}, [cantidadDeSugerencias])
useEffect(() => {
if (!isLoading) {
setProgress(0)
setCurrentMessageIndex(0)
return
}
// --- CÁLCULO DEL TIEMPO TOTAL ---
// y = 4.07x + 10.93 (en segundos)
const estimatedSeconds = 4.07 * cantidadDeSugerencias + 10.93
const durationMs = estimatedSeconds * 1000
// Intervalo de actualización de la barra (cada 50ms para suavidad)
const updateInterval = 50
const totalSteps = durationMs / updateInterval
const incrementPerStep = 99 / totalSteps // Llegamos al 99% para esperar la respuesta real
// --- TIMER 1: BARRA DE PROGRESO ---
const progressTimer = setInterval(() => {
setProgress((prev) => {
const next = prev + incrementPerStep
return next >= 99 ? 99 : next // Topar en 99%
})
}, updateInterval)
// --- TIMER 2: MENSAJES (CADA 5 SEGUNDOS) ---
const messagesTimer = setInterval(() => {
setCurrentMessageIndex((prev) => {
// Si ya es el último mensaje, no avanzar más (no ciclar)
if (prev >= messages.length - 1) return prev
return prev + 1
})
}, 5000)
// Cleanup al desmontar o cuando isLoading cambie
return () => {
clearInterval(progressTimer)
clearInterval(messagesTimer)
}
}, [isLoading, cantidadDeSugerencias, messages])
if (!isLoading) return null
return (
<div className="animate-in fade-in zoom-in m-2 mx-auto w-full max-w-md duration-300">
{/* Contenedor de la barra */}
<div className="relative pt-1">
<div className="mb-2 flex items-center justify-between">
<div>
<span className="inline-block rounded-full bg-blue-200 px-2 py-1 text-xs font-semibold text-blue-600 uppercase">
Generando IA
</span>
</div>
<div className="text-right">
<span className="inline-block text-xs font-semibold text-blue-600">
{Math.floor(progress)}%
</span>
</div>
</div>
{/* Barra de fondo */}
<div className="mb-4 flex h-2 overflow-hidden rounded bg-blue-100 text-xs">
{/* Barra de progreso dinámica */}
<div
style={{ width: `${progress}%` }}
className="flex flex-col justify-center bg-blue-500 text-center whitespace-nowrap text-white shadow-none transition-all duration-75 ease-linear"
></div>
</div>
{/* Mensajes cambiantes */}
<div className="h-6 text-center">
{' '}
{/* Altura fija para evitar saltos */}
<p className="text-sm text-slate-500 italic transition-opacity duration-500">
{messages[currentMessageIndex]}
</p>
</div>
{/* Nota de tiempo estimado (Opcional, transparencia operacional) */}
<p className="mt-2 text-center text-[10px] text-slate-400">
Tiempo estimado: ~{Math.ceil(4.07 * cantidadDeSugerencias + 10.93)}{' '}
segs
</p>
</div>
</div>
)
}

View File

@@ -30,9 +30,9 @@ export function useNuevaAsignaturaWizard(planId: string) {
archivosAdjuntos: [],
},
iaMultiple: {
ciclo: null,
enfoque: '',
cantidadDeSugerencias: 10,
isLoading: false,
},
resumen: {},
isLoading: false,

View File

@@ -65,9 +65,9 @@ export type NewSubjectWizardState = {
archivosAdjuntos?: Array<UploadedFile>
}
iaMultiple?: {
ciclo: number | null
enfoque: string
cantidadDeSugerencias: number
isLoading: boolean
}
resumen: {
previewAsignatura?: AsignaturaPreview