Compare commits
18 Commits
e7a47f56f8
...
fix/typos
| Author | SHA1 | Date | |
|---|---|---|---|
| e3c1a0ce2b | |||
| 76170421aa | |||
| 2db3a0570a | |||
| d8ade3da75 | |||
| 6a28af26b5 | |||
| 9d9fb3d8a8 | |||
| a2dddae5f3 | |||
| a6efb496db | |||
| ef6cc7b96d | |||
| a6f0010a53 | |||
| 29231206c0 | |||
| 93c79eee77 | |||
| 6f97a83eb0 | |||
| 4ec2c2d533 | |||
| efe7faa65f | |||
| c9d66ce2e5 | |||
| f7a29ad510 | |||
| 6d264a8214 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.env*
|
||||
.nitro
|
||||
.tanstack
|
||||
.cta.json
|
||||
|
||||
627
src/components/ai/AIChatModal.jsx
Normal file
627
src/components/ai/AIChatModal.jsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||
const Paperclip = (props) => (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Dialog = ({ open, onOpenChange, children }) =>
|
||||
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
||||
const DialogContent = ({ className, children }) =>
|
||||
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
||||
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
||||
const Button = ({ onClick, disabled, className, variant, children }) => (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
${variant === "outline"
|
||||
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
||||
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||
|
||||
/* ------------- COMPONENT ------------- */
|
||||
export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) {
|
||||
|
||||
const [vectorStores, setVectorStores] = useState([]);
|
||||
const [vectorFiles, setVectorFiles] = useState([]);
|
||||
const [selectedVector, setSelectedVector] = useState(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||
|
||||
// chat
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
// loading states
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||
|
||||
// conversation control
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const [creatingConversation, setCreatingConversation] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
useEffect(scrollToBottom, [messages]);
|
||||
|
||||
const normalizeInvokeResponse = (resp) => {
|
||||
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(() => {
|
||||
if (!open) {
|
||||
if (conversationId) {
|
||||
deleteConversation(conversationId).catch((e) => console.error(e));
|
||||
}
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setSelectedFiles([]);
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
setConversationId(null);
|
||||
setSelectedVector(null);
|
||||
setVectorFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
setMessages([
|
||||
{
|
||||
role: "system",
|
||||
//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 || "—"}`
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await createConversation();
|
||||
fetchVectorStores();
|
||||
})();
|
||||
|
||||
}, [open]);
|
||||
|
||||
// ---------- CREATE CONVERSATION ----------
|
||||
const createConversation = async () => {
|
||||
try {
|
||||
setCreatingConversation(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const resp = await supabase.functions.invoke("conversation-format", {
|
||||
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("conversation-format", {
|
||||
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(
|
||||
"conversation-format",
|
||||
{
|
||||
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) => {
|
||||
try {
|
||||
setLoadingFiles(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector files:", err);
|
||||
setVectorFiles([]);
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- UI helpers ----------
|
||||
const handleAttach = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files.length) return;
|
||||
setAttachedFiles(prev => [...prev, ...files]);
|
||||
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
|
||||
};
|
||||
|
||||
// Al hacer click en un vector: expandir (solo uno a la vez) y cargar sus archivos
|
||||
const handleVectorClick = async (vector) => {
|
||||
if (selectedVector === vector.id) {
|
||||
// colapsar
|
||||
setSelectedVector(null);
|
||||
setVectorFiles([]);
|
||||
setSelectedFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedVector(vector.id);
|
||||
setSelectedFiles([]);
|
||||
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;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose} >
|
||||
<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>
|
||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 pt-4 min-h-0">
|
||||
<div className="flex gap-6 h-full min-h-0">
|
||||
{/* Left: vectors */}
|
||||
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">Repositorio de archivos</h3>
|
||||
<ScrollArea className="flex-1">
|
||||
{loadingVectors ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
|
||||
) : vectorStores.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{vectorStores.map((vector) => (
|
||||
<div key={vector.id}>
|
||||
{/* VECTOR */}
|
||||
<div
|
||||
onClick={() => handleVectorClick(vector)}
|
||||
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"}`}
|
||||
>
|
||||
<div className="truncate">
|
||||
<strong className="block truncate">{vector.name || vector.id}</strong>
|
||||
<p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</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 className="mt-4 flex-shrink-0">
|
||||
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right: Chat */}
|
||||
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
|
||||
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
|
||||
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||
<ReactMarkdown>{m.content}</ReactMarkdown>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
|
||||
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachedPreviews.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 mt-2">
|
||||
{attachedPreviews.map((name, i) => (
|
||||
<li key={i}>📄 {name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Escribe tu pregunta..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
|
||||
style={{ minHeight: "38px" }}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0)} className="shadow-md">
|
||||
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
|
||||
Aplicar mejora
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import type { PlanTextFields } from "../planes/academic-sections";
|
||||
|
||||
// 🔹 SIMULACIÓN DE ICONO LUCIDE-REACT
|
||||
const Paperclip = (props) => (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-paperclip">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 🔹 SIMULACIÓN DE SHADCN/UI
|
||||
const Dialog = ({ open, onOpenChange, children }) => open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
||||
const DialogContent = ({ className, children }) => <div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`} onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
||||
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
||||
const Button = ({ onClick, disabled, className, variant, children }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
variant === "outline" ? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50" :
|
||||
"bg-blue-600 text-white hover:bg-blue-700"
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
||||
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||
// ====================================================================
|
||||
|
||||
|
||||
type AIChatModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
edgeFunctionUrl: string
|
||||
context?: {
|
||||
section?: string
|
||||
fieldKey?: keyof PlanTextFields
|
||||
originalText?: string
|
||||
}
|
||||
onAccept?: (newText: string) => void
|
||||
}
|
||||
|
||||
export default function AIChatModal({ open, onClose, edgeFunctionUrl, context, onAccept }: AIChatModalProps) {
|
||||
const [files, setFiles] = useState<any[]>([])
|
||||
const [attachedFile, setAttachedFile] = useState<File | null>(null)
|
||||
const [attachedPreview, setAttachedPreview] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<{ role: string; content: string }[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Referencia para desplazar al final del chat
|
||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
useEffect(scrollToBottom, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// 🧹 Limpia mensajes y archivos al cerrar
|
||||
setMessages([])
|
||||
setInput("")
|
||||
setAttachedFile(null)
|
||||
setAttachedPreview(null)
|
||||
} else if (context) {
|
||||
// 🧩 Muestra el contexto inicial al abrir
|
||||
setMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [open, context])
|
||||
|
||||
|
||||
// 🔹 Obtener lista de archivos del Vector Store (Lógica de API original)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const fetchVectorFiles = async () => {
|
||||
// Nota: La verificación de Supabase ahora pasa por el mock.
|
||||
if (typeof supabase === 'undefined' || !supabase.auth) {
|
||||
console.error("Supabase no está disponible (Simulación). Saltando fetch de archivos.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
const token = session?.access_token
|
||||
|
||||
// 🟢 TU LÓGICA DE FETCH ORIGINAL
|
||||
const res = await fetch(`${edgeFunctionUrl}?action=list_files`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.files) setFiles(data.files)
|
||||
else console.warn("No se encontraron archivos en el vector store.")
|
||||
} catch (err) {
|
||||
console.error("Error al cargar archivos del vector store:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchVectorFiles()
|
||||
}, [open, edgeFunctionUrl])
|
||||
|
||||
// 📎 Adjuntar archivo
|
||||
const handleAttach = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setAttachedFile(file)
|
||||
setAttachedPreview(file.name)
|
||||
}
|
||||
|
||||
// 🚀 Enviar prompt o archivo al Edge Function (Lógica de API original)
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() && !attachedFile) return
|
||||
|
||||
// 🧩 Crear el mensaje visible del usuario
|
||||
let userMessageContent = input.trim()
|
||||
if (attachedFile) {
|
||||
userMessageContent += (userMessageContent ? " | " : "") + `Adjunto: ${attachedFile.name}`
|
||||
}
|
||||
if (!userMessageContent && attachedFile) {
|
||||
userMessageContent = `Consulta de archivo: ${attachedFile.name}`
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: userMessageContent }])
|
||||
setInput("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (typeof supabase === "undefined" || !supabase.functions) {
|
||||
throw new Error("Supabase no está disponible o no soporta Edge Functions.")
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
// 🧠 Construimos un prompt limpio con el contexto del campo
|
||||
const contextText = context?.originalText || "Sin texto original"
|
||||
const section = context?.section ? `Sección: ${context.section}` : ""
|
||||
const field = context?.fieldKey ? `Campo: ${context.fieldKey}` : ""
|
||||
|
||||
const fullPrompt = `
|
||||
${section}
|
||||
${field}
|
||||
|
||||
Texto original:
|
||||
${contextText}
|
||||
|
||||
Solicitud del usuario:
|
||||
${input}
|
||||
|
||||
Responde con una versión mejorada del texto, sin agregar frases como “Aquí tienes” ni explicaciones.
|
||||
`.trim()
|
||||
|
||||
formData.append("prompt", fullPrompt)
|
||||
if (attachedFile) formData.append("file", attachedFile)
|
||||
|
||||
// 🟢 Llamada a la Edge Function
|
||||
const { data, error } = await supabase.functions.invoke("simple-chat", {
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: data?.text || "Sin respuesta del modelo." },
|
||||
])
|
||||
} catch (err: any) {
|
||||
console.error("Error al enviar prompt:", err)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: "Ocurrió un error al conectar con la API." },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setAttachedFile(null)
|
||||
setAttachedPreview(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
{/* DialogContent ya define el tamaño h-[85vh] y es flex-col */}
|
||||
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col">
|
||||
|
||||
{/* Encabezado fijo (flex-shrink-0) */}
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">Asistente Inteligente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* CONTENEDOR PRINCIPAL QUE AHORA GESTIONA EL SCROLL DEL CONTENIDO */}
|
||||
{/* flex-1: toma el espacio restante. overflow-y-auto: permite scroll si el contenido se desborda */}
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
|
||||
{/* Contenido que originalmente estaba justo debajo del header */}
|
||||
<div className="flex gap-6 min-h-full">
|
||||
{/* 📂 Archivos del Vector Store */}
|
||||
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
||||
{/* Se mantiene flex-1 en CardContent para el ScrollArea */}
|
||||
<CardContent className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">Archivos del Vector Store</h3>
|
||||
|
||||
{/* ScrollArea toma el espacio disponible (flex-1) */}
|
||||
<ScrollArea className="flex-1">
|
||||
{files.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">
|
||||
{loading ? "Cargando archivos..." : "No hay archivos cargados."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<li
|
||||
key={file.id}
|
||||
className="border border-gray-200 bg-white rounded-lg p-2 text-sm shadow-sm hover:shadow-lg transition-shadow cursor-pointer"
|
||||
>
|
||||
<strong className="block text-gray-700 truncate">{file.name}</strong>
|
||||
<p className="text-xs text-gray-400 truncate">{file.path}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="mt-4 flex-shrink-0">
|
||||
<Button variant="outline" className="w-full">
|
||||
Subir archivo
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 💬 Chat con GPT */}
|
||||
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||
|
||||
{/* Mensajes - EL CONTENEDOR QUE DEBE HACER SCROLL */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm text-center mt-10">
|
||||
Inicia una conversación...
|
||||
</p>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${
|
||||
m.role === "user"
|
||||
? "bg-blue-50 text-blue-800 ml-auto"
|
||||
: "bg-white text-gray-800 mr-auto border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<strong className="font-bold">{m.role === "user" ? "Tú:" : "IA:"}</strong>{" "}
|
||||
{m.content}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-blue-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2
|
||||
5.291A7.962 7.962 0 014 12H0c0
|
||||
3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Archivo adjunto - flex-shrink-0 */}
|
||||
{attachedPreview && (
|
||||
<div className="flex items-center justify-between mt-2 p-3 border border-gray-300 rounded-xl text-sm bg-gray-100 shadow-inner flex-shrink-0">
|
||||
<span className="truncate flex items-center gap-2 text-gray-700">
|
||||
<Paperclip className="w-4 h-4 text-blue-500" />
|
||||
{attachedPreview}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setAttachedFile(null)
|
||||
setAttachedPreview(null)
|
||||
setInput("")
|
||||
}}
|
||||
>
|
||||
Quitar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entrada de texto y enviar - flex-shrink-0 */}
|
||||
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
<input type="file" className="hidden" onChange={handleAttach} />
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Escribe tu pregunta..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
|
||||
style={{ minHeight: "38px" }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={loading || (!input.trim() && !attachedFile)}
|
||||
className="shadow-md"
|
||||
>
|
||||
{loading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (onAccept && lastMessage?.role === "assistant") {
|
||||
onAccept(lastMessage.content)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
disabled={!messages.some((m) => m.role === "assistant")}
|
||||
className="shadow-md"
|
||||
>
|
||||
Aplicar mejora
|
||||
</Button>
|
||||
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { useSupabaseAuth } from "@/auth/supabase"
|
||||
|
||||
export function HistorialCambiosModal({
|
||||
|
||||
@@ -6,15 +6,12 @@ import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import confetti from "canvas-confetti"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Field } from "./Field"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||
import { asignaturaKeys } from "./planQueries"
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
|
||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const router = useRouter()
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -33,7 +33,6 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
||||
const sectionGap = 10 // Espacio entre recuadros de sección
|
||||
const bodyFontSize = 10.5
|
||||
const headingFontSize = 12
|
||||
const subHeadingFontSize = 10
|
||||
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
|
||||
const bulletIndent = 6 // Sangría para el texto de la lista
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||
import { toast } from "sonner"
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
||||
import AIChatModal from "../ai/AIChatModal"
|
||||
// @ts-ignore
|
||||
import AIChatModal from "../ai/AIChatModal"
|
||||
|
||||
|
||||
|
||||
/* =====================================================
|
||||
@@ -53,6 +55,8 @@ export const planTextOptions = (planId: string) =>
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
|
||||
|
||||
/* =====================================================
|
||||
Color helpers
|
||||
===================================================== */
|
||||
@@ -68,7 +72,7 @@ const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb
|
||||
/* =====================================================
|
||||
Expandable text
|
||||
===================================================== */
|
||||
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
|
||||
function ExpandableText({ text }: { text?: string | string[] | null; mono?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!text || (Array.isArray(text) && text.length === 0)) {
|
||||
return <span className="text-neutral-400">—</span>
|
||||
@@ -123,6 +127,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
|
||||
|
||||
// --- mutation con actualización optimista ---
|
||||
const updateField = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
|
||||
@@ -298,22 +303,24 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<HistorialCambiosModal
|
||||
<HistorialCambiosModal
|
||||
open={openHistorial}
|
||||
onClose={() => setOpenHistorial(false)}
|
||||
planId={planId}
|
||||
onRestore={async (key, value) => {
|
||||
updateField.mutate({ key, value })
|
||||
updateField.mutate({ key: key as keyof PlanTextFields, value })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatModal
|
||||
open={openModalIa}
|
||||
//plan_format={plan_format}
|
||||
open={openModalIa}
|
||||
onClose={() => setopenModalIa(false)}
|
||||
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
|
||||
context={{
|
||||
section: iaContext?.title,
|
||||
fieldKey: iaContext?.key,
|
||||
section: null,//,iaContext?.title,
|
||||
fieldKey: null,//iaContext?.key,
|
||||
originalText: iaContext?.content,
|
||||
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`,
|
||||
}}
|
||||
onAccept={(newText: string) => {
|
||||
if (iaContext) {
|
||||
|
||||
10
src/formatos/plan.json
Normal file
10
src/formatos/plan.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"objetivo_general": "...",
|
||||
"sistema_evaluacion": "...",
|
||||
"perfil_ingreso": "...",
|
||||
"perfil_egreso": "...",
|
||||
"competencias_genericas": "...",
|
||||
"competencias_especificas": "...",
|
||||
"indicadores_desempeno": "...",
|
||||
"pertinencia": "..."
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
|
||||
/**
|
||||
@@ -11,8 +11,6 @@ export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
|
||||
tableName: string,
|
||||
idKey: keyof T = "id" as keyof T
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Generar diferencias tipo JSON Patch
|
||||
function generateDiff(oldData: T, newData: Partial<T>) {
|
||||
const changes: any[] = [];
|
||||
|
||||
39
src/lib/filesAndVectorStoresClient.ts
Normal file
39
src/lib/filesAndVectorStoresClient.ts
Normal 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
|
||||
}
|
||||
@@ -149,8 +149,6 @@ function Layout() {
|
||||
|
||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { claims } = useSupabaseAuth()
|
||||
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
|
||||
|
||||
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// routes/_authenticated/archivos.tsx
|
||||
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 * as Icons from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -9,92 +9,204 @@ 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,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
import type { RefRow } from "@/types/RefRow"
|
||||
import { uuid } from "zod"
|
||||
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/archivos")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("documentos")
|
||||
.select("*")
|
||||
.order("fecha_subida", { ascending: false })
|
||||
.limit(200)
|
||||
if (error) throw error
|
||||
return (data ?? []) as RefRow[]
|
||||
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
|
||||
module: "vectorStores",
|
||||
action: "list",
|
||||
params: {
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
return stores ?? []
|
||||
},
|
||||
})
|
||||
|
||||
function chipTint(ok?: boolean | null) {
|
||||
return ok
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: "bg-amber-50 text-amber-800 border-amber-200"
|
||||
/* ====== 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 rows = Route.useLoaderData() as RefRow[]
|
||||
const vectorStores = Route.useLoaderData() as VectorStore[]
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
|
||||
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
|
||||
|
||||
const [viewing, setViewing] = useState<RefRow | null>(null)
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
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 t = q.trim().toLowerCase()
|
||||
return rows.filter((r) => {
|
||||
if (estado === "proc" && !r.procesado) return false
|
||||
if (estado === "pend" && r.procesado) return false
|
||||
if (scope === "internos" && !r.interno) return false
|
||||
if (scope === "externos" && r.interno) return false
|
||||
|
||||
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
|
||||
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)
|
||||
)
|
||||
})
|
||||
}, [rows, q, estado, scope])
|
||||
}, [vectorStores, q, statusFilter])
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||
const { error } = await supabase
|
||||
.from("documentos")
|
||||
.delete()
|
||||
.eq("documentos_id", id)
|
||||
if (error) return alert(error.message)
|
||||
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 {
|
||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentos_id: id }),
|
||||
await callFilesAndVectorStoresApi({
|
||||
module: "vectorStores",
|
||||
action: "delete",
|
||||
params: { vector_store_id: id },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Se falló al eliminar el documento")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error al eliminar el documento:", err)
|
||||
}
|
||||
await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.delete()
|
||||
.eq("vector_store_id", id)
|
||||
|
||||
router.invalidate()
|
||||
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">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="relative w-full sm:w-80">
|
||||
@@ -102,240 +214,502 @@ function RouteComponent() {
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por título, etiqueta, fuente…"
|
||||
placeholder="Buscar por nombre o descripción…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</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]">
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
<SelectItem value="proc">Procesados</SelectItem>
|
||||
<SelectItem value="pend">Pendientes</SelectItem>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="completed">Completados</SelectItem>
|
||||
<SelectItem value="in_progress">En proceso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Ámbito" />
|
||||
</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 onClick={() => setCreateOpen(true)}>
|
||||
<Icons.FolderPlus className="w-4 h-4 mr-2" />
|
||||
Nuevo repositorio
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((r) => (
|
||||
<article
|
||||
key={r.documentos_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">
|
||||
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
|
||||
{r.procesado ? "Procesado" : "Pendiente"}
|
||||
</span>
|
||||
{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>
|
||||
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
|
||||
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
|
||||
{r.interno != null && (
|
||||
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
|
||||
)}
|
||||
{r.fecha_subida && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Detalle */}
|
||||
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
|
||||
<CreateVectorStoreDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={() => router.invalidate()}
|
||||
/>
|
||||
|
||||
{/* Subida */}
|
||||
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
|
||||
<VectorStoreDialog
|
||||
store={selected}
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) setSelected(null)
|
||||
}}
|
||||
onUpdated={() => router.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ========= Subida ========= */
|
||||
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)
|
||||
/* ====== Crear repositorio ====== */
|
||||
|
||||
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)
|
||||
}
|
||||
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 upload() {
|
||||
if (!file) { alert("Selecciona un archivo"); return }
|
||||
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
|
||||
|
||||
setSubiendo(true)
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) {
|
||||
alert("Escribe un nombre para el repositorio")
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const fileBase64 = await toBase64(file)
|
||||
// Enviamos al motor (inserta en la tabla si insert=true)
|
||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: instrucciones,
|
||||
fileBase64,
|
||||
insert: true,
|
||||
uuid: supabaseAuth.user?.id ?? null,
|
||||
}),
|
||||
await callFilesAndVectorStoresApi<VectorStore>({
|
||||
module: "vectorStores",
|
||||
action: "create",
|
||||
params: { name: name.trim(), description: description.trim() || undefined },
|
||||
})
|
||||
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)
|
||||
onDone()
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al subir el documento")
|
||||
setName("")
|
||||
setDescription("")
|
||||
onCreated()
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? "Error al crear el repositorio")
|
||||
} finally {
|
||||
setSubiendo(false)
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
|
||||
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
|
||||
<em> procesado </em> cuando termine el flujo.
|
||||
Crea un Vector Store para agrupar archivos relacionados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Archivo</Label>
|
||||
<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>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]"
|
||||
<Label>Nombre</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Planeación curricular, Entrevistas…"
|
||||
/>
|
||||
</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">
|
||||
<Label>Ámbito</Label>
|
||||
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Interno</SelectItem>
|
||||
<SelectItem value="false">Externo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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={upload} disabled={subiendo || !file || !instrucciones.trim()}>
|
||||
{subiendo ? "Subiendo…" : "Subir"}
|
||||
<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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||
@@ -81,7 +81,7 @@ async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
|
||||
const planIds = await fetchPlanIdsByScope(search)
|
||||
if (planIds && planIds.length === 0) return []
|
||||
console.log(AsignaturaCard);
|
||||
|
||||
|
||||
let query = supabase
|
||||
.from('asignaturas')
|
||||
.select(`
|
||||
@@ -168,25 +168,25 @@ function RouteComponent() {
|
||||
const [q, setQ] = useState(search.q ?? '')
|
||||
const [sem, setSem] = useState<string>('todos')
|
||||
const [tipo, setTipo] = useState<string>('todos')
|
||||
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||
const [groupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
||||
|
||||
const [facultad, setFacultad] = useState("todas")
|
||||
const [carrera, setCarrera] = useState("todas")
|
||||
const [facultad, setFacultad] = useState("todas")
|
||||
const [carrera, setCarrera] = useState("todas")
|
||||
|
||||
/* useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: { ...search, q },
|
||||
replace: true,
|
||||
})
|
||||
}, 400)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [q]) */
|
||||
/* useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: { ...search, q },
|
||||
replace: true,
|
||||
})
|
||||
}, 400)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [q]) */
|
||||
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value
|
||||
setQ(value)
|
||||
router.navigate({
|
||||
@@ -199,30 +199,30 @@ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
})
|
||||
}
|
||||
|
||||
// 🟣 Lista única de facultades
|
||||
const facultadesList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
const fac = p.carrera?.facultad
|
||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes])
|
||||
// 🟣 Lista única de facultades
|
||||
const facultadesList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
const fac = p.carrera?.facultad
|
||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes])
|
||||
|
||||
// 🎓 Lista de carreras según la facultad seleccionada
|
||||
const carrerasList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
if (
|
||||
p.carrera?.id &&
|
||||
p.carrera?.nombre &&
|
||||
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
|
||||
) {
|
||||
unique.set(p.carrera.id, p.carrera.nombre)
|
||||
}
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes, facultad])
|
||||
// 🎓 Lista de carreras según la facultad seleccionada
|
||||
const carrerasList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
if (
|
||||
p.carrera?.id &&
|
||||
p.carrera?.nombre &&
|
||||
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
|
||||
) {
|
||||
unique.set(p.carrera.id, p.carrera.nombre)
|
||||
}
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes, facultad])
|
||||
|
||||
|
||||
// NEW: Clonado individual
|
||||
@@ -256,12 +256,6 @@ const carrerasList = useMemo(() => {
|
||||
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
|
||||
}, [asignaturas])
|
||||
|
||||
const tipos = useMemo(() => {
|
||||
const s = new Set<string>()
|
||||
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
|
||||
return Array.from(s).sort()
|
||||
}, [asignaturas])
|
||||
|
||||
// Salud
|
||||
const salud = useMemo(() => {
|
||||
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
|
||||
@@ -274,29 +268,29 @@ const carrerasList = useMemo(() => {
|
||||
}, [asignaturas])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
return asignaturas.filter(a => {
|
||||
const matchesQ =
|
||||
!t ||
|
||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
const t = q.trim().toLowerCase()
|
||||
return asignaturas.filter(a => {
|
||||
const matchesQ =
|
||||
!t ||
|
||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
|
||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
|
||||
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
|
||||
const planOK = !search.planId || a.plan?.id === search.planId
|
||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
|
||||
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
|
||||
const planOK = !search.planId || a.plan?.id === search.planId
|
||||
|
||||
const flagOK =
|
||||
!flag ||
|
||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||
const flagOK =
|
||||
!flag ||
|
||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||
|
||||
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
|
||||
})
|
||||
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
||||
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
|
||||
})
|
||||
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
||||
|
||||
|
||||
// Agrupación
|
||||
@@ -316,18 +310,19 @@ const carrerasList = useMemo(() => {
|
||||
}, [filtered, groupBy])
|
||||
|
||||
// Helpers
|
||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
|
||||
const clearFilters = () => {
|
||||
setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas')
|
||||
// Actualiza la URL limpiando todos los query params
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
q: '',
|
||||
planId: '',
|
||||
carreraId: '',
|
||||
facultadId: '',
|
||||
f: ''
|
||||
},
|
||||
})
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
q: '',
|
||||
planId: '',
|
||||
carreraId: '',
|
||||
facultadId: '',
|
||||
f: ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NEW: util para clonar 1 asignatura
|
||||
@@ -363,7 +358,7 @@ const carrerasList = useMemo(() => {
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// NEW: abrir modal clon individual
|
||||
function openClone(a: Asignatura) {
|
||||
@@ -550,7 +545,12 @@ const carrerasList = useMemo(() => {
|
||||
value={search.planId ?? "todos"}
|
||||
onValueChange={(val) => {
|
||||
router.navigate({
|
||||
search: { ...search, planId: val === "todos" ? "" : val },
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
...search,
|
||||
planId: val === 'todos' ? '' : val,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -828,15 +828,14 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
const horasT = a.horas_teoricas ?? 0
|
||||
const horasP = a.horas_practicas ?? 0
|
||||
const meta = tipoMeta(a.tipo)
|
||||
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
||||
console.log(a);
|
||||
|
||||
|
||||
return (
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
|
||||
style={{
|
||||
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
||||
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
||||
}}
|
||||
style={{
|
||||
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
||||
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
||||
}}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -890,15 +889,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
{a.plan.carrera && (
|
||||
<InfoChip
|
||||
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||
label={a.plan.carrera.nombre}
|
||||
/>
|
||||
label={a.plan.carrera.nombre}
|
||||
/>
|
||||
)}
|
||||
{a.plan.carrera?.facultad && (
|
||||
<InfoChip
|
||||
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||
label={a.plan.carrera.facultad.nombre}
|
||||
tint={a.plan.carrera.facultad.color}
|
||||
/>
|
||||
tint={a.plan.carrera.facultad.color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
|
||||
import { GradientMesh } from "../../../components/planes/GradientMesh"
|
||||
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
|
||||
import { asignaturaExtraOptions, asignaturaKeys, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
|
||||
import { softAccentStyle } from "@/components/planes/planHelpers"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"
|
||||
|
||||
@@ -17,13 +17,6 @@ import { toast } from "sonner"
|
||||
|
||||
|
||||
|
||||
/* -------------------- Tipos -------------------- */
|
||||
|
||||
|
||||
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
|
||||
|
||||
|
||||
|
||||
|
||||
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||
const usersKeys = {
|
||||
@@ -149,35 +142,6 @@ function RouteComponent() {
|
||||
carrera_id?: string | null
|
||||
}>({ email: "", password: "" })
|
||||
|
||||
function genPassword() {
|
||||
/*
|
||||
Supabase requiere que las contraseñas tengan las siguientes características:
|
||||
- Mínimo de 6 caracteres
|
||||
- Debe contener al menos una letra minúscula
|
||||
- Debe contener al menos una letra mayúscula
|
||||
- Debe contener al menos un número
|
||||
- Debe contener al menos un carácter especial
|
||||
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
|
||||
*/
|
||||
|
||||
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
|
||||
|
||||
const positions = Array.from({ length: 12 }, (_, i) => i);
|
||||
for (let i = positions.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[positions[i], positions[j]] = [positions[j], positions[i]];
|
||||
}
|
||||
|
||||
// 2. Las correspondencias son las siguientes:
|
||||
// - El primer número indica la posición de la letra minúscula
|
||||
// - El segundo número indica la posición de la letra mayúscula
|
||||
// - El tercer número indica la posición del número
|
||||
// - El cuarto número indica la posición del carácter especial
|
||||
// - En las demás posiciones puede haber cualquier caracter alfanumérico
|
||||
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
|
||||
return s.slice(0, 14)
|
||||
}
|
||||
|
||||
function RolePill({ role }: { role: Role }) {
|
||||
const meta = ROLE_META[role]
|
||||
@@ -197,61 +161,6 @@ function RouteComponent() {
|
||||
router.invalidate()
|
||||
}
|
||||
|
||||
const upsertNombramiento = useMutation({
|
||||
mutationFn: async (opts: {
|
||||
user_id: string
|
||||
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
|
||||
facultad_id?: string | null
|
||||
carrera_id?: string | null
|
||||
}) => {
|
||||
// cierra vigentes
|
||||
if (opts.puesto === "jefe_carrera") {
|
||||
if (!opts.carrera_id) throw new Error("Selecciona carrera")
|
||||
await supabase
|
||||
.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", "jefe_carrera")
|
||||
.eq("carrera_id", opts.carrera_id)
|
||||
.is("hasta", null)
|
||||
} else {
|
||||
if (!opts.facultad_id) throw new Error("Selecciona facultad")
|
||||
await supabase
|
||||
.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", opts.puesto)
|
||||
.eq("facultad_id", opts.facultad_id)
|
||||
.is("hasta", null)
|
||||
}
|
||||
const { error } = await supabase.from("nombramientos").insert({
|
||||
user_id: opts.user_id,
|
||||
puesto: opts.puesto,
|
||||
facultad_id: opts.facultad_id ?? null,
|
||||
carrera_id: opts.carrera_id ?? null,
|
||||
desde: new Date().toISOString().slice(0, 10),
|
||||
hasta: null,
|
||||
})
|
||||
if (error) throw error
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
|
||||
})
|
||||
|
||||
const toggleBan = useMutation({
|
||||
mutationFn: async (u: UserClaims) => {
|
||||
throw new Error("Funcionalidad de baneo no implementada aún.")
|
||||
const banned = false // cuando se tenga acceso a ese campo
|
||||
// const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
||||
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
|
||||
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||
// if (error) throw new Error(error.message)
|
||||
return !banned
|
||||
},
|
||||
onSuccess: async (isBanned) => {
|
||||
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
|
||||
})
|
||||
|
||||
const createUser = useMutation({
|
||||
mutationFn: async (payload: typeof createForm) => {
|
||||
// Validaciones previas
|
||||
@@ -409,7 +318,7 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
||||
<Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
||||
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
||||
@@ -425,7 +334,7 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
|
||||
<Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
14
staticwebapp.config.json
Normal file
14
staticwebapp.config.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"navigationFallback": {
|
||||
"rewrite": "/index.html",
|
||||
"exclude": [
|
||||
"/assets/*",
|
||||
"/*.css",
|
||||
"/*.js",
|
||||
"/*.ico",
|
||||
"/*.png",
|
||||
"/*.jpg",
|
||||
"/*.svg"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
swa-cli.config.json
Normal file
12
swa-cli.config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
|
||||
"configurations": {
|
||||
"acad-ia": {
|
||||
"appLocation": ".",
|
||||
"outputLocation": "dist",
|
||||
"appBuildCommand": "npm run build",
|
||||
"run": "npm run dev",
|
||||
"appDevserverUrl": "http://localhost:5173"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user