11 Commits

9 changed files with 1287 additions and 263 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

2
.gitignore vendored
View File

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

View File

@@ -1,73 +1,89 @@
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, plan_format }) {
const [vectorStores, setVectorStores] = useState([]); const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]); const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVector, setSelectedVector] = useState(null);
const [selectedFiles, setSelectedFiles] = useState([]);
const [attachedFile, setAttachedFile] = useState(null); const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreview, setAttachedPreview] = useState(null); const [attachedPreviews, setAttachedPreviews] = useState([]);
// chat
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
// loading states
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);
// conversation control
const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false);
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;
// ------------------------------------ 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; }
}
if (typeof raw === "object" && raw !== null) return raw;
return null;
};
// Al abrir: reset o crear conversación
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e));
}
setMessages([]); setMessages([]);
setInput(""); setInput("");
setAttachedFile(null); setSelectedFiles([]);
setAttachedPreview(null); setAttachedFiles([]);
setVectorStores([]); setAttachedPreviews([]);
setVectorFiles([]); setConversationId(null);
setSelectedVector(null); setSelectedVector(null);
setVectorFiles([]);
return; return;
} }
@@ -75,298 +91,534 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
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 || "—"}`
}, content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
}
]); ]);
} }
}, [open, context]);
// ------------------------------------ (async () => {
// Cargar vector stores await createConversation();
// ------------------------------------ fetchVectorStores();
useEffect(() => { })();
if (!open) return;
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 ----------
// Cargar archivos del vector seleccionado const createConversation = async () => {
// ------------------------------------ try {
setCreatingConversation(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
});
let parsed = null;
if (typeof resp?.data === "string") {
try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
} else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
else parsed = resp;
const convId =
parsed?.conversationId ||
parsed?.data?.conversationId ||
parsed?.data?.id ||
parsed?.id ||
parsed?.conversation_id ||
parsed?.data?.conversation_id;
if (!convId) { setCreatingConversation(false); return; }
setConversationId(convId);
} catch (err) {
console.error("Error creando conversación:", err);
} finally {
setCreatingConversation(false);
}
};
// ---------- DELETE CONVERSATION ----------
const deleteConversation = async (convIdParam) => {
try {
const convIdToUse = convIdParam ?? conversationId;
if (!convIdToUse) return;
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse }
});
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);
});
// ---------- HANDLE CONVERSATION (envío) ----------
const handleConversation = async ({ text }) => {
let contextText = "";
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
if (!conversationId) {
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
return;
}
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
// archivos adjuntos (locales) -> base64
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}`
});
}
}
// archivos seleccionados del vector (por id)
if (selectedFiles.length > 0) {
const filesFromVectors = selectedFiles.map(f => ({
type: "input_file",
file_id: f.id
}));
filesInput = [...filesInput, ...filesFromVectors];
}
const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`;
const payload = {
action: "message",
format: plan_format,
conversationId,
vectorStoreId: selectedVector ?? null,
fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
input: [
{
role: "user",
content: [
{ type: "input_text", text: promptFinal },
...filesInput
]
}
]
};
const { data: invokeData, error } = await supabase.functions.invoke(
"modal-conversation",
{
headers: { Authorization: `Bearer ${token}` },
body: payload
}
);
if (error) throw error;
const parsed = normalizeInvokeResponse({ data: invokeData });
// Extraer texto del assistant (robusto)
let assistantText = null;
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
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;
}
assistantText = assistantText || "Sin respuesta del modelo.";
setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]);
// limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos)
setAttachedFiles([]);
setAttachedPreviews([]);
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
setSelectedFiles([]);
} 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 handleAttach = (e) => {
const file = e.target.files?.[0]; const files = Array.from(e.target.files);
if (!file) return; if (!files.length) return;
setAttachedFile(file); setAttachedFiles(prev => [...prev, ...files]);
setAttachedPreview(file.name); setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
}; };
// ------------------------------------ // Al hacer click en un vector: expandir (solo uno a la vez) y cargar sus archivos
// handleSend — versión final para Supabase Edge Function const handleVectorClick = async (vector) => {
// ------------------------------------ if (selectedVector === vector.id) {
const handleSend = async () => { // colapsar
if (!input.trim() && !attachedFile) return; setSelectedVector(null);
setVectorFiles([]);
// Construir texto del mensaje del usuario setSelectedFiles([]);
const userMessage = input.trim() return;
? input.trim()
: attachedFile
? `Consulta sobre archivo: ${attachedFile.name}`
: "";
// 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); setSelectedVector(vector.id);
setAttachedFile(null); setSelectedFiles([]);
setAttachedPreview(null); await loadFilesForVector(vector.id);
};
// Toggle selección de archivo (checkbox)
const toggleFileSelection = (file) => {
if (selectedFiles.some(f => f.id === file.id)) {
setSelectedFiles(prev => prev.filter(f => f.id !== file.id));
} else {
setSelectedFiles(prev => [...prev, file]);
}
};
const removeSelectedFile = (fileId) => {
setSelectedFiles(prev => prev.filter(f => f.id !== fileId));
};
// ---------- Send flow ----------
const handleSend = async () => {
// no permitir enviar si no hay nada
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
if (creatingConversation) {
// no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
return;
}
if (!conversationId) {
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() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
setMessages(prev => [...prev, { role: "user", content: userText }]);
setInput("");
await handleConversation({ text: userText });
};
function cleanAIResponse(text) {
if (!text) return text;
let cleaned = text;
// -------------------------
// 1. Eliminar emojis
// -------------------------
cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, "");
// -------------------------
// 2. Eliminar separadores tipo ---
// -------------------------
cleaned = cleaned.replace(/^---+$/gm, "");
// -------------------------
// 3. Eliminar saludos y frases meta
// -------------------------
const metaPatterns = [
/^hola[!¡., ]*/i,
/^buen(os|as) (días|tardes|noches)[!¡., ]*/i,
/estoy aquí para ayudarte[.! ]*/gi,
/aquí tienes[,:]*/gi,
/claro[,:]*/gi,
/como pediste[,:]*/gi,
/como solicitaste[,:]*/gi,
/el texto íntegro que compartiste.*$/gi,
/te lo dejo a continuación.*$/gi,
/¿te gustaría.*$/gi,
/¿en qué más puedo.*$/gi,
/si necesitas algo más.*$/gi,
/con gusto.*$/gi,
];
metaPatterns.forEach(p => {
cleaned = cleaned.replace(p, "").trim();
});
// -------------------------
// 4. Extraer solo contenido útil
// -------------------------
const startMarker = "CONTEXTO DEL CAMPO";
const startIndex = cleaned.indexOf(startMarker);
if (startIndex !== -1) {
cleaned = cleaned.substring(startIndex).trim();
}
// -------------------------
// 5. Eliminar líneas vacías múltiples
// -------------------------
cleaned = cleaned.replace(/\n{2,}/g, "\n\n");
// -------------------------
// 6. Quitar numeraciones de cortesía (opcional)
// Ejemplo: “1. ” al inicio de líneas
// -------------------------
cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, "");
return cleaned.trim();
}
const handleApply = () => {
const last = [...messages].reverse().find(m => m.role === "assistant");
if (last && onAccept) {
const cleaned = cleanAIResponse(last.content);
onAccept(cleaned);
onClose();
}
};
const cleanAssistantResponse = (text) => {
if (!text) return text;
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;
}; };
// ------------------------------------
// UI
// ------------------------------------
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"
>
<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: vectors */}
{/* LEFT: VECTOR STORES */} <Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
<Card className="w-1/3 max-w-sm flex flex-col bg-muted/20 border border-gray-200"> <CardContent className="flex flex-col flex-1 p-4">
<CardContent className="flex flex-col flex-1"> <h3 className="font-semibold text-sm mb-3">Repositorio de archivos</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 Repositorio de archivos...</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 Repositorio de archivos.</p>
) : ( ) : (
<ul className="space-y-2"> <div className="space-y-3">
{vectorStores.map(store => ( {vectorStores.map((vector) => (
<li <div key={vector.id}>
key={store.id} {/* VECTOR */}
onClick={() => { <div
setSelectedVector(store); onClick={() => handleVectorClick(vector)}
loadFilesForVector(store.id); className={`p-3 rounded-lg border cursor-pointer transition flex items-center justify-between
}} ${selectedVector === vector.id ? "bg-blue-50 border-blue-400 shadow" : "bg-white border-gray-300"}`}
className={`border p-2 rounded-lg cursor-pointer >
${selectedVector?.id === store.id ? "bg-blue-100" : "bg-white"}`} <div className="truncate">
> <strong className="block truncate">{vector.name || vector.id}</strong>
<strong>{store.name || store.id}</strong> <p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
<p className="text-xs text-gray-500 truncate">{store.description}</p> </div>
</li> <div className="text-xs text-gray-500">{selectedVector === vector.id ? "▼" : "▶"}</div>
</div>
{/* ARCHIVOS cuando está expandido */}
{selectedVector === vector.id && (
<div className="ml-4 mt-2 mb-2 space-y-2">
{loadingFiles ? (
<p className="text-gray-400 text-sm">Cargando archivos...</p>
) : vectorFiles.length === 0 ? (
<p className="text-gray-400 text-sm">No hay archivos en este repositorio</p>
) : (
vectorFiles.map((file) => (
<label key={file.id} className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selectedFiles.some(f => f.id === file.id)}
onChange={() => toggleFileSelection(file)}
/>
<div className="text-sm">
<div className="font-medium">{file.filename ?? file.name ?? file.id}</div>
<div className="text-xs text-gray-400">{file.id}</div>
</div>
</label>
))
)}
</div>
)}
</div>
))} ))}
</ul>
)}
</ScrollArea>
<h4 className="mt-4 font-semibold text-sm">Archivos</h4>
<ScrollArea className="mt-2 max-h-40">
{loadingFiles ? (
<p className="text-gray-400 text-sm">Cargando archivos...</p>
) : vectorFiles.length === 0 ? (
<p className="text-gray-400 text-sm">No hay archivos</p>
) : (
<ul className="space-y-1">
{vectorFiles.map(f => (
<li key={f.id} className="border bg-white p-2 rounded-lg text-sm">
{f.id}
</li>
))}
</ul>
)}
</ScrollArea>
</CardContent>
</Card>
{/* RIGHT: CHAT */}
<Card className="flex-1 flex flex-col border border-gray-200">
<CardContent className="flex flex-col flex-1">
<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">
{messages.length === 0 ? (
<p className="text-gray-400 text-center text-sm">Inicia la conversación</p>
) : (
messages.map((msg, idx) => (
<div
key={idx}
className={`p-3 rounded-xl max-w-[85%] shadow-sm whitespace-pre-wrap
${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>
))
)}
{loading && (
<div className="p-3 bg-white border rounded-xl max-w-fit">
<span className="text-gray-600 text-sm">La IA está respondiendo...</span>
</div> </div>
)} )}
<div ref={messagesEndRef} /> </ScrollArea>
{/* Resumen de archivos seleccionados (de vectores) */}
<div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Archivos seleccionados</h4>
{selectedFiles.length === 0 ? (
<p className="text-sm text-gray-500">No has seleccionado archivos del repositorio</p>
) : (
<ul className="space-y-2 max-h-40 overflow-auto">
{selectedFiles.map((f) => (
<li key={f.id} className="flex items-center justify-between p-2 rounded-md border bg-white">
<div className="text-sm">
<div className="font-medium">{f.filename ?? f.name ?? f.id}</div>
<div className="text-xs text-gray-400 truncate">{f.id}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{/* optionally show vector id */}</span>
<button onClick={() => removeSelectedFile(f.id)} className="text-sm text-red-500 hover:underline">Quitar</button>
</div>
</li>
))}
</ul>
)}
</div> </div>
{attachedPreview && ( {/* <div className="mt-4 flex-shrink-0">
<div className="flex items-center mt-2 p-2 border rounded-lg bg-gray-100 text-sm"> <Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
<Paperclip className="w-4 h-4 text-blue-500" /> </div> */}
<span className="ml-2">{attachedPreview}</span> </CardContent>
<button </Card>
onClick={() => { setAttachedFile(null); setAttachedPreview(null); }}
className="ml-auto text-red-500 text-xs" {/* Right: Chat */}
> <Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
Quitar <CardContent className="flex flex-col flex-1 p-4 min-h-0">
</button> <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>
</div>
{attachedPreviews.length > 0 && (
<ul className="text-xs text-gray-600 mt-2">
{attachedPreviews.map((name, i) => (
<li key={i}>📄 {name}</li>
))}
</ul>
)} )}
{/* 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 && selectedFiles.length === 0)} 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

@@ -12,6 +12,7 @@ import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
import AIChatModal from "../ai/AIChatModal" import AIChatModal from "../ai/AIChatModal"
/* ===================================================== /* =====================================================
Query keys & fetcher Query keys & fetcher
===================================================== */ ===================================================== */
@@ -54,6 +55,8 @@ export const planTextOptions = (planId: string) =>
staleTime: 60_000, staleTime: 60_000,
}) })
/* ===================================================== /* =====================================================
Color helpers Color helpers
===================================================== */ ===================================================== */
@@ -124,6 +127,17 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null) const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
const plan_format={
"objetivo_general": "...",
"sistema_evaluacion": "...",
"perfil_ingreso": "...",
"perfil_egreso": "...",
"competencias_genericas": "...",
"competencias_especificas": "...",
"indicadores_desempeno": "...",
"pertinencia": "..."
}
// --- mutation con actualización optimista --- // --- mutation con actualización optimista ---
const updateField = useMutation({ const updateField = useMutation({
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => { mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
@@ -309,13 +323,17 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
/> />
<AIChatModal <AIChatModal
open={openModalIa} //plan_format={plan_format}
open={openModalIa}
onClose={() => setopenModalIa(false)} onClose={() => setopenModalIa(false)}
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
context={{ context={{
section: iaContext?.title, section: null,//,iaContext?.title,
fieldKey: iaContext?.key, fieldKey: null,//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) {

10
src/formatos/plan.json Normal file
View File

@@ -0,0 +1,10 @@
{
"objetivo_general": "...",
"sistema_evaluacion": "...",
"perfil_ingreso": "...",
"perfil_egreso": "...",
"competencias_genericas": "...",
"competencias_especificas": "...",
"indicadores_desempeno": "...",
"pertinencia": "..."
}

View File

@@ -18,6 +18,7 @@ import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authentic
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras' import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras'
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas' import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
import { Route as AuthenticatedArchivos2RouteImport } from './routes/_authenticated/archivos2'
import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos' import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos'
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId' import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId' import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
@@ -69,6 +70,11 @@ const AuthenticatedAsignaturasRoute =
path: '/asignaturas', path: '/asignaturas',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedArchivos2Route = AuthenticatedArchivos2RouteImport.update({
id: '/archivos2',
path: '/archivos2',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedArchivosRoute = AuthenticatedArchivosRouteImport.update({ const AuthenticatedArchivosRoute = AuthenticatedArchivosRouteImport.update({
id: '/archivos', id: '/archivos',
path: '/archivos', path: '/archivos',
@@ -102,6 +108,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/archivos': typeof AuthenticatedArchivosRoute '/archivos': typeof AuthenticatedArchivosRoute
'/archivos2': typeof AuthenticatedArchivos2Route
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren '/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
'/carreras': typeof AuthenticatedCarrerasRoute '/carreras': typeof AuthenticatedCarrerasRoute
'/dashboard': typeof AuthenticatedDashboardRoute '/dashboard': typeof AuthenticatedDashboardRoute
@@ -117,6 +124,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/archivos': typeof AuthenticatedArchivosRoute '/archivos': typeof AuthenticatedArchivosRoute
'/archivos2': typeof AuthenticatedArchivos2Route
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren '/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
'/carreras': typeof AuthenticatedCarrerasRoute '/carreras': typeof AuthenticatedCarrerasRoute
'/dashboard': typeof AuthenticatedDashboardRoute '/dashboard': typeof AuthenticatedDashboardRoute
@@ -134,6 +142,7 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authenticated/archivos': typeof AuthenticatedArchivosRoute '/_authenticated/archivos': typeof AuthenticatedArchivosRoute
'/_authenticated/archivos2': typeof AuthenticatedArchivos2Route
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren '/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
'/_authenticated/carreras': typeof AuthenticatedCarrerasRoute '/_authenticated/carreras': typeof AuthenticatedCarrerasRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
@@ -151,6 +160,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/archivos' | '/archivos'
| '/archivos2'
| '/asignaturas' | '/asignaturas'
| '/carreras' | '/carreras'
| '/dashboard' | '/dashboard'
@@ -166,6 +176,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/archivos' | '/archivos'
| '/archivos2'
| '/asignaturas' | '/asignaturas'
| '/carreras' | '/carreras'
| '/dashboard' | '/dashboard'
@@ -182,6 +193,7 @@ export interface FileRouteTypes {
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/_authenticated/archivos' | '/_authenticated/archivos'
| '/_authenticated/archivos2'
| '/_authenticated/asignaturas' | '/_authenticated/asignaturas'
| '/_authenticated/carreras' | '/_authenticated/carreras'
| '/_authenticated/dashboard' | '/_authenticated/dashboard'
@@ -265,6 +277,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/archivos2': {
id: '/_authenticated/archivos2'
path: '/archivos2'
fullPath: '/archivos2'
preLoaderRoute: typeof AuthenticatedArchivos2RouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/archivos': { '/_authenticated/archivos': {
id: '/_authenticated/archivos' id: '/_authenticated/archivos'
path: '/archivos' path: '/archivos'
@@ -319,6 +338,7 @@ const AuthenticatedAsignaturasRouteWithChildren =
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute
AuthenticatedArchivos2Route: typeof AuthenticatedArchivos2Route
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren
AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
@@ -332,6 +352,7 @@ interface AuthenticatedRouteChildren {
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedArchivosRoute: AuthenticatedArchivosRoute, AuthenticatedArchivosRoute: AuthenticatedArchivosRoute,
AuthenticatedArchivos2Route: AuthenticatedArchivos2Route,
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren, AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren,
AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute, AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,

View File

@@ -47,6 +47,7 @@ const nav = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon }, { to: "/usuarios", label: "Usuarios", icon: Users2Icon },
{ to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D }, { to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D },
{ to: "/archivos2", label: "Repositorio de archivos", icon: FileAxis3D },
] as const ] as const
function getInitials(name?: string) { function getInitials(name?: string) {

View File

@@ -0,0 +1,718 @@
// routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
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/archivos2")({
component: RouteComponent,
loader: async () => {
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
module: "vectorStores",
action: "list",
params: {
limit: 10,
},
})
return stores ?? []
},
})
/* ====== UI helpers ====== */
function StatusBadge({ status }: { status: string }) {
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() {
const router = useRouter()
const vectorStores = Route.useLoaderData() as VectorStore[]
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
const [selected, setSelected] = useState<VectorStore | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
return vectorStores.filter((vs) => {
if (statusFilter !== "all" && vs.status !== statusFilter) return false
if (!term) return true
return (
(vs.name ?? "").toLowerCase().includes(term) ||
(vs.description ?? "").toLowerCase().includes(term)
)
})
}, [vectorStores, q, statusFilter])
function openDetails(vs: VectorStore) {
setSelected(vs)
setDialogOpen(true)
}
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
try {
await callFilesAndVectorStoresApi({
module: "vectorStores",
action: "delete",
params: { vector_store_id: id },
})
await supabase
.from("vector_store_files_meta")
.delete()
.eq("vector_store_id", id)
router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
}
}
return (
<div className="p-6 space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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="relative w-full sm:w-80">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre o descripción…"
className="pl-8"
/>
</div>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="completed">Completados</SelectItem>
<SelectItem value="in_progress">En proceso</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setCreateOpen(true)}>
<Icons.FolderPlus className="w-4 h-4 mr-2" />
Nuevo repositorio
</Button>
</div>
</CardHeader>
<CardContent>
{filtered.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((vs) => (
<article
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 space-y-1">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold truncate">
{vs.name || "(Sin nombre)"}
</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>
</article>
))}
</div>
) : (
<div className="text-center text-sm text-neutral-500 py-10">
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
</div>
)}
</CardContent>
</Card>
<CreateVectorStoreDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
<VectorStoreDialog
store={selected}
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => router.invalidate()}
/>
</div>
)
}
/* ====== Crear repositorio ====== */
function CreateVectorStoreDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}) {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [creating, setCreating] = useState(false)
async function handleCreate() {
if (!name.trim()) {
alert("Escribe un nombre para el repositorio")
return
}
setCreating(true)
try {
await callFilesAndVectorStoresApi<VectorStore>({
module: "vectorStores",
action: "create",
params: { name: name.trim(), description: description.trim() || undefined },
})
onOpenChange(false)
setName("")
setDescription("")
onCreated()
} catch (err: any) {
alert(err?.message ?? "Error al crear el repositorio")
} finally {
setCreating(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
<DialogDescription>
Crea un Vector Store para agrupar archivos relacionados.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1">
<Label>Nombre</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Planeación curricular, Entrevistas…"
/>
</div>
<div className="space-y-1">
<Label>Descripción (opcional)</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Breve descripción del contenido de este repositorio."
className="min-h-[80px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</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 handleUpload() {
if (!store || !file) {
alert("Selecciona un archivo")
return
}
setUploading(true)
try {
// 1) Subir archivo a OpenAI vía Edge con FormData (igual que en tu script)
const formData = new FormData()
formData.append("module", "files")
formData.append("action", "upload")
formData.append("file", file)
formData.append("purpose", "assistants") // o lo que uses en tu flujo
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: formData,
},
)
if (error) {
console.error(error)
throw error
}
const uploaded = data
// La respuesta es el objeto "file" de OpenAI:
// { object: "file", id: "file-xxx", ... }
const openaiFileId: string | undefined = uploaded?.id
if (!openaiFileId) {
console.error("Respuesta Edge inesperada:", uploaded)
throw new Error("La Edge Function no devolvió el id del archivo")
}
// 2) Mapear archivo al Vector Store (JSON normal)
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "create",
params: {
vector_store_id: store.id,
body: {
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>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -265,7 +265,7 @@ function Page() {
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<span className="font-medium"> <span className="font-medium">
{/^\s*\d+/.test(String(u.key)) {/^\s*\d+/.test(String(u.key))
? `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}${u.__title ? `: ${u.__title}` : ""}` ? `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}${u.__title ? `: ${u.__title}` : ""}`
: u.__title} : u.__title}
</span> </span>
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span> <span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>