primera versión funcional de creación de plan con IA
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user