13 Commits

Author SHA1 Message Date
a6efb496db Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-27 15:05:17 -06:00
ef6cc7b96d gitingore modificado 2025-11-27 15:04:47 -06:00
29231206c0 Se quitan respuestas del asistente y se agrega boton de cerrar modal 2025-11-25 15:21:32 -06:00
93c79eee77 Se agrega modelo de respuestas y conversaciones archivos multiples y contexto de id plan de estudios 2025-11-25 11:34:00 -06:00
6f97a83eb0 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-24 16:17:53 -06:00
4ec2c2d533 Se agrega funcionalidades de crear conversacion, archivos y vectores ademas de MCP 2025-11-24 16:17:49 -06:00
efe7faa65f Cambios de Roberto 2025-11-21 17:05:16 -06:00
c9d66ce2e5 Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-11-18 15:17:15 -06:00
f7a29ad510 Version estable conversacion normal 2025-11-18 15:17:11 -06:00
e7a47f56f8 Merge pull request '[#67] dummy' (!2) from task/67-dummy into main
Reviewed-on: #2
2025-11-13 21:32:48 +00:00
214d17cf98 [#67] dummy
https://proyectos.apps.lci.ulsa.mx/work_packages/67
2025-11-13 15:22:28 -06:00
8c890d76e0 Se agrega titulo a pdf 2025-11-13 10:23:04 -06:00
6d264a8214 Merge branch 'master' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-10-30 14:38:56 -06:00
8 changed files with 604 additions and 378 deletions

4
.env.local2 Normal file
View File

@@ -0,0 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
#VITE_BACK_ORIGIN=http://localhost:3001
VITE_BACK_ORIGIN=http://localhost:3001

3
.gitignore vendored
View File

@@ -4,6 +4,7 @@ dist
dist-ssr
*.local
count.txt
.env
.env*
.nitro
.tanstack
.cta.json

View File

@@ -0,0 +1,585 @@
import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase";
import ReactMarkdown from "react-markdown"
/* ---------- UI Mocks (sin cambios) ---------- */
const Paperclip = (props) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
);
const Dialog = ({ open, onOpenChange, children }) =>
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
const DialogContent = ({ className, children }) =>
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
onClick={(e) => e.stopPropagation()}>{children}</div>;
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
const Button = ({ onClick, disabled, className, variant, children }) => (
<button onClick={onClick} disabled={disabled}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
${variant === "outline"
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
: "bg-blue-600 text-white hover:bg-blue-700"}
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
{children}
</button>
);
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
/* ------------- COMPONENT ------------- */
export default function AIChatModal({ open, onClose, context, onAccept }) {
const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreviews, setAttachedPreviews] = useState([]);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [loadingVectors, setLoadingVectors] = useState(false);
const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
const messagesEndRef = useRef(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
useEffect(scrollToBottom, [messages]);
const normalizeInvokeResponse = (resp) => {
if (!resp) return null;
// cuando invocas funciones, Supabase siempre regresa:
// { data: "...string...", error: null, response: {} }
const raw = resp.data;
if (typeof raw === "string") {
try {
return JSON.parse(raw);
} catch (e) {
console.warn("❗ No se pudo parsear resp.data:", raw);
return null;
}
}
// si ya viene como objeto
if (typeof raw === "object" && raw !== null) return raw;
return null;
};
// Al abrir: reset o crear conversación
useEffect(() => {
console.log(context.cont_conversation);
console.log(context);
if (!open) {
// si ya existe una conversación la eliminamos
if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e));
}
setMessages([]);
setInput("");
setSelectedVectorFile(null);
setAttachedFiles([]);
setAttachedPreviews([]);
setConversationId(null);
return;
}
// inyectar contexto como system message
if (context) {
setMessages([
{
role: "system",
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
}
]);
} else {
setMessages(prev => prev); // no hacer nada si no hay contexto
}
// crear conversación y esperar a que termine antes de permitir enviar
(async () => {
await createConversation();
// tras crear podemos también cargar vector stores
fetchVectorStores();
})();
}, [open]);
// --------- CREATE CONVERSATION (robusto) ----------
const createConversation = async () => {
try {
setCreatingConversation(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// llamada
const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "start" , role:"system", content:context.cont_conversation, }
});
console.log("createConversation -> raw resp:", resp);
// resp puede ser { data: "...json string..." } o { data: { ... } }
let parsed = null;
if (typeof resp?.data === "string") {
try {
parsed = JSON.parse(resp.data);
} catch (e) {
console.warn("No se pudo parsear resp.data como JSON:", e, resp.data);
parsed = null;
}
} else if (typeof resp?.data === "object" && resp.data !== null) {
parsed = resp.data;
} else {
// fallback: quizá la respuesta viene en resp (sin data)
parsed = resp;
}
console.log("createConversation -> parsed payload:", parsed);
// buscar el id en varios lugares (robusto)
const convId =
parsed?.conversationId ||
parsed?.data?.conversationId ||
parsed?.data?.id ||
parsed?.id ||
parsed?.conversation_id ||
parsed?.data?.conversation_id;
if (!convId) {
console.warn("No se encontró conversationId en la respuesta parseada:", parsed);
setCreatingConversation(false);
return;
}
setConversationId(convId);
console.log("🟢 Conversación creada y guardada:", convId);
} catch (err) {
console.error("Error creando conversación:", err);
} finally {
setCreatingConversation(false);
}
};
// --------- DELETE CONVERSATION (robusto) ----------
const deleteConversation = async (convIdParam) => {
try {
const convIdToUse = convIdParam ?? conversationId;
if (!convIdToUse) return;
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// algunas implementations esperan { action: "end", conversationId }, otras { action: "end", id }
const { data, error } = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse }
});
console.log("deleteConversation -> response:", data);
setConversationId(null);
} catch (err) {
console.error("Error eliminando conversación:", err);
}
};
// ---------- CONVERT FILE TO BASE64 ----------
const fileToBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = (e) => reject(e);
reader.onload = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(file);
});
// ---------- SEND MESSAGE (usa conversationId) ----------
const handleConversation = async ({ text }) => {
if (!conversationId) {
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
// si no hay conv, opcionalmente intentar crear una sin que el usuario note
return;
}
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
let filesInput = [];
if (attachedFiles.length > 0) {
for (const file of attachedFiles) {
const base64 = await fileToBase64(file);
filesInput.push({
type: "input_file",
filename: file.name,
file_data: `data:${file.type};base64,${base64}`
});
}
}
if (selectedVectorFile) {
// si el archivo del vector viene sólo con id
filesInput.push({
type: "input_file",
file_id: selectedVectorFile.id
});
}
const payload = {
action: "message",
conversationId,
vectorStoreId: selectedVectorFile?.vector_store_id ?? null,
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
input: [
{
role: "user",
content: [
{ type: "input_text", text },
...filesInput
]
}
]
};
const { data: invokeData, error } = await supabase.functions.invoke(
"modal-conversation",
{
headers: { Authorization: `Bearer ${token}` },
body: payload
}
);
if (error) throw error;
console.log("handleConversation -> RAW invokeData:", invokeData);
const parsed = normalizeInvokeResponse({ data: invokeData });
console.log("handleConversation -> PARSED:", parsed);
// 🔥 EXTRACTOR DEFINITIVO
let assistantText = null;
// 1) directo
if (parsed?.data?.output_text) {
assistantText = parsed.data.output_text;
}
// 2) buscar el message
if (!assistantText && Array.isArray(parsed?.data?.output)) {
const msgBlock = parsed.data.output.find(o => o.type === "message");
if (msgBlock?.content?.[0]?.text) {
assistantText = msgBlock.content[0].text;
}
}
// 3) fallback
assistantText = assistantText || "Sin respuesta del modelo.";
setMessages(prev => [
...prev,
{ role: "assistant", content: cleanAssistantResponse(assistantText) }
]);
setAttachedFiles([]);
setAttachedPreviews([]);
} catch (err) {
console.error("Error en handleConversation:", err);
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
} finally {
setLoading(false);
}
};
// ---------- VECTORES ----------
const fetchVectorStores = async () => {
try {
setLoadingVectors(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
headers: { Authorization: `Bearer ${token}` },
body: { module: "vectorStores", action: "list" }
});
if (error) throw error;
setVectorStores(Array.isArray(data) ? data : (data?.data ?? []));
} catch (err) {
console.error("Error loading vector stores:", err);
setVectorStores([]);
} finally {
setLoadingVectors(false);
}
};
useEffect(() => {
if (open) fetchVectorStores();
}, [open]);
const loadFilesForVector = async (vectorStoreId) => {
try {
setLoadingFiles(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
headers: { Authorization: `Bearer ${token}` },
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
});
if (error) throw error;
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
} catch (err) {
console.error("Error loading vector files:", err);
setVectorFiles([]);
} finally {
setLoadingFiles(false);
}
};
// ---------- UI helpers ----------
const handleAttach = (e) => {
const files = Array.from(e.target.files);
if (!files.length) return;
setAttachedFiles(prev => [...prev, ...files]);
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
};
const handleSelectVectorFile = (file) => {
setSelectedVectorFile(file);
};
// ---------- Send flow ----------
const handleSend = async () => {
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
// esperar si aún se está creando la conversación
if (creatingConversation) {
console.log("Esperando a que se cree la conversación...");
// opcional: podrías mostrar un toast; aquí simplemente retornamos
return;
}
if (!conversationId) {
console.warn("No hay conversationId — intentaremos crear una ahora.");
await createConversation();
if (!conversationId) {
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
return;
}
}
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
setMessages(prev => [...prev, { role: "user", content: userText }]);
setInput("");
await handleConversation({ text: userText });
};
const handleApply = () => {
const last = [...messages].reverse().find(m => m.role === "assistant");
if (last && onAccept) {
onAccept(last.content);
onClose();
}
};
const cleanAssistantResponse = (text) => {
if (!text) return text;
// Frases que quieres eliminar (puedes agregar más)
const patterns = [
/^claro[, ]*/i,
/^por supuesto[, ]*/i,
/^aquí tienes[, ]*/i,
/^con gusto[, ]*/i,
/^hola[, ]*/i,
/^perfecto[, ]*/i,
/^entendido[, ]*/i,
/^muy bien[, ]*/i,
/^ok[, ]*/i,
];
let cleaned = text.trim();
for (const p of patterns) {
cleaned = cleaned.replace(p, "").trim();
}
return cleaned;
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative">
{/* Botón siempre visible */}
<button
onClick={onClose}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"
>
</button>
<DialogHeader>
<DialogTitle>Asistente Inteligente</DialogTitle>
</DialogHeader>
<div className="flex-1 pt-4 min-h-0">
<div className="flex gap-6 h-full min-h-0">
{/* Left: vectors */}
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
<CardContent className="flex flex-col flex-1 p-4">
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
<ScrollArea className="flex-1">
{loadingVectors ? (
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
) : vectorStores.length === 0 ? (
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
) : (
<ul className="space-y-2">
{vectorStores.map((vector) => (
<li key={vector.id}
onClick={() => loadFilesForVector(vector.id)}
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
>
<strong className="truncate">{vector.name || vector.id}</strong>
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
</li>
))}
</ul>
)}
</ScrollArea>
<div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4>
{loadingFiles ? (
<p className="text-sm text-gray-500">Cargando archivos...</p>
) : selectedVectorFile ? (
<div className="text-sm text-gray-700 mb-2">
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
</div>
) : (
<p className="text-sm text-gray-500">Selecciona un archivo del vector</p>
)}
<ul className="space-y-2 max-h-40 overflow-auto mt-2">
{vectorFiles.map((file) => (
<li key={file.id}
onClick={() => handleSelectVectorFile(file)}
className={`p-2 rounded-lg cursor-pointer border ${selectedVectorFile?.id === file.id ? "bg-blue-50 border-blue-300" : "bg-white"}`}
>
<div className="text-sm font-medium">{file.filename}</div>
<div className="text-xs text-gray-400">{file.id}</div>
</li>
))}
</ul>
</div>
<div className="mt-4 flex-shrink-0">
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
</div>
</CardContent>
</Card>
{/* Right: Chat */}
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
<div className="flex-1 flex flex-col min-h-0">
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
{messages.length === 0 ? (
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
) : (
messages.map((m, i) => (
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
<ReactMarkdown>{m.content}</ReactMarkdown>
</div>
))
)}
{loading && (
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{attachedPreviews.length > 0 && (
<ul className="text-xs text-gray-600 mt-2">
{attachedPreviews.map((name, i) => (
<li key={i}>📄 {name}</li>
))}
</ul>
)}
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
<Paperclip className="w-5 h-5" />
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
</label>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Escribe tu pregunta..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
rows={1}
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
style={{ minHeight: "38px" }}
/>
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
</Button>
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
Aplicar mejora
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,371 +0,0 @@
import React, { useEffect, useState } from "react"
import { supabase } from "@/auth/supabase"
import type { PlanTextFields } from "../planes/academic-sections";
// 🔹 SIMULACIÓN DE ICONO LUCIDE-REACT
const Paperclip = (props) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-paperclip">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
);
// 🔹 SIMULACIÓN DE SHADCN/UI
const Dialog = ({ open, onOpenChange, children }) => open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
const DialogContent = ({ className, children }) => <div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`} onClick={(e) => e.stopPropagation()}>{children}</div>;
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
const Button = ({ onClick, disabled, className, variant, children }) => (
<button
onClick={onClick}
disabled={disabled}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
variant === "outline" ? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50" :
"bg-blue-600 text-white hover:bg-blue-700"
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
{children}
</button>
);
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
// ====================================================================
type AIChatModalProps = {
open: boolean
onClose: () => void
edgeFunctionUrl: string
context?: {
section?: string
fieldKey?: keyof PlanTextFields
originalText?: string
}
onAccept?: (newText: string) => void
}
export default function AIChatModal({ open, onClose, edgeFunctionUrl, context, onAccept }: AIChatModalProps) {
const [files, setFiles] = useState<any[]>([])
const [attachedFile, setAttachedFile] = useState<File | null>(null)
const [attachedPreview, setAttachedPreview] = useState<string | null>(null)
const [messages, setMessages] = useState<{ role: string; content: string }[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
// Referencia para desplazar al final del chat
const messagesEndRef = React.useRef<HTMLDivElement>(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
useEffect(scrollToBottom, [messages])
useEffect(() => {
if (!open) {
// 🧹 Limpia mensajes y archivos al cerrar
setMessages([])
setInput("")
setAttachedFile(null)
setAttachedPreview(null)
} else if (context) {
// 🧩 Muestra el contexto inicial al abrir
setMessages([
{
role: "system",
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`,
},
])
}
}, [open, context])
// 🔹 Obtener lista de archivos del Vector Store (Lógica de API original)
useEffect(() => {
if (!open) return
const fetchVectorFiles = async () => {
// Nota: La verificación de Supabase ahora pasa por el mock.
if (typeof supabase === 'undefined' || !supabase.auth) {
console.error("Supabase no está disponible (Simulación). Saltando fetch de archivos.");
return;
}
try {
setLoading(true)
const { data: { session } } = await supabase.auth.getSession()
const token = session?.access_token
// 🟢 TU LÓGICA DE FETCH ORIGINAL
const res = await fetch(`${edgeFunctionUrl}?action=list_files`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (data.files) setFiles(data.files)
else console.warn("No se encontraron archivos en el vector store.")
} catch (err) {
console.error("Error al cargar archivos del vector store:", err)
} finally {
setLoading(false)
}
}
fetchVectorFiles()
}, [open, edgeFunctionUrl])
// 📎 Adjuntar archivo
const handleAttach = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setAttachedFile(file)
setAttachedPreview(file.name)
}
// 🚀 Enviar prompt o archivo al Edge Function (Lógica de API original)
const handleSend = async () => {
if (!input.trim() && !attachedFile) return
// 🧩 Crear el mensaje visible del usuario
let userMessageContent = input.trim()
if (attachedFile) {
userMessageContent += (userMessageContent ? " | " : "") + `Adjunto: ${attachedFile.name}`
}
if (!userMessageContent && attachedFile) {
userMessageContent = `Consulta de archivo: ${attachedFile.name}`
}
setMessages((prev) => [...prev, { role: "user", content: userMessageContent }])
setInput("")
setLoading(true)
try {
if (typeof supabase === "undefined" || !supabase.functions) {
throw new Error("Supabase no está disponible o no soporta Edge Functions.")
}
const formData = new FormData()
// 🧠 Construimos un prompt limpio con el contexto del campo
const contextText = context?.originalText || "Sin texto original"
const section = context?.section ? `Sección: ${context.section}` : ""
const field = context?.fieldKey ? `Campo: ${context.fieldKey}` : ""
const fullPrompt = `
${section}
${field}
Texto original:
${contextText}
Solicitud del usuario:
${input}
Responde con una versión mejorada del texto, sin agregar frases como “Aquí tienes” ni explicaciones.
`.trim()
formData.append("prompt", fullPrompt)
if (attachedFile) formData.append("file", attachedFile)
// 🟢 Llamada a la Edge Function
const { data, error } = await supabase.functions.invoke("simple-chat", {
body: formData,
})
if (error) throw error
setMessages((prev) => [
...prev,
{ role: "assistant", content: data?.text || "Sin respuesta del modelo." },
])
} catch (err: any) {
console.error("Error al enviar prompt:", err)
setMessages((prev) => [
...prev,
{ role: "assistant", content: "Ocurrió un error al conectar con la API." },
])
} finally {
setLoading(false)
setAttachedFile(null)
setAttachedPreview(null)
}
}
return (
    <Dialog open={open} onOpenChange={onClose}>
      {/* DialogContent ya define el tamaño h-[85vh] y es flex-col */}
      <DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col">
       
{/* Encabezado fijo (flex-shrink-0) */}
        <DialogHeader>
          <DialogTitle className="text-lg font-semibold">Asistente Inteligente</DialogTitle>
        </DialogHeader>
{/* CONTENEDOR PRINCIPAL QUE AHORA GESTIONA EL SCROLL DEL CONTENIDO */}
{/* flex-1: toma el espacio restante. overflow-y-auto: permite scroll si el contenido se desborda */}
        <div className="flex-1 overflow-y-auto pt-4">
{/* Contenido que originalmente estaba justo debajo del header */}
<div className="flex gap-6 min-h-full">
          {/* 📂 Archivos del Vector Store */}
          <Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
            {/* Se mantiene flex-1 en CardContent para el ScrollArea */}
            <CardContent className="flex flex-col flex-1 p-4">
              <h3 className="font-semibold text-sm mb-3">Archivos del Vector Store</h3>
              {/* ScrollArea toma el espacio disponible (flex-1) */}
              <ScrollArea className="flex-1">
                {files.length === 0 ? (
                  <p className="text-gray-500 text-sm text-center mt-10">
                    {loading ? "Cargando archivos..." : "No hay archivos cargados."}
                  </p>
                ) : (
                  <ul className="space-y-2">
                    {files.map((file) => (
                      <li
                        key={file.id}
                        className="border border-gray-200 bg-white rounded-lg p-2 text-sm shadow-sm hover:shadow-lg transition-shadow cursor-pointer"
                      >
                        <strong className="block text-gray-700 truncate">{file.name}</strong>
                        <p className="text-xs text-gray-400 truncate">{file.path}</p>
                      </li>
                    ))}
                  </ul>
                )}
              </ScrollArea>
              <div className="mt-4 flex-shrink-0">
                <Button variant="outline" className="w-full">
                  Subir archivo
                </Button>
              </div>
            </CardContent>
          </Card>
          {/* 💬 Chat con GPT */}
          <Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
            <CardContent className="flex flex-col flex-1 p-4">
              <h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
              {/* Mensajes - EL CONTENEDOR QUE DEBE HACER SCROLL */}
               <div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
                {messages.length === 0 ? (
                  <p className="text-gray-400 text-sm text-center mt-10">
                    Inicia una conversación...
                  </p>
                ) : (
                  messages.map((m, i) => (
                    <div
                      key={i}
                      className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${
                        m.role === "user"
                          ? "bg-blue-50 text-blue-800 ml-auto"
                          : "bg-white text-gray-800 mr-auto border border-gray-200"
                      }`}
                    >
                      <strong className="font-bold">{m.role === "user" ? "Tú:" : "IA:"}</strong>{" "}
                      {m.content}
                    </div>
                  ))
                )}
                {loading && (
                  <div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
                    <svg
                      className="animate-spin h-4 w-4 text-blue-500"
                      xmlns="http://www.w3.org/2000/svg"
                      fill="none"
                      viewBox="0 0 24 24"
                    >
                      <circle
                        className="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        strokeWidth="4"
                      ></circle>
                      <path
                        className="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2
                        5.291A7.962 7.962 0 014 12H0c0
                        3.042 1.135 5.824 3 7.938l3-2.647z"
                      ></path>
                    </svg>
                    <span className="text-sm text-gray-600">La IA está respondiendo...</span>
                  </div>
                )}
                <div ref={messagesEndRef} />
              </div>
              {/* Archivo adjunto - flex-shrink-0 */}
              {attachedPreview && (
                <div className="flex items-center justify-between mt-2 p-3 border border-gray-300 rounded-xl text-sm bg-gray-100 shadow-inner flex-shrink-0">
                  <span className="truncate flex items-center gap-2 text-gray-700">
                    <Paperclip className="w-4 h-4 text-blue-500" />
                    {attachedPreview}
                  </span>
                  <Button
                    variant="ghost"
                    size="sm"
                    className="text-red-500 hover:bg-gray-200"
                    onClick={() => {
                      setAttachedFile(null)
                      setAttachedPreview(null)
                      setInput("")
                    }}
                  >
                    Quitar
                  </Button>
                </div>
              )}
              {/* Entrada de texto y enviar - flex-shrink-0 */}
              <div className="flex gap-2 mt-4 items-end flex-shrink-0">
                <label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
                  <Paperclip className="w-5 h-5" />
                  <input type="file" className="hidden" onChange={handleAttach} />
                </label>
                <textarea
                  value={input}
                  onChange={(e) => setInput(e.target.value)}
                  placeholder="Escribe tu pregunta..."
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && !e.shiftKey) {
                      e.preventDefault()
                      handleSend()
                    }
                  }}
                  rows={1}
                  className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
                  style={{ minHeight: "38px" }}
                />
                <Button
                  onClick={handleSend}
                  disabled={loading || (!input.trim() && !attachedFile)}
                  className="shadow-md"
                >
                  {loading ? "Enviando..." : "Enviar"}
                </Button>
<Button
onClick={() => {
const lastMessage = messages[messages.length - 1]
if (onAccept && lastMessage?.role === "assistant") {
onAccept(lastMessage.content)
onClose()
}
}}
disabled={!messages.some((m) => m.role === "assistant")}
className="shadow-md"
>
Aplicar mejora
</Button>
              </div>
            </CardContent>
          </Card>
        </div>
</div>
      </DialogContent>
    </Dialog>
  )
}

View File

@@ -21,6 +21,7 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
unit: "mm",
format: "letter",
})
console.log(plan);
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
@@ -229,7 +230,7 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
doc.setFontSize(18)
// Manejamos la conversión a string si es necesario
const mainTitle = (plan["titulo"] !== null && plan["titulo"] !== undefined ? String(plan["titulo"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
const mainTitle = (plan["nombre"] !== null && plan["nombre"] !== undefined ? String(plan["nombre"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
cursorY += mainTitleLines.length * 8

View File

@@ -8,7 +8,8 @@ import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { toast } from "sonner"
import ReactMarkdown from 'react-markdown'
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
import AIChatModal from "../ai/AIChatModal"
// @ts-ignore
import AIChatModal from "../ai/AIChatModal"
/* =====================================================
@@ -306,14 +307,18 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
updateField.mutate({ key, value })
}}
/>
<AIChatModal
open={openModalIa}
onClose={() => setopenModalIa(false)}
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
context={{
section: iaContext?.title,
fieldKey: iaContext?.key,
originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
No uses introducciones, despedidas ni texto de relleno.
Entrega solo el contenido útil.`,
}}
onAccept={(newText: string) => {
if (iaContext) {

View File

@@ -1,3 +1,4 @@
// dummy test
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'

View File

@@ -1,5 +1,5 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",