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 ( - - - {/* Botón siempre visible */} - + + + Asistente Inteligente
- {/* Left: vectors */} -

Vector Stores

+

Repositorio de archivos

{loadingVectors ? ( -

Cargando vector stores...

+

Cargando Repositorio de archivos...

) : vectorStores.length === 0 ? ( -

No hay vector stores.

+

No hay Repositorio de archivos.

) : ( -
    +
    {vectorStores.map((vector) => ( -
  • loadFilesForVector(vector.id)} - className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white" - > - {vector.name || vector.id} -

    {vector.description || vector.id}

    +
    + {/* VECTOR */} +
    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"}`} + > +
    + {vector.name || vector.id} +

    {vector.description || ""}

    +
    +
    {selectedVector === vector.id ? "▼" : "▶"}
    +
    + + {/* ARCHIVOS cuando está expandido */} + {selectedVector === vector.id && ( +
    + {loadingFiles ? ( +

    Cargando archivos...

    + ) : vectorFiles.length === 0 ? ( +

    No hay archivos en este repositorio

    + ) : ( + vectorFiles.map((file) => ( + + )) + )} +
    + )} +
    + ))} +
  • + )} + + + + {/* Resumen de archivos seleccionados (de vectores) */} +
    +

    Archivos seleccionados

    + {selectedFiles.length === 0 ? ( +

    No has seleccionado archivos del repositorio

    + ) : ( +
      + {selectedFiles.map((f) => ( +
    • +
      +
      {f.filename ?? f.name ?? f.id}
      +
      {f.id}
      +
      +
      + {/* optionally show vector id */} + +
    • ))}
    )} - - -
    -

    Archivos del Vector

    - {loadingFiles ? ( -

    Cargando archivos...

    - ) : selectedVectorFile ? ( -
    - Seleccionado: {selectedVectorFile.filename ?? selectedVectorFile.id} -
    - ) : ( -

    Selecciona un archivo del vector

    - )} - -
      - {vectorFiles.map((file) => ( -
    • handleSelectVectorFile(file)} - className={`p-2 rounded-lg cursor-pointer border ${selectedVectorFile?.id === file.id ? "bg-blue-50 border-blue-300" : "bg-white"}`} - > -
      {file.filename}
      -
      {file.id}
      -
    • - ))} -
    -
    + {/*
    -
    +
    */} {/* Right: Chat */} -

    Chat con IA

    {/* CONTENEDOR SCROLL DE LOS MENSAJES */}
    {messages.length === 0 ? ( -

    Inicia una conversación...

    - ) : ( - messages.map((m, i) => ( -
    - {m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}{" "} - {m.content} - +

    Inicia una conversación...

    + ) : ( + messages.map((m, i) => ( +
    + {m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}{" "} + {m.content} +
    + )) + )} + + {loading && ( +
    + + + + + La IA está respondiendo...
    - )) - )} + )} - {loading && ( -
    - - - - - La IA está respondiendo... -
    - )} - -
    -
    +
    +
    {attachedPreviews.length > 0 && ( @@ -552,7 +591,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for