8 Commits

5 changed files with 1096 additions and 474 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,372 +1,582 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase"; import { supabase } from "@/auth/supabase";
import ReactMarkdown from "react-markdown"
// ---------------- UI MOCKS ---------------- // /* ---------- UI Mocks (sin cambios) ---------- */
// Puedes reemplazarlos por tus propios componentes UI
const Paperclip = (props) => ( 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"> <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"/> <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> </svg>
); );
const Dialog = ({ open, onOpenChange, children }) => 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; 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 }) => 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()}> <div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
{children} onClick={(e) => e.stopPropagation()}>{children}</div>;
</div>;
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{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 DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
const Button = ({ onClick, disabled, className, variant, children }) => ( const Button = ({ onClick, disabled, className, variant, children }) => (
<button <button onClick={onClick} disabled={disabled}
onClick={onClick}
disabled={disabled}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors 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"} ${variant === "outline"
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`} ? "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} {children}
</button> </button>
); );
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>; 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 CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>; const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
// ---------------- COMPONENTE ---------------- // /* ------------- COMPONENT ------------- */
export default function AIChatModal({ open, onClose, context, onAccept }) { export default function AIChatModal({ open, onClose, context, onAccept }) {
const [vectorStores, setVectorStores] = useState([]); const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]); const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
const [attachedFile, setAttachedFile] = useState(null); const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreview, setAttachedPreview] = useState(null); const [attachedPreviews, setAttachedPreviews] = useState([]);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false); const [loadingFiles, setLoadingFiles] = useState(false);
const [selectedVector, setSelectedVector] = useState(null); const [loadingVectors, setLoadingVectors] = useState(false);
const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
useEffect(scrollToBottom, [messages]); useEffect(scrollToBottom, [messages]);
// ------------------------------------ const normalizeInvokeResponse = (resp) => {
// Reset al abrir o cerrar modal 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(() => { useEffect(() => {
console.log(context.cont_conversation);
console.log(context);
if (!open) { if (!open) {
// si ya existe una conversación la eliminamos
if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e));
}
setMessages([]); setMessages([]);
setInput(""); setInput("");
setAttachedFile(null); setSelectedVectorFile(null);
setAttachedPreview(null); setAttachedFiles([]);
setVectorStores([]); setAttachedPreviews([]);
setVectorFiles([]); setConversationId(null);
setSelectedVector(null);
return; return;
} }
// inyectar contexto como system message
if (context) { if (context) {
setMessages([ setMessages([
{ {
role: "system", role: "system",
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`, content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
}, }
]); ]);
} else {
setMessages(prev => prev); // no hacer nada si no hay contexto
} }
}, [open, context]);
// ------------------------------------ // crear conversación y esperar a que termine antes de permitir enviar
// Cargar vector stores (async () => {
// ------------------------------------ await createConversation();
useEffect(() => { // tras crear podemos también cargar vector stores
if (!open) return; fetchVectorStores();
})();
const fetchVectorStores = async () => {
try {
setLoading(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 : []);
} catch (err) {
console.error("Error al obtener vector stores:", err);
setVectorStores([]);
} finally {
setLoading(false);
}
};
fetchVectorStores();
}, [open]); }, [open]);
// ------------------------------------ // --------- CREATE CONVERSATION (robusto) ----------
// Cargar archivos del vector seleccionado 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) => { const loadFilesForVector = async (vectorStoreId) => {
try { try {
setLoadingFiles(true); setLoadingFiles(true);
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
"files-and-vector-stores-api", headers: { Authorization: `Bearer ${token}` },
{ body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
headers: { Authorization: `Bearer ${token}` }, });
body: {
module: "vectorStoreFiles",
action: "list",
params: { vector_store_id: vectorStoreId },
},
}
);
if (error) throw error; if (error) throw error;
setVectorFiles(Array.isArray(data) ? data : []); setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
} catch (err) { } catch (err) {
console.error("Error al obtener archivos del vector store:", err); console.error("Error loading vector files:", err);
setVectorFiles([]); setVectorFiles([]);
} finally { } finally {
setLoadingFiles(false); setLoadingFiles(false);
} }
}; };
// ------------------------------------ // ---------- UI helpers ----------
// Adjuntar archivo const handleAttach = (e) => {
// ------------------------------------ const files = Array.from(e.target.files);
const handleAttach = (e) => { if (!files.length) return;
const file = e.target.files?.[0];
if (!file) return; setAttachedFiles(prev => [...prev, ...files]);
setAttachedFile(file); setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
setAttachedPreview(file.name); };
const handleSelectVectorFile = (file) => {
setSelectedVectorFile(file);
}; };
// ------------------------------------ // ---------- Send flow ----------
// handleSend — versión final para Supabase Edge Function
// ------------------------------------
const handleSend = async () => { const handleSend = async () => {
if (!input.trim() && !attachedFile) return; if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
// Construir texto del mensaje del usuario // esperar si aún se está creando la conversación
const userMessage = input.trim() if (creatingConversation) {
? input.trim() console.log("Esperando a que se cree la conversación...");
: attachedFile // opcional: podrías mostrar un toast; aquí simplemente retornamos
? `Consulta sobre archivo: ${attachedFile.name}` return;
: "";
// Agregar mensaje al chat
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
setInput("");
setLoading(true);
try {
const formData = new FormData();
const fullPrompt = `
${context?.section ? `Sección: ${context.section}` : ""}
${context?.fieldKey ? `Campo: ${context.fieldKey}` : ""}
Texto original:
${context?.originalText || "Sin texto original"}
Solicitud del usuario:
${userMessage}
Responde con una versión mejorada en texto directo, sin explicaciones.
`.trim();
formData.append("prompt", fullPrompt);
if (attachedFile) formData.append("file", attachedFile);
const { data, error } = await supabase.functions.invoke(
"simple-chat",
{ body: formData }
);
if (error) throw error;
// Respuesta de la IA
setMessages(prev => [
...prev,
{ role: "assistant", content: data?.text || "Sin respuesta del modelo." }
]);
} catch (err) {
console.error("Error enviando mensaje:", err);
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al conectar con la IA." }]);
} }
setLoading(false); if (!conversationId) {
setAttachedFile(null); console.warn("No hay conversationId — intentaremos crear una ahora.");
setAttachedPreview(null); 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 = () => {
// UI 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 ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent> <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> <DialogHeader>
<DialogTitle>Asistente Inteligente</DialogTitle> <DialogTitle>Asistente Inteligente</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto pt-4"> <div className="flex-1 pt-4 min-h-0">
<div className="flex gap-6 min-h-full"> <div className="flex gap-6 h-full min-h-0">
{/* LEFT: VECTOR STORES */} {/* Left: vectors */}
<Card className="w-1/3 max-w-sm flex flex-col bg-muted/20 border border-gray-200"> <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"> <CardContent className="flex flex-col flex-1 p-4">
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3> <h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{loading ? ( {loadingVectors ? (
<p className="text-center text-gray-400">Cargando...</p> <p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
) : vectorStores.length === 0 ? ( ) : vectorStores.length === 0 ? (
<p className="text-center text-gray-400">No hay vector stores</p> <p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{vectorStores.map(store => ( {vectorStores.map((vector) => (
<li <li key={vector.id}
key={store.id} onClick={() => loadFilesForVector(vector.id)}
onClick={() => { className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
setSelectedVector(store);
loadFilesForVector(store.id);
}}
className={`border p-2 rounded-lg cursor-pointer
${selectedVector?.id === store.id ? "bg-blue-100" : "bg-white"}`}
> >
<strong>{store.name || store.id}</strong> <strong className="truncate">{vector.name || vector.id}</strong>
<p className="text-xs text-gray-500 truncate">{store.description}</p> <p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</ScrollArea> </ScrollArea>
<h4 className="mt-4 font-semibold text-sm">Archivos</h4> <div className="mt-4">
<ScrollArea className="mt-2 max-h-40"> <h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4>
{loadingFiles ? ( {loadingFiles ? (
<p className="text-gray-400 text-sm">Cargando archivos...</p> <p className="text-sm text-gray-500">Cargando archivos...</p>
) : vectorFiles.length === 0 ? ( ) : selectedVectorFile ? (
<p className="text-gray-400 text-sm">No hay archivos</p> <div className="text-sm text-gray-700 mb-2">
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
</div>
) : ( ) : (
<ul className="space-y-1"> <p className="text-sm text-gray-500">Selecciona un archivo del vector</p>
{vectorFiles.map(f => (
<li key={f.id} className="border bg-white p-2 rounded-lg text-sm">
{f.id}
</li>
))}
</ul>
)} )}
</ScrollArea>
<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> </CardContent>
</Card> </Card>
{/* RIGHT: CHAT */} {/* Right: Chat */}
<Card className="flex-1 flex flex-col border border-gray-200"> <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"> <CardContent className="flex flex-col flex-1 p-4 min-h-0">
<h3 className="font-semibold text-sm mb-3">Chat</h3>
<div className="flex-1 overflow-y-auto border p-3 rounded-lg bg-gray-50 space-y-3"> <h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
{messages.length === 0 ? (
<p className="text-gray-400 text-center text-sm">Inicia la conversación</p> <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((msg, idx) => ( messages.map((m, i) => (
<div <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"}`}>
key={idx} <strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
className={`p-3 rounded-xl max-w-[85%] shadow-sm whitespace-pre-wrap <ReactMarkdown>{m.content}</ReactMarkdown>
${msg.role === "user"
? "bg-blue-50 text-blue-800 ml-auto"
: "bg-white text-gray-700 border border-gray-200 mr-auto"
}`}
>
<strong>{msg.role === "user" ? "Tú:" : "IA:"}</strong>
<p>{msg.content}</p>
</div> </div>
)) ))
)} )}
{loading && ( {loading && (
<div className="p-3 bg-white border rounded-xl max-w-fit"> <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">
<span className="text-gray-600 text-sm">La IA está respondiendo...</span> <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>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div>
{attachedPreview && ( {attachedPreviews.length > 0 && (
<div className="flex items-center mt-2 p-2 border rounded-lg bg-gray-100 text-sm"> <ul className="text-xs text-gray-600 mt-2">
<Paperclip className="w-4 h-4 text-blue-500" /> {attachedPreviews.map((name, i) => (
<span className="ml-2">{attachedPreview}</span> <li key={i}>📄 {name}</li>
<button ))}
onClick={() => { setAttachedFile(null); setAttachedPreview(null); }} </ul>
className="ml-auto text-red-500 text-xs"
>
Quitar
</button>
</div>
)} )}
{/* Input */} <div className="flex gap-2 mt-4 items-end flex-shrink-0">
<div className="flex gap-2 mt-4"> <label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
<label className="cursor-pointer text-gray-600 hover:text-blue-600">
<Paperclip className="w-5 h-5" /> <Paperclip className="w-5 h-5" />
<input type="file" className="hidden" onChange={handleAttach} /> <input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
</label> </label>
<textarea <textarea
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Escribe tu pregunta..." placeholder="Escribe tu pregunta..."
className="flex-1 border rounded-lg p-3 text-sm"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); 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 <Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
onClick={handleSend} {creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
disabled={loading || (!input.trim() && !attachedFile)}
>
Enviar
</Button> </Button>
<Button <Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
onClick={() => { Aplicar mejora
const last = messages[messages.length - 1];
if (last?.role === "assistant") {
onAccept(last.content);
onClose();
}
}}
disabled={!messages.some(m => m.role === "assistant")}
>
Aplicar
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -311,11 +311,14 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
<AIChatModal <AIChatModal
open={openModalIa} open={openModalIa}
onClose={() => setopenModalIa(false)} onClose={() => setopenModalIa(false)}
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
context={{ context={{
section: iaContext?.title, section: iaContext?.title,
fieldKey: iaContext?.key, fieldKey: iaContext?.key,
originalText: iaContext?.content, 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) => { onAccept={(newText: string) => {
if (iaContext) { if (iaContext) {

View File

@@ -0,0 +1,39 @@
import { supabase } from "@/auth/supabase"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
type EdgeArgs = {
module: EdgeModule
action: string
params?: Record<string, any>
}
export async function callFilesAndVectorStoresApi<T = unknown>(
args: EdgeArgs,
): Promise<T> {
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: args,
},
)
if (error) {
console.error(error)
throw error
}
const payload = data ?? {}
if (payload.error) {
const msg =
typeof payload.error === "string"
? payload.error
: payload.error.message ?? "Error en la función Edge"
throw new Error(msg)
}
// Soporta tanto `{ data: [...] }` como `[...]`
const result = payload.data !== undefined ? payload.data : payload
return result as T
}

View File

@@ -1,6 +1,6 @@
// routes/_authenticated/archivos.tsx // routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router" import { createFileRoute, useRouter } from "@tanstack/react-router"
import { use, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -9,92 +9,202 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" import {
import { DetailDialog } from "@/components/archivos/DetailDialog" Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { RefRow } from "@/types/RefRow" type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
import { uuid } from "zod"
interface VectorStore {
id: string
object: "vector_store"
created_at: number
name: string | null
description?: string | null
usage_bytes: number
file_counts: {
in_progress: number
completed: number
failed: number
cancelled: number
total: number
}
status: string
last_active_at?: number | null
metadata?: Record<string, any> | null
}
interface VectorStoreFile {
id: string
object: string
created_at: number
vector_store_id: string
status: string
usage_bytes: number
last_error?: { code: string; message: string } | null
}
interface VectorStoreFileMeta {
id: string
user_id: string | null
vector_store_id: string
openai_file_id: string
label: string | null
tags: string[] | null
created_at: string
}
type EdgeArgs = {
module: EdgeModule
action: string
params?: Record<string, any>
}
async function callFilesAndVectorStoresApi<T = unknown>(
args: EdgeArgs,
): Promise<T> {
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: args,
},
)
if (error) {
console.error(error)
throw error
}
const payload = data ?? {}
if (payload.error) {
const msg =
typeof payload.error === "string"
? payload.error
: payload.error.message ?? "Error en la función Edge"
throw new Error(msg)
}
const result = payload.data !== undefined ? payload.data : payload
return result as T
}
export const Route = createFileRoute("/_authenticated/archivos")({ export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async () => {
const { data, error } = await supabase const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
.from("documentos") module: "vectorStores",
.select("*") action: "list",
.order("fecha_subida", { ascending: false }) params: {},
.limit(200) })
if (error) throw error return stores ?? []
return (data ?? []) as RefRow[]
}, },
}) })
function chipTint(ok?: boolean | null) { /* ====== UI helpers ====== */
return ok
? "bg-emerald-50 text-emerald-700 border-emerald-200" function StatusBadge({ status }: { status: string }) {
: "bg-amber-50 text-amber-800 border-amber-200" const label =
status === "completed"
? "Completado"
: status === "in_progress"
? "Procesando"
: status
const base = "text-[10px] px-2 py-0.5 rounded-full border"
if (status === "completed") {
return (
<span
className={`${base} bg-emerald-50 text-emerald-700 border-emerald-200`}
>
{label}
</span>
)
}
if (status === "in_progress") {
return (
<span
className={`${base} bg-amber-50 text-amber-800 border-amber-200`}
>
{label}
</span>
)
}
return (
<span className={`${base} bg-neutral-50 text-neutral-700 border-neutral-200`}>
{label}
</span>
)
} }
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
function RouteComponent() { function RouteComponent() {
const router = useRouter() const router = useRouter()
const rows = Route.useLoaderData() as RefRow[] const vectorStores = Route.useLoaderData() as VectorStore[]
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos") const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos") const [selected, setSelected] = useState<VectorStore | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [viewing, setViewing] = useState<RefRow | null>(null) const [createOpen, setCreateOpen] = useState(false)
const [uploadOpen, setUploadOpen] = useState(false) const [deletingId, setDeletingId] = useState<string | null>(null)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const t = q.trim().toLowerCase() const term = q.trim().toLowerCase()
return rows.filter((r) => { return vectorStores.filter((vs) => {
if (estado === "proc" && !r.procesado) return false if (statusFilter !== "all" && vs.status !== statusFilter) return false
if (estado === "pend" && r.procesado) return false if (!term) return true
if (scope === "internos" && !r.interno) return false return (
if (scope === "externos" && r.interno) return false (vs.name ?? "").toLowerCase().includes(term) ||
(vs.description ?? "").toLowerCase().includes(term)
if (!t) return true )
const hay =
[r.titulo_archivo, r.descripcion, r.fuente_autoridad, r.tipo_contenido, ...(r.tags ?? [])]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(t))
return hay
}) })
}, [rows, q, estado, scope]) }, [vectorStores, q, statusFilter])
async function remove(id: string) { function openDetails(vs: VectorStore) {
if (!confirm("¿Eliminar archivo de referencia?")) return setSelected(vs)
const { error } = await supabase setDialogOpen(true)
.from("documentos") }
.delete()
.eq("documentos_id", id)
if (error) return alert(error.message)
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
try { try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, { await callFilesAndVectorStoresApi({
method: "DELETE", module: "vectorStores",
headers: { "Content-Type": "application/json" }, action: "delete",
body: JSON.stringify({ documentos_id: id }), params: { id },
}) })
if (!res.ok) { await supabase
throw new Error("Se falló al eliminar el documento") .from("vector_store_files_meta")
} .delete()
} catch (err) { .eq("vector_store_id", id)
console.error("Error al eliminar el documento:", err)
}
router.invalidate() router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
}
} }
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Card> <Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono">Archivos de referencia</CardTitle> <CardTitle className="font-mono">Repositorios de archivos</CardTitle>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <div className="relative w-full sm:w-80">
@@ -102,240 +212,499 @@ function RouteComponent() {
<Input <Input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por título, etiqueta, fuente…" placeholder="Buscar por nombre o descripción…"
className="pl-8" className="pl-8"
/> />
</div> </div>
<Select value={estado} onValueChange={(v: any) => setEstado(v)}> <Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<SelectTrigger className="sm:w-[160px]"> <SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Estado" /> <SelectValue placeholder="Estado" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="todos">Todos</SelectItem> <SelectItem value="all">Todos</SelectItem>
<SelectItem value="proc">Procesados</SelectItem> <SelectItem value="completed">Completados</SelectItem>
<SelectItem value="pend">Pendientes</SelectItem> <SelectItem value="in_progress">En proceso</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={scope} onValueChange={(v: any) => setScope(v)}> <Button onClick={() => setCreateOpen(true)}>
<SelectTrigger className="sm:w-[160px]"> <Icons.FolderPlus className="w-4 h-4 mr-2" />
<SelectValue placeholder="Ámbito" /> Nuevo repositorio
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="internos">Internos</SelectItem>
<SelectItem value="externos">Externos</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setUploadOpen(true)}>
<Icons.Upload className="w-4 h-4 mr-2" /> Nuevo
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {filtered.length ? (
{filtered.map((r) => ( <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<article {filtered.map((vs) => (
key={r.documentos_id} <article
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3" key={vs.id}
> className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
<header className="min-w-0"> >
<div className="flex items-center justify-between gap-2"> <header className="min-w-0 space-y-1">
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3> <div className="flex items-center justify-between gap-2">
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}> <h3 className="font-semibold truncate">
{r.procesado ? "Procesado" : "Pendiente"} {vs.name || "(Sin nombre)"}
</span> </h3>
<StatusBadge status={vs.status} />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<Badge variant="outline">
Archivos: {vs.file_counts?.completed ?? 0}
</Badge>
{typeof vs.usage_bytes === "number" && (
<span>
{(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB
</span>
)}
{vs.last_active_at && (
<span className="inline-flex items-center gap-1">
<Icons.Clock3 className="w-3 h-3" />
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
</span>
)}
</div>
</header>
{vs.description && (
<p className="text-sm text-neutral-700 line-clamp-3">
{vs.description}
</p>
)}
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={() => openDetails(vs)}>
<Icons.Eye className="w-4 h-4 mr-1" /> Abrir
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(vs.id)}
disabled={deletingId === vs.id}
>
<Icons.Trash2 className="w-4 h-4 mr-1" />
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
</Button>
</div> </div>
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2"> </article>
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>} ))}
{r.interno != null && ( </div>
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge> ) : (
)} <div className="text-center text-sm text-neutral-500 py-10">
{r.fecha_subida && ( No hay repositorios todavía. Crea uno nuevo para empezar 🚀
<span className="inline-flex items-center gap-1"> </div>
<Icons.CalendarClock className="w-3 h-3" />
{new Date(r.fecha_subida).toLocaleDateString()}
</span>
)}
</div>
</header>
{r.descripcion && (
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
)}
{/* Tags
{r.tags && r.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{r.tags.map((t, i) => (
<span key={i} className="text-[10px] px-2 py-0.5 rounded-full border bg-white/60">
#{t}
</span>
))}
</div>
)} */}
<div className="mt-auto flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
</Button>
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
</Button>
</div>
</article>
))}
</div>
{!filtered.length && (
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Detalle */} <CreateVectorStoreDialog
<DetailDialog row={viewing} onClose={() => setViewing(null)} /> open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
{/* Subida */} <VectorStoreDialog
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} /> store={selected}
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => router.invalidate()}
/>
</div> </div>
) )
} }
/* ========= Subida ========= */ /* ====== Crear repositorio ====== */
function UploadDialog({
open, onOpenChange, onDone,
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
const supabaseAuth = useSupabaseAuth()
const [file, setFile] = useState<File | null>(null)
const [instrucciones, setInstrucciones] = useState("")
const [tags, setTags] = useState("")
const [interno, setInterno] = useState(true)
const [fuente, setFuente] = useState("")
const [subiendo, setSubiendo] = useState(false)
async function toBase64(f: File): Promise<string> { function CreateVectorStoreDialog({
const buf = await f.arrayBuffer() open,
const bytes = new Uint8Array(buf) onOpenChange,
let binary = "" onCreated,
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]) }: {
return btoa(binary) open: boolean
} onOpenChange: (open: boolean) => void
onCreated: () => void
}) {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [creating, setCreating] = useState(false)
async function upload() { async function handleCreate() {
if (!file) { alert("Selecciona un archivo"); return } if (!name.trim()) {
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return } alert("Escribe un nombre para el repositorio")
return
setSubiendo(true) }
setCreating(true)
try { try {
const fileBase64 = await toBase64(file) await callFilesAndVectorStoresApi<VectorStore>({
// Enviamos al motor (inserta en la tabla si insert=true) module: "vectorStores",
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, { action: "create",
method: "POST", params: { name: name.trim(), description: description.trim() || undefined },
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: instrucciones,
fileBase64,
insert: true,
uuid: supabaseAuth.user?.id ?? null,
}),
}) })
if (!res.ok) {
const txt = await res.text()
throw new Error(txt || "Error al subir")
}
// Ajustes extra (tags, interno, fuente) si el motor no los llenó
// Intentamos leer el id que regrese el servicio; si no, solo invalidamos.
let createdId: string | null = null
try {
const payload = await res.json()
createdId =
payload?.documentos_id ||
payload?.id ||
payload?.data?.documentos_id ||
null
} catch { /* noop */ }
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
await supabase
.from("documentos")
.update({
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
fuente_autoridad: fuente.trim() || undefined,
interno,
})
.eq("documentos_id", createdId)
}
onOpenChange(false) onOpenChange(false)
onDone() setName("")
} catch (e: any) { setDescription("")
alert(e?.message ?? "Error al subir el documento") onCreated()
} catch (err: any) {
alert(err?.message ?? "Error al crear el repositorio")
} finally { } finally {
setSubiendo(false) setCreating(false)
} }
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle> <DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
<DialogDescription> <DialogDescription>
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como Crea un Vector Store para agrupar archivos relacionados.
<em> procesado </em> cuando termine el flujo.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Archivo</Label> <Label>Nombre</Label>
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} /> <Input
{file && ( value={name}
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div> onChange={(e) => setName(e.target.value)}
)} placeholder="Planeación curricular, Entrevistas…"
</div>
<div className="space-y-1">
<Label>Instrucciones</Label>
<Textarea
value={instrucciones}
onChange={(e) => setInstrucciones(e.target.value)}
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
className="min-h-[120px]"
/> />
</div> </div>
<div className="grid sm:grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Tags (separados por coma)</Label>
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
</div>
<div className="space-y-1">
<Label>Fuente de autoridad</Label>
<Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
</div>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Ámbito</Label> <Label>Descripción (opcional)</Label>
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}> <Textarea
<SelectTrigger><SelectValue /></SelectTrigger> value={description}
<SelectContent> onChange={(e) => setDescription(e.target.value)}
<SelectItem value="true">Interno</SelectItem> placeholder="Breve descripción del contenido de este repositorio."
<SelectItem value="false">Externo</SelectItem> className="min-h-[80px]"
</SelectContent> />
</Select>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}> Cancelar
{subiendo ? "Subiendo…" : "Subir"} </Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? "Creando…" : "Crear repositorio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* ====== Detalle de un repositorio: archivos + subida ====== */
type FileRow = {
file: VectorStoreFile
meta: VectorStoreFileMeta | null
}
function VectorStoreDialog({
store,
open,
onOpenChange,
onUpdated,
}: {
store: VectorStore | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: () => void
}) {
const supabaseAuth = useSupabaseAuth()
const [files, setFiles] = useState<FileRow[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [label, setLabel] = useState("")
useEffect(() => {
if (!open || !store) return
void refreshFiles()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, store?.id])
async function refreshFiles() {
if (!store) return
setLoading(true)
setError(null)
try {
const vectorFiles = await callFilesAndVectorStoresApi<VectorStoreFile[]>({
module: "vectorStoreFiles",
action: "list",
params: { vector_store_id: store.id },
})
const { data: metaRows, error: metaError } = await supabase
.from("vector_store_files_meta")
.select("*")
.eq("vector_store_id", store.id)
.order("created_at", { ascending: false })
if (metaError) throw metaError
const meta = (metaRows ?? []) as VectorStoreFileMeta[]
const merged: FileRow[] = (vectorFiles ?? []).map((vf) => ({
file: vf,
meta: meta.find((m) => m.openai_file_id === vf.id) ?? null,
}))
setFiles(merged)
} catch (err: any) {
console.error(err)
setError(err?.message ?? "No se pudieron cargar los archivos")
} finally {
setLoading(false)
}
}
async function toBase64(f: File): Promise<string> {
const buf = await f.arrayBuffer()
const bytes = new Uint8Array(buf)
let binary = ""
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
async function handleUpload() {
if (!store || !file) {
alert("Selecciona un archivo")
return
}
setUploading(true)
try {
const fileBase64 = await toBase64(file)
// 1) Subir archivo a OpenAI vía Edge (módulo files)
const uploaded: any = await callFilesAndVectorStoresApi<any>({
module: "files",
action: "upload",
params: {
fileBase64,
filename: file.name,
mimeType: file.type,
},
})
const openaiFileId: string =
uploaded?.id ?? uploaded?.file?.id ?? uploaded?.data?.id
if (!openaiFileId) {
throw new Error("La Edge Function no devolvió el id del archivo")
}
// 2) Mapear archivo al Vector Store
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "create",
params: {
vector_store_id: store.id,
file_id: openaiFileId,
},
})
// 3) Guardar metadata en Supabase
const { error: insertError } = await supabase
.from("vector_store_files_meta")
.insert({
user_id: supabaseAuth.user?.id ?? null,
vector_store_id: store.id,
openai_file_id: openaiFileId,
label: label.trim() || file.name,
})
if (insertError) throw insertError
setFile(null)
setLabel("")
await refreshFiles()
onUpdated()
} catch (err: any) {
console.error(err)
alert(err?.message ?? "Error al subir el archivo")
} finally {
setUploading(false)
}
}
async function handleDeleteFile(fileId: string) {
if (!store) return
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return
setRefreshing(true)
try {
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "delete",
params: {
vector_store_id: store.id,
file_id: fileId,
},
})
// Opcional: eliminar también el archivo global de OpenAI
await callFilesAndVectorStoresApi<any>({
module: "files",
action: "delete",
params: { id: fileId },
})
await supabase
.from("vector_store_files_meta")
.delete()
.eq("openai_file_id", fileId)
await refreshFiles()
onUpdated()
} catch (err: any) {
console.error(err)
alert(err?.message ?? "Error al eliminar el archivo")
} finally {
setRefreshing(false)
}
}
if (!store) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.Folder className="h-4 w-4" />
{store.name || "(Sin nombre)"}
</DialogTitle>
<DialogDescription>
Gestiona los archivos asociados a este repositorio (Vector Store).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
<StatusBadge status={store.status} />
<Badge variant="outline">
Archivos completados: {store.file_counts?.completed ?? 0}
</Badge>
<Badge variant="outline">
Total archivos: {store.file_counts?.total ?? 0}
</Badge>
{typeof store.usage_bytes === "number" && (
<span>{(store.usage_bytes / 1024 / 1024).toFixed(2)} MB</span>
)}
</div>
{/* Subida de archivo */}
<div className="space-y-2 rounded-lg border bg-muted/50 p-4">
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Agregar archivo al repositorio
</Label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end">
<div className="space-y-1">
<Input
type="file"
accept=".pdf,.doc,.docx,.txt,.md"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file && (
<div className="text-xs text-neutral-600">
{file.name} · {(file.size / 1024).toFixed(1)} KB
</div>
)}
</div>
<div className="space-y-1">
<Label>Título / etiqueta</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ej.: Plan 2025, Entrevista 3…"
/>
<Button
className="mt-2 w-full sm:w-auto"
onClick={handleUpload}
disabled={uploading || !file}
>
{uploading ? "Subiendo…" : "Subir al repositorio"}
</Button>
</div>
</div>
</div>
{/* Lista de archivos */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Archivos en este repositorio</Label>
<Button
variant="ghost"
size="sm"
onClick={() => refreshFiles()}
disabled={loading || refreshing}
>
<Icons.RefreshCw className="h-4 w-4 mr-1" />
Actualizar
</Button>
</div>
{loading ? (
<div className="text-xs text-neutral-500 py-4">
Cargando archivos
</div>
) : error ? (
<div className="text-xs text-red-500 py-4">{error}</div>
) : files.length === 0 ? (
<div className="text-xs text-neutral-500 py-4">
Todavía no hay archivos en este repositorio
</div>
) : (
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
{files.map(({ file, meta }) => (
<li
key={file.id}
className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2"
>
<div className="min-w-0">
<p className="font-medium truncate">
{meta?.label || file.id}
</p>
<p className="text-xs text-neutral-500 truncate">
{new Date(file.created_at * 1000).toLocaleString()} ·{" "}
{(file.usage_bytes / 1024).toFixed(1)} KB
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={file.status} />
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteFile(file.id)}
>
<Icons.Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cerrar
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>