Compare commits
8 Commits
feat/reest
...
6a28af26b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a28af26b5 | |||
| a2dddae5f3 | |||
| a6efb496db | |||
| ef6cc7b96d | |||
| 29231206c0 | |||
| 93c79eee77 | |||
| 6f97a83eb0 | |||
| 4ec2c2d533 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
count.txt
|
count.txt
|
||||||
.env
|
.env*
|
||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
|
.cta.json
|
||||||
|
|||||||
@@ -1,372 +1,582 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { supabase } from "@/auth/supabase";
|
import { supabase } from "@/auth/supabase";
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
|
||||||
// ---------------- UI MOCKS ---------------- //
|
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||||
// Puedes reemplazarlos por tus propios componentes UI
|
|
||||||
const Paperclip = (props) => (
|
const Paperclip = (props) => (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Dialog = ({ open, onOpenChange, children }) =>
|
const Dialog = ({ open, onOpenChange, children }) =>
|
||||||
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
||||||
|
|
||||||
const DialogContent = ({ className, children }) =>
|
const DialogContent = ({ className, children }) =>
|
||||||
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
|
||||||
{children}
|
onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||||
</div>;
|
|
||||||
|
|
||||||
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
||||||
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
||||||
|
|
||||||
const Button = ({ onClick, disabled, className, variant, children }) => (
|
const Button = ({ onClick, disabled, className, variant, children }) => (
|
||||||
<button
|
<button onClick={onClick} disabled={disabled}
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||||
${variant === "outline" ? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50" : "bg-blue-600 text-white hover:bg-blue-700"}
|
${variant === "outline"
|
||||||
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}
|
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||||
>
|
: "bg-blue-600 text-white hover:bg-blue-700"}
|
||||||
|
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
||||||
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
||||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||||
|
|
||||||
// ---------------- COMPONENTE ---------------- //
|
/* ------------- COMPONENT ------------- */
|
||||||
export default function AIChatModal({ open, onClose, context, onAccept }) {
|
export default function AIChatModal({ open, onClose, context, onAccept }) {
|
||||||
const [vectorStores, setVectorStores] = useState([]);
|
const [vectorStores, setVectorStores] = useState([]);
|
||||||
const [vectorFiles, setVectorFiles] = useState([]);
|
const [vectorFiles, setVectorFiles] = useState([]);
|
||||||
|
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
|
||||||
|
|
||||||
const [attachedFile, setAttachedFile] = useState(null);
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||||
const [attachedPreview, setAttachedPreview] = useState(null);
|
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||||
|
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||||
const [selectedVector, setSelectedVector] = useState(null);
|
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||||
|
|
||||||
|
const [conversationId, setConversationId] = useState(null);
|
||||||
|
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
|
||||||
|
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
|
||||||
useEffect(scrollToBottom, [messages]);
|
useEffect(scrollToBottom, [messages]);
|
||||||
|
|
||||||
// ------------------------------------
|
const normalizeInvokeResponse = (resp) => {
|
||||||
// Reset al abrir o cerrar modal
|
if (!resp) return null;
|
||||||
// ------------------------------------
|
|
||||||
|
// cuando invocas funciones, Supabase siempre regresa:
|
||||||
|
// { data: "...string...", error: null, response: {} }
|
||||||
|
const raw = resp.data;
|
||||||
|
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("❗ No se pudo parsear resp.data:", raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// si ya viene como objeto
|
||||||
|
if (typeof raw === "object" && raw !== null) return raw;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Al abrir: reset o crear conversación
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log(context.cont_conversation);
|
||||||
|
console.log(context);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
// si ya existe una conversación la eliminamos
|
||||||
|
if (conversationId) {
|
||||||
|
deleteConversation(conversationId).catch((e) => console.error(e));
|
||||||
|
}
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setAttachedFile(null);
|
setSelectedVectorFile(null);
|
||||||
setAttachedPreview(null);
|
setAttachedFiles([]);
|
||||||
setVectorStores([]);
|
setAttachedPreviews([]);
|
||||||
setVectorFiles([]);
|
setConversationId(null);
|
||||||
setSelectedVector(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inyectar contexto como system message
|
||||||
if (context) {
|
if (context) {
|
||||||
setMessages([
|
setMessages([
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`,
|
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
|
||||||
},
|
}
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
setMessages(prev => prev); // no hacer nada si no hay contexto
|
||||||
}
|
}
|
||||||
}, [open, context]);
|
|
||||||
|
|
||||||
// ------------------------------------
|
// crear conversación y esperar a que termine antes de permitir enviar
|
||||||
// Cargar vector stores
|
(async () => {
|
||||||
// ------------------------------------
|
await createConversation();
|
||||||
useEffect(() => {
|
// tras crear podemos también cargar vector stores
|
||||||
if (!open) return;
|
fetchVectorStores();
|
||||||
|
})();
|
||||||
|
|
||||||
const fetchVectorStores = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
const token = session?.access_token;
|
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
|
||||||
"files-and-vector-stores-api",
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
body: { module: "vectorStores", action: "list" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
setVectorStores(Array.isArray(data) ? data : []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error al obtener vector stores:", err);
|
|
||||||
setVectorStores([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchVectorStores();
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// ------------------------------------
|
// --------- CREATE CONVERSATION (robusto) ----------
|
||||||
// Cargar archivos del vector seleccionado
|
const createConversation = async () => {
|
||||||
// ------------------------------------
|
try {
|
||||||
|
setCreatingConversation(true);
|
||||||
|
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
|
||||||
|
// llamada
|
||||||
|
const resp = await supabase.functions.invoke("modal-conversation", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: { action: "start" , role:"system", content:context.cont_conversation, }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("createConversation -> raw resp:", resp);
|
||||||
|
|
||||||
|
// resp puede ser { data: "...json string..." } o { data: { ... } }
|
||||||
|
let parsed = null;
|
||||||
|
|
||||||
|
if (typeof resp?.data === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(resp.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("No se pudo parsear resp.data como JSON:", e, resp.data);
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
} else if (typeof resp?.data === "object" && resp.data !== null) {
|
||||||
|
parsed = resp.data;
|
||||||
|
} else {
|
||||||
|
// fallback: quizá la respuesta viene en resp (sin data)
|
||||||
|
parsed = resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("createConversation -> parsed payload:", parsed);
|
||||||
|
|
||||||
|
// buscar el id en varios lugares (robusto)
|
||||||
|
const convId =
|
||||||
|
parsed?.conversationId ||
|
||||||
|
parsed?.data?.conversationId ||
|
||||||
|
parsed?.data?.id ||
|
||||||
|
parsed?.id ||
|
||||||
|
parsed?.conversation_id ||
|
||||||
|
parsed?.data?.conversation_id;
|
||||||
|
|
||||||
|
if (!convId) {
|
||||||
|
console.warn("No se encontró conversationId en la respuesta parseada:", parsed);
|
||||||
|
setCreatingConversation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConversationId(convId);
|
||||||
|
console.log("🟢 Conversación creada y guardada:", convId);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error creando conversación:", err);
|
||||||
|
} finally {
|
||||||
|
setCreatingConversation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --------- DELETE CONVERSATION (robusto) ----------
|
||||||
|
const deleteConversation = async (convIdParam) => {
|
||||||
|
try {
|
||||||
|
const convIdToUse = convIdParam ?? conversationId;
|
||||||
|
if (!convIdToUse) return;
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
|
||||||
|
// algunas implementations esperan { action: "end", conversationId }, otras { action: "end", id }
|
||||||
|
const { data, error } = await supabase.functions.invoke("modal-conversation", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: { action: "end", conversationId: convIdToUse }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("deleteConversation -> response:", data);
|
||||||
|
setConversationId(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error eliminando conversación:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- CONVERT FILE TO BASE64 ----------
|
||||||
|
const fileToBase64 = (file) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = (e) => reject(e);
|
||||||
|
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- SEND MESSAGE (usa conversationId) ----------
|
||||||
|
const handleConversation = async ({ text }) => {
|
||||||
|
if (!conversationId) {
|
||||||
|
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
|
||||||
|
// si no hay conv, opcionalmente intentar crear una sin que el usuario note
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
|
||||||
|
let filesInput = [];
|
||||||
|
|
||||||
|
if (attachedFiles.length > 0) {
|
||||||
|
for (const file of attachedFiles) {
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
filesInput.push({
|
||||||
|
type: "input_file",
|
||||||
|
filename: file.name,
|
||||||
|
file_data: `data:${file.type};base64,${base64}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedVectorFile) {
|
||||||
|
// si el archivo del vector viene sólo con id
|
||||||
|
filesInput.push({
|
||||||
|
type: "input_file",
|
||||||
|
file_id: selectedVectorFile.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
action: "message",
|
||||||
|
conversationId,
|
||||||
|
vectorStoreId: selectedVectorFile?.vector_store_id ?? null,
|
||||||
|
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_text", text },
|
||||||
|
...filesInput
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const { data: invokeData, error } = await supabase.functions.invoke(
|
||||||
|
"modal-conversation",
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: payload
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
console.log("handleConversation -> RAW invokeData:", invokeData);
|
||||||
|
|
||||||
|
const parsed = normalizeInvokeResponse({ data: invokeData });
|
||||||
|
console.log("handleConversation -> PARSED:", parsed);
|
||||||
|
|
||||||
|
// 🔥 EXTRACTOR DEFINITIVO
|
||||||
|
let assistantText = null;
|
||||||
|
|
||||||
|
// 1) directo
|
||||||
|
if (parsed?.data?.output_text) {
|
||||||
|
assistantText = parsed.data.output_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) buscar el message
|
||||||
|
if (!assistantText && Array.isArray(parsed?.data?.output)) {
|
||||||
|
const msgBlock = parsed.data.output.find(o => o.type === "message");
|
||||||
|
if (msgBlock?.content?.[0]?.text) {
|
||||||
|
assistantText = msgBlock.content[0].text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) fallback
|
||||||
|
assistantText = assistantText || "Sin respuesta del modelo.";
|
||||||
|
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{ role: "assistant", content: cleanAssistantResponse(assistantText) }
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
setAttachedFiles([]);
|
||||||
|
setAttachedPreviews([]);
|
||||||
|
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error en handleConversation:", err);
|
||||||
|
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- VECTORES ----------
|
||||||
|
const fetchVectorStores = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingVectors(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
|
||||||
|
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: { module: "vectorStores", action: "list" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setVectorStores(Array.isArray(data) ? data : (data?.data ?? []));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading vector stores:", err);
|
||||||
|
setVectorStores([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingVectors(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) fetchVectorStores();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const loadFilesForVector = async (vectorStoreId) => {
|
const loadFilesForVector = async (vectorStoreId) => {
|
||||||
try {
|
try {
|
||||||
setLoadingFiles(true);
|
setLoadingFiles(true);
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
const token = session?.access_token;
|
const token = session?.access_token;
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||||
"files-and-vector-stores-api",
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
{
|
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
});
|
||||||
body: {
|
|
||||||
module: "vectorStoreFiles",
|
|
||||||
action: "list",
|
|
||||||
params: { vector_store_id: vectorStoreId },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setVectorFiles(Array.isArray(data) ? data : []);
|
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al obtener archivos del vector store:", err);
|
console.error("Error loading vector files:", err);
|
||||||
setVectorFiles([]);
|
setVectorFiles([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingFiles(false);
|
setLoadingFiles(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------
|
// ---------- UI helpers ----------
|
||||||
// Adjuntar archivo
|
const handleAttach = (e) => {
|
||||||
// ------------------------------------
|
const files = Array.from(e.target.files);
|
||||||
const handleAttach = (e) => {
|
if (!files.length) return;
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
setAttachedFiles(prev => [...prev, ...files]);
|
||||||
setAttachedFile(file);
|
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
|
||||||
setAttachedPreview(file.name);
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSelectVectorFile = (file) => {
|
||||||
|
setSelectedVectorFile(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------
|
// ---------- Send flow ----------
|
||||||
// handleSend — versión final para Supabase Edge Function
|
|
||||||
// ------------------------------------
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() && !attachedFile) return;
|
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
|
||||||
|
|
||||||
// Construir texto del mensaje del usuario
|
// esperar si aún se está creando la conversación
|
||||||
const userMessage = input.trim()
|
if (creatingConversation) {
|
||||||
? input.trim()
|
console.log("Esperando a que se cree la conversación...");
|
||||||
: attachedFile
|
// opcional: podrías mostrar un toast; aquí simplemente retornamos
|
||||||
? `Consulta sobre archivo: ${attachedFile.name}`
|
return;
|
||||||
: "";
|
|
||||||
|
|
||||||
// Agregar mensaje al chat
|
|
||||||
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
|
|
||||||
setInput("");
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
const fullPrompt = `
|
|
||||||
${context?.section ? `Sección: ${context.section}` : ""}
|
|
||||||
${context?.fieldKey ? `Campo: ${context.fieldKey}` : ""}
|
|
||||||
|
|
||||||
Texto original:
|
|
||||||
${context?.originalText || "Sin texto original"}
|
|
||||||
|
|
||||||
Solicitud del usuario:
|
|
||||||
${userMessage}
|
|
||||||
|
|
||||||
Responde con una versión mejorada en texto directo, sin explicaciones.
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
formData.append("prompt", fullPrompt);
|
|
||||||
if (attachedFile) formData.append("file", attachedFile);
|
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
|
||||||
"simple-chat",
|
|
||||||
{ body: formData }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Respuesta de la IA
|
|
||||||
setMessages(prev => [
|
|
||||||
...prev,
|
|
||||||
{ role: "assistant", content: data?.text || "Sin respuesta del modelo." }
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error enviando mensaje:", err);
|
|
||||||
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al conectar con la IA." }]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
if (!conversationId) {
|
||||||
setAttachedFile(null);
|
console.warn("No hay conversationId — intentaremos crear una ahora.");
|
||||||
setAttachedPreview(null);
|
await createConversation();
|
||||||
|
if (!conversationId) {
|
||||||
|
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
|
||||||
|
setMessages(prev => [...prev, { role: "user", content: userText }]);
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
await handleConversation({ text: userText });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------
|
const handleApply = () => {
|
||||||
// UI
|
const last = [...messages].reverse().find(m => m.role === "assistant");
|
||||||
// ------------------------------------
|
if (last && onAccept) {
|
||||||
|
onAccept(last.content);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanAssistantResponse = (text) => {
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
// Frases que quieres eliminar (puedes agregar más)
|
||||||
|
const patterns = [
|
||||||
|
/^claro[, ]*/i,
|
||||||
|
/^por supuesto[, ]*/i,
|
||||||
|
/^aquí tienes[, ]*/i,
|
||||||
|
/^con gusto[, ]*/i,
|
||||||
|
/^hola[, ]*/i,
|
||||||
|
/^perfecto[, ]*/i,
|
||||||
|
/^entendido[, ]*/i,
|
||||||
|
/^muy bien[, ]*/i,
|
||||||
|
/^ok[, ]*/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
let cleaned = text.trim();
|
||||||
|
|
||||||
|
for (const p of patterns) {
|
||||||
|
cleaned = cleaned.replace(p, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative">
|
||||||
|
{/* Botón siempre visible */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 pt-4 min-h-0">
|
||||||
<div className="flex gap-6 min-h-full">
|
<div className="flex gap-6 h-full min-h-0">
|
||||||
|
|
||||||
{/* LEFT: VECTOR STORES */}
|
{/* Left: vectors */}
|
||||||
<Card className="w-1/3 max-w-sm flex flex-col bg-muted/20 border border-gray-200">
|
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
||||||
<CardContent className="flex flex-col flex-1">
|
<CardContent className="flex flex-col flex-1 p-4">
|
||||||
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
|
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{loading ? (
|
{loadingVectors ? (
|
||||||
<p className="text-center text-gray-400">Cargando...</p>
|
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
|
||||||
) : vectorStores.length === 0 ? (
|
) : vectorStores.length === 0 ? (
|
||||||
<p className="text-center text-gray-400">No hay vector stores</p>
|
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{vectorStores.map(store => (
|
{vectorStores.map((vector) => (
|
||||||
<li
|
<li key={vector.id}
|
||||||
key={store.id}
|
onClick={() => loadFilesForVector(vector.id)}
|
||||||
onClick={() => {
|
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
|
||||||
setSelectedVector(store);
|
|
||||||
loadFilesForVector(store.id);
|
|
||||||
}}
|
|
||||||
className={`border p-2 rounded-lg cursor-pointer
|
|
||||||
${selectedVector?.id === store.id ? "bg-blue-100" : "bg-white"}`}
|
|
||||||
>
|
>
|
||||||
<strong>{store.name || store.id}</strong>
|
<strong className="truncate">{vector.name || vector.id}</strong>
|
||||||
<p className="text-xs text-gray-500 truncate">{store.description}</p>
|
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<h4 className="mt-4 font-semibold text-sm">Archivos</h4>
|
<div className="mt-4">
|
||||||
<ScrollArea className="mt-2 max-h-40">
|
<h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4>
|
||||||
{loadingFiles ? (
|
{loadingFiles ? (
|
||||||
<p className="text-gray-400 text-sm">Cargando archivos...</p>
|
<p className="text-sm text-gray-500">Cargando archivos...</p>
|
||||||
) : vectorFiles.length === 0 ? (
|
) : selectedVectorFile ? (
|
||||||
<p className="text-gray-400 text-sm">No hay archivos</p>
|
<div className="text-sm text-gray-700 mb-2">
|
||||||
|
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<p className="text-sm text-gray-500">Selecciona un archivo del vector</p>
|
||||||
{vectorFiles.map(f => (
|
|
||||||
<li key={f.id} className="border bg-white p-2 rounded-lg text-sm">
|
|
||||||
{f.id}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
|
||||||
|
<ul className="space-y-2 max-h-40 overflow-auto mt-2">
|
||||||
|
{vectorFiles.map((file) => (
|
||||||
|
<li key={file.id}
|
||||||
|
onClick={() => handleSelectVectorFile(file)}
|
||||||
|
className={`p-2 rounded-lg cursor-pointer border ${selectedVectorFile?.id === file.id ? "bg-blue-50 border-blue-300" : "bg-white"}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{file.filename}</div>
|
||||||
|
<div className="text-xs text-gray-400">{file.id}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex-shrink-0">
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RIGHT: CHAT */}
|
{/* Right: Chat */}
|
||||||
<Card className="flex-1 flex flex-col border border-gray-200">
|
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
||||||
<CardContent className="flex flex-col flex-1">
|
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
|
||||||
<h3 className="font-semibold text-sm mb-3">Chat</h3>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto border p-3 rounded-lg bg-gray-50 space-y-3">
|
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||||
{messages.length === 0 ? (
|
|
||||||
<p className="text-gray-400 text-center text-sm">Inicia la conversación</p>
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg, idx) => (
|
messages.map((m, i) => (
|
||||||
<div
|
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
|
||||||
key={idx}
|
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||||
className={`p-3 rounded-xl max-w-[85%] shadow-sm whitespace-pre-wrap
|
<ReactMarkdown>{m.content}</ReactMarkdown>
|
||||||
${msg.role === "user"
|
|
||||||
? "bg-blue-50 text-blue-800 ml-auto"
|
|
||||||
: "bg-white text-gray-700 border border-gray-200 mr-auto"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<strong>{msg.role === "user" ? "Tú:" : "IA:"}</strong>
|
|
||||||
<p>{msg.content}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="p-3 bg-white border rounded-xl max-w-fit">
|
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
|
||||||
<span className="text-gray-600 text-sm">La IA está respondiendo...</span>
|
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachedPreview && (
|
{attachedPreviews.length > 0 && (
|
||||||
<div className="flex items-center mt-2 p-2 border rounded-lg bg-gray-100 text-sm">
|
<ul className="text-xs text-gray-600 mt-2">
|
||||||
<Paperclip className="w-4 h-4 text-blue-500" />
|
{attachedPreviews.map((name, i) => (
|
||||||
<span className="ml-2">{attachedPreview}</span>
|
<li key={i}>📄 {name}</li>
|
||||||
<button
|
))}
|
||||||
onClick={() => { setAttachedFile(null); setAttachedPreview(null); }}
|
</ul>
|
||||||
className="ml-auto text-red-500 text-xs"
|
|
||||||
>
|
|
||||||
Quitar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input */}
|
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||||
<div className="flex gap-2 mt-4">
|
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600">
|
|
||||||
<Paperclip className="w-5 h-5" />
|
<Paperclip className="w-5 h-5" />
|
||||||
<input type="file" className="hidden" onChange={handleAttach} />
|
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Escribe tu pregunta..."
|
placeholder="Escribe tu pregunta..."
|
||||||
className="flex-1 border rounded-lg p-3 text-sm"
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
|
||||||
|
style={{ minHeight: "38px" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
|
||||||
onClick={handleSend}
|
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||||
disabled={loading || (!input.trim() && !attachedFile)}
|
|
||||||
>
|
|
||||||
Enviar
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
|
||||||
onClick={() => {
|
Aplicar mejora
|
||||||
const last = messages[messages.length - 1];
|
|
||||||
if (last?.role === "assistant") {
|
|
||||||
onAccept(last.content);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!messages.some(m => m.role === "assistant")}
|
|
||||||
>
|
|
||||||
Aplicar
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -311,11 +311,14 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
|||||||
<AIChatModal
|
<AIChatModal
|
||||||
open={openModalIa}
|
open={openModalIa}
|
||||||
onClose={() => setopenModalIa(false)}
|
onClose={() => setopenModalIa(false)}
|
||||||
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
|
|
||||||
context={{
|
context={{
|
||||||
section: iaContext?.title,
|
section: iaContext?.title,
|
||||||
fieldKey: iaContext?.key,
|
fieldKey: iaContext?.key,
|
||||||
originalText: iaContext?.content,
|
originalText: iaContext?.content,
|
||||||
|
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
|
||||||
|
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
|
||||||
|
No uses introducciones, despedidas ni texto de relleno.
|
||||||
|
Entrega solo el contenido útil.`,
|
||||||
}}
|
}}
|
||||||
onAccept={(newText: string) => {
|
onAccept={(newText: string) => {
|
||||||
if (iaContext) {
|
if (iaContext) {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// routes/_authenticated/archivos.tsx
|
// routes/_authenticated/archivos.tsx
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
import { use, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -9,92 +9,202 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
import type { RefRow } from "@/types/RefRow"
|
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
|
||||||
import { uuid } from "zod"
|
|
||||||
|
interface VectorStore {
|
||||||
|
id: string
|
||||||
|
object: "vector_store"
|
||||||
|
created_at: number
|
||||||
|
name: string | null
|
||||||
|
description?: string | null
|
||||||
|
usage_bytes: number
|
||||||
|
file_counts: {
|
||||||
|
in_progress: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
cancelled: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
status: string
|
||||||
|
last_active_at?: number | null
|
||||||
|
metadata?: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VectorStoreFile {
|
||||||
|
id: string
|
||||||
|
object: string
|
||||||
|
created_at: number
|
||||||
|
vector_store_id: string
|
||||||
|
status: string
|
||||||
|
usage_bytes: number
|
||||||
|
last_error?: { code: string; message: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VectorStoreFileMeta {
|
||||||
|
id: string
|
||||||
|
user_id: string | null
|
||||||
|
vector_store_id: string
|
||||||
|
openai_file_id: string
|
||||||
|
label: string | null
|
||||||
|
tags: string[] | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EdgeArgs = {
|
||||||
|
module: EdgeModule
|
||||||
|
action: string
|
||||||
|
params?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callFilesAndVectorStoresApi<T = unknown>(
|
||||||
|
args: EdgeArgs,
|
||||||
|
): Promise<T> {
|
||||||
|
const { data, error } = await supabase.functions.invoke<any>(
|
||||||
|
"files-and-vector-stores-api",
|
||||||
|
{
|
||||||
|
body: args,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = data ?? {}
|
||||||
|
if (payload.error) {
|
||||||
|
const msg =
|
||||||
|
typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: payload.error.message ?? "Error en la función Edge"
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = payload.data !== undefined ? payload.data : payload
|
||||||
|
return result as T
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/archivos")({
|
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const { data, error } = await supabase
|
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
|
||||||
.from("documentos")
|
module: "vectorStores",
|
||||||
.select("*")
|
action: "list",
|
||||||
.order("fecha_subida", { ascending: false })
|
params: {},
|
||||||
.limit(200)
|
})
|
||||||
if (error) throw error
|
return stores ?? []
|
||||||
return (data ?? []) as RefRow[]
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function chipTint(ok?: boolean | null) {
|
/* ====== UI helpers ====== */
|
||||||
return ok
|
|
||||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
function StatusBadge({ status }: { status: string }) {
|
||||||
: "bg-amber-50 text-amber-800 border-amber-200"
|
const label =
|
||||||
|
status === "completed"
|
||||||
|
? "Completado"
|
||||||
|
: status === "in_progress"
|
||||||
|
? "Procesando"
|
||||||
|
: status
|
||||||
|
const base = "text-[10px] px-2 py-0.5 rounded-full border"
|
||||||
|
if (status === "completed") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${base} bg-emerald-50 text-emerald-700 border-emerald-200`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === "in_progress") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${base} bg-amber-50 text-amber-800 border-amber-200`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`${base} bg-neutral-50 text-neutral-700 border-neutral-200`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const rows = Route.useLoaderData() as RefRow[]
|
const vectorStores = Route.useLoaderData() as VectorStore[]
|
||||||
|
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
|
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
|
||||||
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
|
const [selected, setSelected] = useState<VectorStore | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [viewing, setViewing] = useState<RefRow | null>(null)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const t = q.trim().toLowerCase()
|
const term = q.trim().toLowerCase()
|
||||||
return rows.filter((r) => {
|
return vectorStores.filter((vs) => {
|
||||||
if (estado === "proc" && !r.procesado) return false
|
if (statusFilter !== "all" && vs.status !== statusFilter) return false
|
||||||
if (estado === "pend" && r.procesado) return false
|
if (!term) return true
|
||||||
if (scope === "internos" && !r.interno) return false
|
return (
|
||||||
if (scope === "externos" && r.interno) return false
|
(vs.name ?? "").toLowerCase().includes(term) ||
|
||||||
|
(vs.description ?? "").toLowerCase().includes(term)
|
||||||
if (!t) return true
|
)
|
||||||
const hay =
|
|
||||||
[r.titulo_archivo, r.descripcion, r.fuente_autoridad, r.tipo_contenido, ...(r.tags ?? [])]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((v) => String(v).toLowerCase().includes(t))
|
|
||||||
return hay
|
|
||||||
})
|
})
|
||||||
}, [rows, q, estado, scope])
|
}, [vectorStores, q, statusFilter])
|
||||||
|
|
||||||
async function remove(id: string) {
|
function openDetails(vs: VectorStore) {
|
||||||
if (!confirm("¿Eliminar archivo de referencia?")) return
|
setSelected(vs)
|
||||||
const { error } = await supabase
|
setDialogOpen(true)
|
||||||
.from("documentos")
|
}
|
||||||
.delete()
|
|
||||||
.eq("documentos_id", id)
|
|
||||||
if (error) return alert(error.message)
|
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
|
||||||
|
setDeletingId(id)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
|
await callFilesAndVectorStoresApi({
|
||||||
method: "DELETE",
|
module: "vectorStores",
|
||||||
headers: { "Content-Type": "application/json" },
|
action: "delete",
|
||||||
body: JSON.stringify({ documentos_id: id }),
|
params: { id },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
await supabase
|
||||||
throw new Error("Se falló al eliminar el documento")
|
.from("vector_store_files_meta")
|
||||||
}
|
.delete()
|
||||||
} catch (err) {
|
.eq("vector_store_id", id)
|
||||||
console.error("Error al eliminar el documento:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
router.invalidate()
|
router.invalidate()
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.message ?? "Error al eliminar el repositorio")
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle className="font-mono">Archivos de referencia</CardTitle>
|
<CardTitle className="font-mono">Repositorios de archivos</CardTitle>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
<div className="relative w-full sm:w-80">
|
<div className="relative w-full sm:w-80">
|
||||||
@@ -102,240 +212,499 @@ function RouteComponent() {
|
|||||||
<Input
|
<Input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
placeholder="Buscar por título, etiqueta, fuente…"
|
placeholder="Buscar por nombre o descripción…"
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setStatusFilter(v as "all" | "completed" | "in_progress")
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectTrigger className="sm:w-[160px]">
|
<SelectTrigger className="sm:w-[160px]">
|
||||||
<SelectValue placeholder="Estado" />
|
<SelectValue placeholder="Estado" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="todos">Todos</SelectItem>
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
<SelectItem value="proc">Procesados</SelectItem>
|
<SelectItem value="completed">Completados</SelectItem>
|
||||||
<SelectItem value="pend">Pendientes</SelectItem>
|
<SelectItem value="in_progress">En proceso</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<SelectTrigger className="sm:w-[160px]">
|
<Icons.FolderPlus className="w-4 h-4 mr-2" />
|
||||||
<SelectValue placeholder="Ámbito" />
|
Nuevo repositorio
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="todos">Todos</SelectItem>
|
|
||||||
<SelectItem value="internos">Internos</SelectItem>
|
|
||||||
<SelectItem value="externos">Externos</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button onClick={() => setUploadOpen(true)}>
|
|
||||||
<Icons.Upload className="w-4 h-4 mr-2" /> Nuevo
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
{filtered.length ? (
|
||||||
{filtered.map((r) => (
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<article
|
{filtered.map((vs) => (
|
||||||
key={r.documentos_id}
|
<article
|
||||||
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
key={vs.id}
|
||||||
>
|
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||||
<header className="min-w-0">
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<header className="min-w-0 space-y-1">
|
||||||
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
|
<h3 className="font-semibold truncate">
|
||||||
{r.procesado ? "Procesado" : "Pendiente"}
|
{vs.name || "(Sin nombre)"}
|
||||||
</span>
|
</h3>
|
||||||
|
<StatusBadge status={vs.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
||||||
|
<Badge variant="outline">
|
||||||
|
Archivos: {vs.file_counts?.completed ?? 0}
|
||||||
|
</Badge>
|
||||||
|
{typeof vs.usage_bytes === "number" && (
|
||||||
|
<span>
|
||||||
|
{(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{vs.last_active_at && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icons.Clock3 className="w-3 h-3" />
|
||||||
|
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{vs.description && (
|
||||||
|
<p className="text-sm text-neutral-700 line-clamp-3">
|
||||||
|
{vs.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openDetails(vs)}>
|
||||||
|
<Icons.Eye className="w-4 h-4 mr-1" /> Abrir
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(vs.id)}
|
||||||
|
disabled={deletingId === vs.id}
|
||||||
|
>
|
||||||
|
<Icons.Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
|
</article>
|
||||||
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
|
))}
|
||||||
{r.interno != null && (
|
</div>
|
||||||
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
|
) : (
|
||||||
)}
|
<div className="text-center text-sm text-neutral-500 py-10">
|
||||||
{r.fecha_subida && (
|
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
|
||||||
<span className="inline-flex items-center gap-1">
|
</div>
|
||||||
<Icons.CalendarClock className="w-3 h-3" />
|
|
||||||
{new Date(r.fecha_subida).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{r.descripcion && (
|
|
||||||
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags
|
|
||||||
{r.tags && r.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{r.tags.map((t, i) => (
|
|
||||||
<span key={i} className="text-[10px] px-2 py-0.5 rounded-full border bg-white/60">
|
|
||||||
#{t}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between gap-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
|
||||||
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
|
|
||||||
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!filtered.length && (
|
|
||||||
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Detalle */}
|
<CreateVectorStoreDialog
|
||||||
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={() => router.invalidate()}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Subida */}
|
<VectorStoreDialog
|
||||||
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
|
store={selected}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open)
|
||||||
|
if (!open) setSelected(null)
|
||||||
|
}}
|
||||||
|
onUpdated={() => router.invalidate()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========= Subida ========= */
|
/* ====== Crear repositorio ====== */
|
||||||
function UploadDialog({
|
|
||||||
open, onOpenChange, onDone,
|
|
||||||
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
|
||||||
const supabaseAuth = useSupabaseAuth()
|
|
||||||
const [file, setFile] = useState<File | null>(null)
|
|
||||||
const [instrucciones, setInstrucciones] = useState("")
|
|
||||||
const [tags, setTags] = useState("")
|
|
||||||
const [interno, setInterno] = useState(true)
|
|
||||||
const [fuente, setFuente] = useState("")
|
|
||||||
const [subiendo, setSubiendo] = useState(false)
|
|
||||||
|
|
||||||
async function toBase64(f: File): Promise<string> {
|
function CreateVectorStoreDialog({
|
||||||
const buf = await f.arrayBuffer()
|
open,
|
||||||
const bytes = new Uint8Array(buf)
|
onOpenChange,
|
||||||
let binary = ""
|
onCreated,
|
||||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
}: {
|
||||||
return btoa(binary)
|
open: boolean
|
||||||
}
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
async function upload() {
|
async function handleCreate() {
|
||||||
if (!file) { alert("Selecciona un archivo"); return }
|
if (!name.trim()) {
|
||||||
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
|
alert("Escribe un nombre para el repositorio")
|
||||||
|
return
|
||||||
setSubiendo(true)
|
}
|
||||||
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
const fileBase64 = await toBase64(file)
|
await callFilesAndVectorStoresApi<VectorStore>({
|
||||||
// Enviamos al motor (inserta en la tabla si insert=true)
|
module: "vectorStores",
|
||||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
|
action: "create",
|
||||||
method: "POST",
|
params: { name: name.trim(), description: description.trim() || undefined },
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt: instrucciones,
|
|
||||||
fileBase64,
|
|
||||||
insert: true,
|
|
||||||
uuid: supabaseAuth.user?.id ?? null,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text()
|
|
||||||
throw new Error(txt || "Error al subir")
|
|
||||||
}
|
|
||||||
// Ajustes extra (tags, interno, fuente) si el motor no los llenó
|
|
||||||
// Intentamos leer el id que regrese el servicio; si no, solo invalidamos.
|
|
||||||
let createdId: string | null = null
|
|
||||||
try {
|
|
||||||
const payload = await res.json()
|
|
||||||
createdId =
|
|
||||||
payload?.documentos_id ||
|
|
||||||
payload?.id ||
|
|
||||||
payload?.data?.documentos_id ||
|
|
||||||
null
|
|
||||||
} catch { /* noop */ }
|
|
||||||
|
|
||||||
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
|
||||||
await supabase
|
|
||||||
.from("documentos")
|
|
||||||
.update({
|
|
||||||
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
|
||||||
fuente_autoridad: fuente.trim() || undefined,
|
|
||||||
interno,
|
|
||||||
})
|
|
||||||
.eq("documentos_id", createdId)
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
onDone()
|
setName("")
|
||||||
} catch (e: any) {
|
setDescription("")
|
||||||
alert(e?.message ?? "Error al subir el documento")
|
onCreated()
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.message ?? "Error al crear el repositorio")
|
||||||
} finally {
|
} finally {
|
||||||
setSubiendo(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-xl">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
|
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
|
Crea un Vector Store para agrupar archivos relacionados.
|
||||||
<em> procesado </em> cuando termine el flujo.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Archivo</Label>
|
<Label>Nombre</Label>
|
||||||
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
<Input
|
||||||
{file && (
|
value={name}
|
||||||
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
|
onChange={(e) => setName(e.target.value)}
|
||||||
)}
|
placeholder="Planeación curricular, Entrevistas…"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Instrucciones</Label>
|
|
||||||
<Textarea
|
|
||||||
value={instrucciones}
|
|
||||||
onChange={(e) => setInstrucciones(e.target.value)}
|
|
||||||
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
|
|
||||||
className="min-h-[120px]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Tags (separados por coma)</Label>
|
|
||||||
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Fuente de autoridad</Label>
|
|
||||||
<Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Ámbito</Label>
|
<Label>Descripción (opcional)</Label>
|
||||||
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
|
<Textarea
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
value={description}
|
||||||
<SelectContent>
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
<SelectItem value="true">Interno</SelectItem>
|
placeholder="Breve descripción del contenido de este repositorio."
|
||||||
<SelectItem value="false">Externo</SelectItem>
|
className="min-h-[80px]"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
|
Cancelar
|
||||||
{subiendo ? "Subiendo…" : "Subir"}
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||||
|
{creating ? "Creando…" : "Crear repositorio"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Detalle de un repositorio: archivos + subida ====== */
|
||||||
|
|
||||||
|
type FileRow = {
|
||||||
|
file: VectorStoreFile
|
||||||
|
meta: VectorStoreFileMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function VectorStoreDialog({
|
||||||
|
store,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onUpdated,
|
||||||
|
}: {
|
||||||
|
store: VectorStore | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onUpdated: () => void
|
||||||
|
}) {
|
||||||
|
const supabaseAuth = useSupabaseAuth()
|
||||||
|
const [files, setFiles] = useState<FileRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [label, setLabel] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !store) return
|
||||||
|
void refreshFiles()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, store?.id])
|
||||||
|
|
||||||
|
async function refreshFiles() {
|
||||||
|
if (!store) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const vectorFiles = await callFilesAndVectorStoresApi<VectorStoreFile[]>({
|
||||||
|
module: "vectorStoreFiles",
|
||||||
|
action: "list",
|
||||||
|
params: { vector_store_id: store.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: metaRows, error: metaError } = await supabase
|
||||||
|
.from("vector_store_files_meta")
|
||||||
|
.select("*")
|
||||||
|
.eq("vector_store_id", store.id)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
if (metaError) throw metaError
|
||||||
|
|
||||||
|
const meta = (metaRows ?? []) as VectorStoreFileMeta[]
|
||||||
|
|
||||||
|
const merged: FileRow[] = (vectorFiles ?? []).map((vf) => ({
|
||||||
|
file: vf,
|
||||||
|
meta: meta.find((m) => m.openai_file_id === vf.id) ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setFiles(merged)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err?.message ?? "No se pudieron cargar los archivos")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toBase64(f: File): Promise<string> {
|
||||||
|
const buf = await f.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buf)
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!store || !file) {
|
||||||
|
alert("Selecciona un archivo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const fileBase64 = await toBase64(file)
|
||||||
|
|
||||||
|
// 1) Subir archivo a OpenAI vía Edge (módulo files)
|
||||||
|
const uploaded: any = await callFilesAndVectorStoresApi<any>({
|
||||||
|
module: "files",
|
||||||
|
action: "upload",
|
||||||
|
params: {
|
||||||
|
fileBase64,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const openaiFileId: string =
|
||||||
|
uploaded?.id ?? uploaded?.file?.id ?? uploaded?.data?.id
|
||||||
|
|
||||||
|
if (!openaiFileId) {
|
||||||
|
throw new Error("La Edge Function no devolvió el id del archivo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Mapear archivo al Vector Store
|
||||||
|
await callFilesAndVectorStoresApi<any>({
|
||||||
|
module: "vectorStoreFiles",
|
||||||
|
action: "create",
|
||||||
|
params: {
|
||||||
|
vector_store_id: store.id,
|
||||||
|
file_id: openaiFileId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) Guardar metadata en Supabase
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from("vector_store_files_meta")
|
||||||
|
.insert({
|
||||||
|
user_id: supabaseAuth.user?.id ?? null,
|
||||||
|
vector_store_id: store.id,
|
||||||
|
openai_file_id: openaiFileId,
|
||||||
|
label: label.trim() || file.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (insertError) throw insertError
|
||||||
|
|
||||||
|
setFile(null)
|
||||||
|
setLabel("")
|
||||||
|
await refreshFiles()
|
||||||
|
onUpdated()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err?.message ?? "Error al subir el archivo")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteFile(fileId: string) {
|
||||||
|
if (!store) return
|
||||||
|
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await callFilesAndVectorStoresApi<any>({
|
||||||
|
module: "vectorStoreFiles",
|
||||||
|
action: "delete",
|
||||||
|
params: {
|
||||||
|
vector_store_id: store.id,
|
||||||
|
file_id: fileId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Opcional: eliminar también el archivo global de OpenAI
|
||||||
|
await callFilesAndVectorStoresApi<any>({
|
||||||
|
module: "files",
|
||||||
|
action: "delete",
|
||||||
|
params: { id: fileId },
|
||||||
|
})
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("vector_store_files_meta")
|
||||||
|
.delete()
|
||||||
|
.eq("openai_file_id", fileId)
|
||||||
|
|
||||||
|
await refreshFiles()
|
||||||
|
onUpdated()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err?.message ?? "Error al eliminar el archivo")
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Folder className="h-4 w-4" />
|
||||||
|
{store.name || "(Sin nombre)"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Gestiona los archivos asociados a este repositorio (Vector Store).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
|
||||||
|
<StatusBadge status={store.status} />
|
||||||
|
<Badge variant="outline">
|
||||||
|
Archivos completados: {store.file_counts?.completed ?? 0}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Total archivos: {store.file_counts?.total ?? 0}
|
||||||
|
</Badge>
|
||||||
|
{typeof store.usage_bytes === "number" && (
|
||||||
|
<span>{(store.usage_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subida de archivo */}
|
||||||
|
<div className="space-y-2 rounded-lg border bg-muted/50 p-4">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Agregar archivo al repositorio
|
||||||
|
</Label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{file && (
|
||||||
|
<div className="text-xs text-neutral-600">
|
||||||
|
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Título / etiqueta</Label>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="Ej.: Plan 2025, Entrevista 3…"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="mt-2 w-full sm:w-auto"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading || !file}
|
||||||
|
>
|
||||||
|
{uploading ? "Subiendo…" : "Subir al repositorio"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de archivos */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Archivos en este repositorio</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refreshFiles()}
|
||||||
|
disabled={loading || refreshing}
|
||||||
|
>
|
||||||
|
<Icons.RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-xs text-neutral-500 py-4">
|
||||||
|
Cargando archivos…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-xs text-red-500 py-4">{error}</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="text-xs text-neutral-500 py-4">
|
||||||
|
Todavía no hay archivos en este repositorio ⚡
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||||
|
{files.map(({ file, meta }) => (
|
||||||
|
<li
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate">
|
||||||
|
{meta?.label || file.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500 truncate">
|
||||||
|
{new Date(file.created_at * 1000).toLocaleString()} ·{" "}
|
||||||
|
{(file.usage_bytes / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={file.status} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteFile(file.id)}
|
||||||
|
>
|
||||||
|
<Icons.Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cerrar
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user