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 {
|
interface FileDropzoneProps {
|
||||||
|
persistentFiles?: Array<UploadedFile>
|
||||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||||
acceptedTypes?: string
|
acceptedTypes?: string
|
||||||
maxFiles?: number
|
maxFiles?: number
|
||||||
@@ -20,6 +21,7 @@ interface FileDropzoneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileDropzone({
|
export function FileDropzone({
|
||||||
|
persistentFiles,
|
||||||
onFilesChange,
|
onFilesChange,
|
||||||
acceptedTypes = '.doc,.docx,.pdf',
|
acceptedTypes = '.doc,.docx,.pdf',
|
||||||
maxFiles = 5,
|
maxFiles = 5,
|
||||||
@@ -27,7 +29,7 @@ export function FileDropzone({
|
|||||||
description = 'o haz clic para seleccionar',
|
description = 'o haz clic para seleccionar',
|
||||||
}: FileDropzoneProps) {
|
}: FileDropzoneProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
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 onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ import {
|
|||||||
export function PasoDetallesPanel({
|
export function PasoDetallesPanel({
|
||||||
wizard,
|
wizard,
|
||||||
onChange,
|
onChange,
|
||||||
onGenerarIA,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
wizard: NewPlanWizardState
|
wizard: NewPlanWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||||
onGenerarIA: () => void
|
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
if (wizard.tipoOrigen === 'MANUAL') {
|
if (wizard.tipoOrigen === 'MANUAL') {
|
||||||
@@ -87,6 +85,7 @@ export function PasoDetallesPanel({
|
|||||||
<ReferenciasParaIA
|
<ReferenciasParaIA
|
||||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||||
|
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
|
||||||
onToggleArchivo={(id, checked) =>
|
onToggleArchivo={(id, checked) =>
|
||||||
onChange((w) => {
|
onChange((w) => {
|
||||||
const prev = w.iaConfig?.archivosReferencia || []
|
const prev = w.iaConfig?.archivosReferencia || []
|
||||||
@@ -133,7 +132,7 @@ export function PasoDetallesPanel({
|
|||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onGenerarIA} disabled={isLoading}>
|
<Button disabled={isLoading}>
|
||||||
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
|
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
|
|||||||
const ReferenciasParaIA = ({
|
const ReferenciasParaIA = ({
|
||||||
selectedArchivoIds = [],
|
selectedArchivoIds = [],
|
||||||
selectedRepositorioIds = [],
|
selectedRepositorioIds = [],
|
||||||
|
uploadedFiles = [],
|
||||||
onToggleArchivo,
|
onToggleArchivo,
|
||||||
onToggleRepositorio,
|
onToggleRepositorio,
|
||||||
onFilesChange,
|
onFilesChange,
|
||||||
}: {
|
}: {
|
||||||
selectedArchivoIds?: Array<string>
|
selectedArchivoIds?: Array<string>
|
||||||
selectedRepositorioIds?: Array<string>
|
selectedRepositorioIds?: Array<string>
|
||||||
|
uploadedFiles?: Array<UploadedFile>
|
||||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||||
@@ -163,6 +165,7 @@ const ReferenciasParaIA = ({
|
|||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
|
persistentFiles={uploadedFiles}
|
||||||
onFilesChange={onFilesChange}
|
onFilesChange={onFilesChange}
|
||||||
title="Sube archivos de referencia"
|
title="Sube archivos de referencia"
|
||||||
description="Documentos que serán usados como contexto para la generación"
|
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 type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -166,7 +167,7 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="font-medium">Adjuntos</div>
|
<div className="font-medium">Adjuntos</div>
|
||||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||||
{adjuntos.map((f) => (
|
{adjuntos.map((f: UploadedFile) => (
|
||||||
<li key={f.id}>
|
<li key={f.id}>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
{f.file.name}
|
{f.file.name}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/
|
|||||||
|
|
||||||
const EDGE = {
|
const EDGE = {
|
||||||
plans_create_manual: "plans_create_manual",
|
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_persist_from_ai: "plans_persist_from_ai",
|
||||||
plans_clone_from_existing: "plans_clone_from_existing",
|
plans_clone_from_existing: "plans_clone_from_existing",
|
||||||
|
|
||||||
@@ -214,10 +214,10 @@ export type AIGeneratePlanInput = {
|
|||||||
nivel: string;
|
nivel: string;
|
||||||
tipoCiclo: TipoCiclo;
|
tipoCiclo: TipoCiclo;
|
||||||
numCiclos: number;
|
numCiclos: number;
|
||||||
|
estructuraPlanId: UUID;
|
||||||
};
|
};
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: string;
|
descripcionEnfoque: string;
|
||||||
poblacionObjetivo?: string;
|
|
||||||
notasAdicionales?: string;
|
notasAdicionales?: string;
|
||||||
archivosReferencia?: Array<UUID>;
|
archivosReferencia?: Array<UUID>;
|
||||||
repositoriosIds?: Array<UUID>;
|
repositoriosIds?: Array<UUID>;
|
||||||
@@ -229,7 +229,27 @@ export type AIGeneratePlanInput = {
|
|||||||
export async function ai_generate_plan(
|
export async function ai_generate_plan(
|
||||||
input: AIGeneratePlanInput,
|
input: AIGeneratePlanInput,
|
||||||
): Promise<any> {
|
): 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(
|
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 { supabaseBrowser } from "./client";
|
||||||
|
|
||||||
|
import type { Database } from "@/types/supabase";
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
export type EdgeInvokeOptions = {
|
export type EdgeInvokeOptions = {
|
||||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -12,7 +13,7 @@ export class EdgeFunctionError extends Error {
|
|||||||
message: string,
|
message: string,
|
||||||
public readonly functionName: string,
|
public readonly functionName: string,
|
||||||
public readonly status?: number,
|
public readonly status?: number,
|
||||||
public readonly details?: unknown
|
public readonly details?: unknown,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "EdgeFunctionError";
|
this.name = "EdgeFunctionError";
|
||||||
@@ -21,9 +22,17 @@ export class EdgeFunctionError extends Error {
|
|||||||
|
|
||||||
export async function invokeEdge<TOut>(
|
export async function invokeEdge<TOut>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
body?: unknown,
|
body?:
|
||||||
|
| string
|
||||||
|
| File
|
||||||
|
| Blob
|
||||||
|
| ArrayBuffer
|
||||||
|
| FormData
|
||||||
|
| ReadableStream<Uint8Array<ArrayBufferLike>>
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined,
|
||||||
opts: EdgeInvokeOptions = {},
|
opts: EdgeInvokeOptions = {},
|
||||||
client?: SupabaseClient<Database>
|
client?: SupabaseClient<Database>,
|
||||||
): Promise<TOut> {
|
): Promise<TOut> {
|
||||||
const supabase = client ?? supabaseBrowser();
|
const supabase = client ?? supabaseBrowser();
|
||||||
|
|
||||||
@@ -34,12 +43,12 @@ export async function invokeEdge<TOut>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
const anyErr = error as any;
|
const anyErr = error;
|
||||||
throw new EdgeFunctionError(
|
throw new EdgeFunctionError(
|
||||||
anyErr.message ?? "Error en Edge Function",
|
anyErr.message ?? "Error en Edge Function",
|
||||||
functionName,
|
functionName,
|
||||||
anyErr.status,
|
anyErr.status,
|
||||||
anyErr
|
anyErr,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { useGeneratePlanAI } from '@/data/hooks/usePlans'
|
||||||
|
|
||||||
// Mock de permisos/rol
|
// Mock de permisos/rol
|
||||||
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
||||||
@@ -47,6 +48,8 @@ const Wizard = defineStepper(
|
|||||||
export default function NuevoPlanModalContainer() {
|
export default function NuevoPlanModalContainer() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const role = auth_get_current_user_role()
|
const role = auth_get_current_user_role()
|
||||||
|
const generatePlanAI = useGeneratePlanAI()
|
||||||
|
// const persistPlanFromAI = usePersistPlanFromAI()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
wizard,
|
wizard,
|
||||||
@@ -54,7 +57,6 @@ export default function NuevoPlanModalContainer() {
|
|||||||
canContinueDesdeModo,
|
canContinueDesdeModo,
|
||||||
canContinueDesdeBasicos,
|
canContinueDesdeBasicos,
|
||||||
canContinueDesdeDetalles,
|
canContinueDesdeDetalles,
|
||||||
generarPreviewIA,
|
|
||||||
} = useNuevoPlanWizard()
|
} = useNuevoPlanWizard()
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -62,15 +64,55 @@ export default function NuevoPlanModalContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const crearPlan = async () => {
|
const crearPlan = async () => {
|
||||||
setWizard((w: NewPlanWizardState) => ({
|
setWizard(
|
||||||
|
(w: NewPlanWizardState): NewPlanWizardState => ({
|
||||||
...w,
|
...w,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
errorMessage: null,
|
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))
|
await new Promise((r) => setTimeout(r, 900))
|
||||||
const nuevoId = (() => {
|
const nuevoId = (() => {
|
||||||
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
|
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
|
||||||
if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001'
|
|
||||||
if (
|
if (
|
||||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||||
@@ -79,6 +121,15 @@ export default function NuevoPlanModalContainer() {
|
|||||||
return 'plan_new_import_001'
|
return 'plan_new_import_001'
|
||||||
})()
|
})()
|
||||||
navigate({ to: `/planes/${nuevoId}` })
|
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 (
|
return (
|
||||||
@@ -164,7 +215,6 @@ export default function NuevoPlanModalContainer() {
|
|||||||
<PasoDetallesPanel
|
<PasoDetallesPanel
|
||||||
wizard={wizard}
|
wizard={wizard}
|
||||||
onChange={setWizard}
|
onChange={setWizard}
|
||||||
onGenerarIA={generarPreviewIA}
|
|
||||||
isLoading={wizard.isLoading}
|
isLoading={wizard.isLoading}
|
||||||
/>
|
/>
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { NewPlanWizardState, PlanPreview } from "../types";
|
import type { NewPlanWizardState } from "../types";
|
||||||
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
|
|
||||||
|
|
||||||
export function useNuevoPlanWizard() {
|
export function useNuevoPlanWizard() {
|
||||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||||
@@ -81,45 +80,11 @@ export function useNuevoPlanWizard() {
|
|||||||
return false;
|
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 {
|
return {
|
||||||
wizard,
|
wizard,
|
||||||
setWizard,
|
setWizard,
|
||||||
canContinueDesdeModo,
|
canContinueDesdeModo,
|
||||||
canContinueDesdeBasicos,
|
canContinueDesdeBasicos,
|
||||||
canContinueDesdeDetalles,
|
canContinueDesdeDetalles,
|
||||||
generarPreviewIA,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user