primera versión funcional de creación de plan con IA

This commit is contained in:
2026-01-20 17:04:39 -06:00
parent 9aad9aed00
commit 18f2bed3ea
8 changed files with 119 additions and 70 deletions

View File

@@ -12,6 +12,7 @@ export interface UploadedFile {
}
interface FileDropzoneProps {
persistentFiles?: Array<UploadedFile>
onFilesChange?: (files: Array<UploadedFile>) => void
acceptedTypes?: string
maxFiles?: number
@@ -20,6 +21,7 @@ interface FileDropzoneProps {
}
export function FileDropzone({
persistentFiles,
onFilesChange,
acceptedTypes = '.doc,.docx,.pdf',
maxFiles = 5,
@@ -27,7 +29,7 @@ export function FileDropzone({
description = 'o haz clic para seleccionar',
}: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>([])
const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const addFiles = useCallback(

View File

@@ -23,12 +23,10 @@ import {
export function PasoDetallesPanel({
wizard,
onChange,
onGenerarIA,
isLoading,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
onGenerarIA: () => void
isLoading: boolean
}) {
if (wizard.tipoOrigen === 'MANUAL') {
@@ -87,6 +85,7 @@ export function PasoDetallesPanel({
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w) => {
const prev = w.iaConfig?.archivosReferencia || []
@@ -133,7 +132,7 @@ export function PasoDetallesPanel({
<div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante.
</div>
<Button onClick={onGenerarIA} disabled={isLoading}>
<Button disabled={isLoading}>
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
</Button>
</div>

View File

@@ -21,12 +21,14 @@ import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
const ReferenciasParaIA = ({
selectedArchivoIds = [],
selectedRepositorioIds = [],
uploadedFiles = [],
onToggleArchivo,
onToggleRepositorio,
onFilesChange,
}: {
selectedArchivoIds?: Array<string>
selectedRepositorioIds?: Array<string>
uploadedFiles?: Array<UploadedFile>
onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: (files: Array<UploadedFile>) => void
@@ -163,6 +165,7 @@ const ReferenciasParaIA = ({
content: (
<div>
<FileDropzone
persistentFiles={uploadedFiles}
onFilesChange={onFilesChange}
title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación"

View File

@@ -1,3 +1,4 @@
import type { UploadedFile } from './PasoDetallesPanel/FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import {
@@ -166,7 +167,7 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
<div className="mt-2">
<div className="font-medium">Adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => (
{adjuntos.map((f: UploadedFile) => (
<li key={f.id}>
<span className="text-foreground">
{f.file.name}

View File

@@ -18,7 +18,7 @@ import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/
const EDGE = {
plans_create_manual: "plans_create_manual",
ai_generate_plan: "ai_generate_plan",
ai_generate_plan: "ai-generate-plan",
plans_persist_from_ai: "plans_persist_from_ai",
plans_clone_from_existing: "plans_clone_from_existing",
@@ -214,10 +214,10 @@ export type AIGeneratePlanInput = {
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
estructuraPlanId: UUID;
};
iaConfig: {
descripcionEnfoque: string;
poblacionObjetivo?: string;
notasAdicionales?: string;
archivosReferencia?: Array<UUID>;
repositoriosIds?: Array<UUID>;
@@ -229,7 +229,27 @@ export type AIGeneratePlanInput = {
export async function ai_generate_plan(
input: AIGeneratePlanInput,
): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_plan, input);
console.log("input ai generate", input);
const edgeFunctionBody = new FormData();
edgeFunctionBody.append("datosBasicos", JSON.stringify(input.datosBasicos));
edgeFunctionBody.append(
"iaConfig",
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
);
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file);
});
return invokeEdge<any>(
EDGE.ai_generate_plan,
edgeFunctionBody,
undefined,
supabaseBrowser(),
);
}
export async function plans_persist_from_ai(

View File

@@ -1,7 +1,8 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
import { supabaseBrowser } from "./client";
import type { Database } from "@/types/supabase";
import type { SupabaseClient } from "@supabase/supabase-js";
export type EdgeInvokeOptions = {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
@@ -12,7 +13,7 @@ export class EdgeFunctionError extends Error {
message: string,
public readonly functionName: string,
public readonly status?: number,
public readonly details?: unknown
public readonly details?: unknown,
) {
super(message);
this.name = "EdgeFunctionError";
@@ -21,9 +22,17 @@ export class EdgeFunctionError extends Error {
export async function invokeEdge<TOut>(
functionName: string,
body?: unknown,
body?:
| string
| File
| Blob
| ArrayBuffer
| FormData
| ReadableStream<Uint8Array<ArrayBufferLike>>
| Record<string, unknown>
| undefined,
opts: EdgeInvokeOptions = {},
client?: SupabaseClient<Database>
client?: SupabaseClient<Database>,
): Promise<TOut> {
const supabase = client ?? supabaseBrowser();
@@ -34,12 +43,12 @@ export async function invokeEdge<TOut>(
});
if (error) {
const anyErr = error as any;
const anyErr = error;
throw new EdgeFunctionError(
anyErr.message ?? "Error en Edge Function",
functionName,
anyErr.status,
anyErr
anyErr,
);
}

View File

@@ -25,6 +25,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useGeneratePlanAI } from '@/data/hooks/usePlans'
// Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
@@ -47,6 +48,8 @@ const Wizard = defineStepper(
export default function NuevoPlanModalContainer() {
const navigate = useNavigate()
const role = auth_get_current_user_role()
const generatePlanAI = useGeneratePlanAI()
// const persistPlanFromAI = usePersistPlanFromAI()
const {
wizard,
@@ -54,7 +57,6 @@ export default function NuevoPlanModalContainer() {
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
generarPreviewIA,
} = useNuevoPlanWizard()
const handleClose = () => {
@@ -62,23 +64,72 @@ export default function NuevoPlanModalContainer() {
}
const crearPlan = async () => {
setWizard((w: NewPlanWizardState) => ({
...w,
isLoading: true,
errorMessage: null,
}))
await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001'
if (
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
)
return 'plan_new_clone_001'
return 'plan_new_import_001'
})()
navigate({ to: `/planes/${nuevoId}` })
setWizard(
(w: NewPlanWizardState): NewPlanWizardState => ({
...w,
isLoading: true,
errorMessage: null,
}),
)
try {
if (wizard.tipoOrigen === 'IA') {
const tipoCicloSafe = (wizard.datosBasicos.tipoCiclo ||
'Semestre') as any
const numCiclosSafe =
typeof wizard.datosBasicos.numCiclos === 'number'
? wizard.datosBasicos.numCiclos
: 1
const aiInput = {
datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carreraId,
facultadId: wizard.datosBasicos.facultadId || undefined,
nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
},
iaConfig: {
descripcionEnfoque: wizard.iaConfig?.descripcionEnfoque || '',
notasAdicionales: wizard.iaConfig?.notasAdicionales || '',
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
},
}
const response = await generatePlanAI.mutateAsync(aiInput as any)
// const createdPlan = await persistPlanFromAI.mutateAsync({
// jsonPlan: generatedJson,
// })
// navigate({ to: `/planes/${createdPlan.id}` })
console.log('Plan generado por IA:', response)
return
}
// Fallback: comportamiento previo para otros modos (mock IDs)
await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
)
return 'plan_new_clone_001'
return 'plan_new_import_001'
})()
navigate({ to: `/planes/${nuevoId}` })
} catch (err: any) {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan con IA',
}))
} finally {
setWizard((w) => ({ ...w, isLoading: false }))
}
}
return (
@@ -164,7 +215,6 @@ export default function NuevoPlanModalContainer() {
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>

View File

@@ -1,7 +1,6 @@
import { useState } from "react";
import type { NewPlanWizardState, PlanPreview } from "../types";
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
import type { NewPlanWizardState } from "../types";
export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({
@@ -81,45 +80,11 @@ export function useNuevoPlanWizard() {
return false;
})();
const generarPreviewIA = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
await new Promise((r) => setTimeout(r, 800));
// Ensure preview has the stricter types required by `PlanPreview`.
let tipoCicloSafe: TipoCiclo;
if (wizard.datosBasicos.tipoCiclo === "") {
tipoCicloSafe = "Semestre";
} else {
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
}
const numCiclosSafe: number =
typeof wizard.datosBasicos.numCiclos === "number"
? wizard.datosBasicos.numCiclos
: 1;
const preview: PlanPreview = {
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
numAsignaturasAprox: numCiclosSafe * 6,
secciones: [
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
],
};
setWizard((w: NewPlanWizardState) => ({
...w,
isLoading: false,
resumen: { previewPlan: preview },
}));
};
return {
wizard,
setWizard,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
generarPreviewIA,
};
}