diff --git a/src/components/ai/AIChatModal.jsx b/src/components/ai/AIChatModal.jsx
index 6858f6d..07d69f5 100644
--- a/src/components/ai/AIChatModal.jsx
+++ b/src/components/ai/AIChatModal.jsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase";
-import ReactMarkdown from "react-markdown"
+import ReactMarkdown from "react-markdown";
/* ---------- UI Mocks (sin cambios) ---------- */
const Paperclip = (props) => (
@@ -34,152 +34,112 @@ const ScrollArea = ({ className, children }) =>
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
useEffect(scrollToBottom, [messages]);
const normalizeInvokeResponse = (resp) => {
- if (!resp) return null;
-
- // cuando invocas funciones, Supabase siempre regresa:
- // { data: "...string...", error: null, response: {} }
- const raw = resp.data;
-
- if (typeof raw === "string") {
- try {
- return JSON.parse(raw);
- } catch (e) {
- console.warn("❗ No se pudo parsear resp.data:", raw);
- return null;
+ 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; }
}
- }
-
- // si ya viene como objeto
- if (typeof raw === "object" && raw !== null) return raw;
-
- return null;
-};
-
+ if (typeof raw === "object" && raw !== null) return raw;
+ return null;
+ };
// Al abrir: reset o crear conversación
useEffect(() => {
- console.log(context.cont_conversation);
- console.log(context);
-
if (!open) {
- // si ya existe una conversación la eliminamos
if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e));
}
setMessages([]);
setInput("");
- setSelectedVectorFile(null);
+ setSelectedFiles([]);
setAttachedFiles([]);
setAttachedPreviews([]);
setConversationId(null);
+ setSelectedVector(null);
+ setVectorFiles([]);
return;
}
- // inyectar contexto como system message
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 || "—"}`
}
]);
- } else {
- setMessages(prev => prev); // no hacer nada si no hay contexto
}
- // crear conversación y esperar a que termine antes de permitir enviar
(async () => {
await createConversation();
- // tras crear podemos también cargar vector stores
fetchVectorStores();
})();
}, [open]);
- // --------- CREATE CONVERSATION (robusto) ----------
+ // ---------- CREATE CONVERSATION ----------
const createConversation = async () => {
- try {
- setCreatingConversation(true);
+ try {
+ setCreatingConversation(true);
+ const { data: { session } } = await supabase.auth.getSession();
+ const token = session?.access_token;
- 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 ?? "" }
+ });
- // llamada
- 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;
- console.log("createConversation -> raw resp:", resp);
+ const convId =
+ parsed?.conversationId ||
+ parsed?.data?.conversationId ||
+ parsed?.data?.id ||
+ parsed?.id ||
+ parsed?.conversation_id ||
+ parsed?.data?.conversation_id;
- // 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);
+ if (!convId) { setCreatingConversation(false); return; }
+ setConversationId(convId);
+ } catch (err) {
+ console.error("Error creando conversación:", err);
+ } finally {
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) ----------
+ // ---------- DELETE CONVERSATION ----------
const deleteConversation = async (convIdParam) => {
try {
const convIdToUse = convIdParam ?? conversationId;
@@ -187,13 +147,11 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
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("conversation-format", {
+ await supabase.functions.invoke("conversation-format", {
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);
@@ -209,13 +167,13 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
reader.readAsDataURL(file);
});
- // ---------- SEND MESSAGE (usa conversationId) ----------
+ // ---------- HANDLE CONVERSATION (envío) ----------
const handleConversation = async ({ text }) => {
- console.log(plan_format);
-
+ 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);
- // si no hay conv, opcionalmente intentar crear una sin que el usuario note
return;
}
@@ -224,43 +182,46 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
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}`
- });
- }
-}
-
- if (selectedVectorFile) {
- // si el archivo del vector viene sólo con id
- filesInput.push({
- type: "input_file",
- file_id: selectedVectorFile.id
- });
+ 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: selectedVectorFile?.vector_store_id ?? null,
- fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
+ vectorStoreId: selectedVector ?? null,
+ fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
input: [
{
role: "user",
content: [
- { type: "input_text", text },
+ { type: "input_text", text: promptFinal },
...filesInput
]
}
]
};
+
const { data: invokeData, error } = await supabase.functions.invoke(
"conversation-format",
{
@@ -270,40 +231,24 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
);
if (error) throw error;
-
- console.log("handleConversation -> RAW invokeData:", invokeData);
-
const parsed = normalizeInvokeResponse({ data: invokeData });
- console.log("handleConversation -> PARSED:", parsed);
- // 🔥 EXTRACTOR DEFINITIVO
+ // Extraer texto del assistant (robusto)
let assistantText = null;
-
- // 1) directo
- if (parsed?.data?.output_text) {
- assistantText = parsed.data.output_text;
- }
-
- // 2) buscar el message
+ 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;
- }
+ 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) }
- ]);
-
+ 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);
@@ -361,32 +306,52 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
};
// ---------- UI helpers ----------
- const handleAttach = (e) => {
- const files = Array.from(e.target.files);
- if (!files.length) return;
+ 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)]);
+ };
- 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);
+ };
- const handleSelectVectorFile = (file) => {
- setSelectedVectorFile(file);
+ // 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 () => {
- if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
+ // no permitir enviar si no hay nada
+ if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
- // esperar si aún se está creando la conversación
if (creatingConversation) {
- console.log("Esperando a que se cree la conversación...");
- // opcional: podrías mostrar un toast; aquí simplemente retornamos
+ // no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
return;
}
if (!conversationId) {
- console.warn("No hay conversationId — intentaremos crear una ahora.");
await createConversation();
if (!conversationId) {
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
@@ -394,151 +359,225 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
}
}
- const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
+ 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 });
};
- const handleApply = () => {
- const last = [...messages].reverse().find(m => m.role === "assistant");
- if (last && onAccept) {
- onAccept(last.content);
- onClose();
- }
- };
- const cleanAssistantResponse = (text) => {
+ 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;
-
- // 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,
- ];
-
+ 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();
- }
-
+ for (const p of patterns) cleaned = cleaned.replace(p, "").trim();
return cleaned;
};
-
return (
-