diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..144d26b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy to Azure Static Web Apps + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build + env: + VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }} + VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }} + VITE_BACK_ORIGIN: ${{ vars.VITE_BACK_ORIGIN }} + run: bun run build + + # No hace falta instalar el CLI globalmente, usamos bunx + - name: Deploy to Azure Static Web Apps + env: + AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }} + run: | + bunx @azure/static-web-apps-cli deploy ./dist \ + --env production \ + --deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN" diff --git a/.gitignore b/.gitignore index 693a705..c57c17b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist dist-ssr *.local count.txt -.env +.env* .nitro .tanstack +.cta.json diff --git a/src/components/ai/AIChatModal.jsx b/src/components/ai/AIChatModal.jsx index b97769b..07d69f5 100644 --- a/src/components/ai/AIChatModal.jsx +++ b/src/components/ai/AIChatModal.jsx @@ -1,73 +1,89 @@ import React, { useEffect, useRef, useState } from "react"; import { supabase } from "@/auth/supabase"; +import ReactMarkdown from "react-markdown"; -// ---------------- UI MOCKS ---------------- // -// Puedes reemplazarlos por tus propios componentes UI +/* ---------- UI Mocks (sin cambios) ---------- */ const Paperclip = (props) => ( - + ); const Dialog = ({ open, onOpenChange, children }) => open ?
{children}
: null; - const DialogContent = ({ className, children }) => -
e.stopPropagation()}> - {children} -
; - +
e.stopPropagation()}>{children}
; const DialogHeader = ({ children }) =>
{children}
; const DialogTitle = ({ className, children }) =>

{children}

; - const Button = ({ onClick, disabled, className, variant, children }) => ( - ); - const Card = ({ className, children }) =>
{children}
; const CardContent = ({ className, children }) =>
{children}
; const ScrollArea = ({ className, children }) =>
{children}
; -// ---------------- COMPONENTE ---------------- // -export default function AIChatModal({ open, onClose, context, onAccept }) { +/* ------------- COMPONENT ------------- */ +export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) { + const [vectorStores, setVectorStores] = useState([]); - const [vectorFiles, setVectorFiles] = useState([]); + const [vectorFiles, setVectorFiles] = useState([]); + const [selectedVector, setSelectedVector] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); - const [attachedFile, setAttachedFile] = useState(null); - const [attachedPreview, setAttachedPreview] = useState(null); + const [attachedFiles, setAttachedFiles] = useState([]); + const [attachedPreviews, setAttachedPreviews] = useState([]); + // chat const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); + // loading states const [loading, setLoading] = useState(false); const [loadingFiles, setLoadingFiles] = useState(false); - const [selectedVector, setSelectedVector] = useState(null); + const [loadingVectors, setLoadingVectors] = useState(false); + + // conversation control + const [conversationId, setConversationId] = useState(null); + const [creatingConversation, setCreatingConversation] = useState(false); const messagesEndRef = useRef(null); const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - useEffect(scrollToBottom, [messages]); - // ------------------------------------ - // Reset al abrir o cerrar modal - // ------------------------------------ + const normalizeInvokeResponse = (resp) => { + if (!resp) return null; + const raw = resp.data; + if (typeof raw === "string") { + try { return JSON.parse(raw); } catch (e) { console.warn("❗ No se pudo parsear resp.data:", raw); return null; } + } + if (typeof raw === "object" && raw !== null) return raw; + return null; + }; + + // Al abrir: reset o crear conversación useEffect(() => { if (!open) { + if (conversationId) { + deleteConversation(conversationId).catch((e) => console.error(e)); + } setMessages([]); setInput(""); - setAttachedFile(null); - setAttachedPreview(null); - setVectorStores([]); - setVectorFiles([]); + setSelectedFiles([]); + setAttachedFiles([]); + setAttachedPreviews([]); + setConversationId(null); setSelectedVector(null); + setVectorFiles([]); return; } @@ -75,298 +91,534 @@ export default function AIChatModal({ open, onClose, context, onAccept }) { setMessages([ { role: "system", - content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`, - }, + //content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}` + content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}` + } ]); } - }, [open, context]); - // ------------------------------------ - // Cargar vector stores - // ------------------------------------ - useEffect(() => { - if (!open) return; + (async () => { + await createConversation(); + 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]); - // ------------------------------------ - // Cargar archivos del vector seleccionado - // ------------------------------------ + // ---------- CREATE CONVERSATION ---------- + const createConversation = async () => { + try { + setCreatingConversation(true); + const { data: { session } } = await supabase.auth.getSession(); + const token = session?.access_token; + + const resp = await supabase.functions.invoke("conversation-format", { + headers: { Authorization: `Bearer ${token}` }, + body: { action: "start", role: "system", content: context?.cont_conversation ?? "" } + }); + + let parsed = null; + if (typeof resp?.data === "string") { + try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; } + } else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data; + else parsed = resp; + + const convId = + parsed?.conversationId || + parsed?.data?.conversationId || + parsed?.data?.id || + parsed?.id || + parsed?.conversation_id || + parsed?.data?.conversation_id; + + if (!convId) { setCreatingConversation(false); return; } + setConversationId(convId); + } catch (err) { + console.error("Error creando conversación:", err); + } finally { + setCreatingConversation(false); + } + }; + + // ---------- DELETE CONVERSATION ---------- + const deleteConversation = async (convIdParam) => { + try { + const convIdToUse = convIdParam ?? conversationId; + if (!convIdToUse) return; + const { data: { session } } = await supabase.auth.getSession(); + const token = session?.access_token; + + await supabase.functions.invoke("conversation-format", { + headers: { Authorization: `Bearer ${token}` }, + body: { action: "end", conversationId: convIdToUse } + }); + + setConversationId(null); + } catch (err) { + console.error("Error eliminando conversación:", err); + } + }; + + // ---------- CONVERT FILE TO BASE64 ---------- + const fileToBase64 = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = (e) => reject(e); + reader.onload = () => resolve(reader.result.split(",")[1]); + reader.readAsDataURL(file); + }); + + // ---------- HANDLE CONVERSATION (envío) ---------- + const handleConversation = async ({ text }) => { + let contextText = ""; + if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`; + + if (!conversationId) { + console.warn("No hay conversación activa todavía. conversationId:", conversationId); + return; + } + + try { + setLoading(true); + const { data: { session } } = await supabase.auth.getSession(); + const token = session?.access_token; + + // archivos adjuntos (locales) -> base64 + let filesInput = []; + if (attachedFiles.length > 0) { + for (const file of attachedFiles) { + const base64 = await fileToBase64(file); + filesInput.push({ + type: "input_file", + filename: file.name, + file_data: `data:${file.type};base64,${base64}` + }); + } + } + + // archivos seleccionados del vector (por id) + if (selectedFiles.length > 0) { + const filesFromVectors = selectedFiles.map(f => ({ + type: "input_file", + file_id: f.id + })); + filesInput = [...filesInput, ...filesFromVectors]; + } + + const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`; + const payload = { + action: "message", + format: plan_format, + conversationId, + vectorStoreId: selectedVector ?? null, + fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [], + input: [ + { + role: "user", + content: [ + { type: "input_text", text: promptFinal }, + ...filesInput + ] + } + ] + }; + + const { data: invokeData, error } = await supabase.functions.invoke( + "conversation-format", + { + headers: { Authorization: `Bearer ${token}` }, + body: payload + } + ); + + if (error) throw error; + const parsed = normalizeInvokeResponse({ data: invokeData }); + + // Extraer texto del assistant (robusto) + let assistantText = null; + if (parsed?.data?.output_text) assistantText = parsed.data.output_text; + if (!assistantText && Array.isArray(parsed?.data?.output)) { + const msgBlock = parsed.data.output.find(o => o.type === "message"); + if (msgBlock?.content?.[0]?.text) assistantText = msgBlock.content[0].text; + } + assistantText = assistantText || "Sin respuesta del modelo."; + + setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]); + + // limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos) + setAttachedFiles([]); + setAttachedPreviews([]); + // si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea: + setSelectedFiles([]); + + } catch (err) { + console.error("Error en handleConversation:", err); + setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]); + } finally { + setLoading(false); + } + }; + + // ---------- VECTORES ---------- + const fetchVectorStores = async () => { + try { + setLoadingVectors(true); + const { data: { session } } = await supabase.auth.getSession(); + const token = session?.access_token; + + const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", { + headers: { Authorization: `Bearer ${token}` }, + body: { module: "vectorStores", action: "list" } + }); + + if (error) throw error; + setVectorStores(Array.isArray(data) ? data : (data?.data ?? [])); + } catch (err) { + console.error("Error loading vector stores:", err); + setVectorStores([]); + } finally { + setLoadingVectors(false); + } + }; + + useEffect(() => { + if (open) fetchVectorStores(); + }, [open]); + const loadFilesForVector = async (vectorStoreId) => { try { setLoadingFiles(true); const { data: { session } } = await supabase.auth.getSession(); const token = session?.access_token; - const { data, error } = await supabase.functions.invoke( - "files-and-vector-stores-api", - { - headers: { Authorization: `Bearer ${token}` }, - body: { - module: "vectorStoreFiles", - action: "list", - params: { vector_store_id: vectorStoreId }, - }, - } - ); + const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", { + headers: { Authorization: `Bearer ${token}` }, + body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } } + }); if (error) throw error; - setVectorFiles(Array.isArray(data) ? data : []); + setVectorFiles(Array.isArray(data) ? data : (data?.data ?? [])); } catch (err) { - console.error("Error al obtener archivos del vector store:", err); + console.error("Error loading vector files:", err); setVectorFiles([]); } finally { setLoadingFiles(false); } }; - // ------------------------------------ - // Adjuntar archivo - // ------------------------------------ + // ---------- UI helpers ---------- const handleAttach = (e) => { - const file = e.target.files?.[0]; - if (!file) return; - setAttachedFile(file); - setAttachedPreview(file.name); + const files = Array.from(e.target.files); + if (!files.length) return; + setAttachedFiles(prev => [...prev, ...files]); + setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]); }; - // ------------------------------------ - // handleSend — versión final para Supabase Edge Function - // ------------------------------------ - const handleSend = async () => { - if (!input.trim() && !attachedFile) return; - - // Construir texto del mensaje del usuario - const userMessage = input.trim() - ? input.trim() - : attachedFile - ? `Consulta sobre archivo: ${attachedFile.name}` - : ""; - - // Agregar mensaje al chat - setMessages(prev => [...prev, { role: "user", content: userMessage }]); - setInput(""); - - setLoading(true); - - try { - const formData = new FormData(); - - const fullPrompt = ` -${context?.section ? `Sección: ${context.section}` : ""} -${context?.fieldKey ? `Campo: ${context.fieldKey}` : ""} - -Texto original: -${context?.originalText || "Sin texto original"} - -Solicitud del usuario: -${userMessage} - -Responde con una versión mejorada en texto directo, sin explicaciones. - `.trim(); - - formData.append("prompt", fullPrompt); - if (attachedFile) formData.append("file", attachedFile); - - const { data, error } = await supabase.functions.invoke( - "simple-chat", - { body: formData } - ); - - if (error) throw error; - - // Respuesta de la IA - setMessages(prev => [ - ...prev, - { role: "assistant", content: data?.text || "Sin respuesta del modelo." } - ]); - - } catch (err) { - console.error("Error enviando mensaje:", err); - setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al conectar con la IA." }]); + // 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; } - setLoading(false); - setAttachedFile(null); - setAttachedPreview(null); + setSelectedVector(vector.id); + setSelectedFiles([]); + await loadFilesForVector(vector.id); + }; + + // Toggle selección de archivo (checkbox) + const toggleFileSelection = (file) => { + if (selectedFiles.some(f => f.id === file.id)) { + setSelectedFiles(prev => prev.filter(f => f.id !== file.id)); + } else { + setSelectedFiles(prev => [...prev, file]); + } + }; + + const removeSelectedFile = (fileId) => { + setSelectedFiles(prev => prev.filter(f => f.id !== fileId)); + }; + + // ---------- Send flow ---------- + const handleSend = async () => { + // no permitir enviar si no hay nada + if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return; + + if (creatingConversation) { + // no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando + return; + } + + if (!conversationId) { + await createConversation(); + if (!conversationId) { + setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]); + return; + } + } + + const userText = input.trim() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : ""); + setMessages(prev => [...prev, { role: "user", content: userText }]); + setInput(""); + + await handleConversation({ text: userText }); + }; + + function cleanAIResponse(text) { + if (!text) return text; + + let cleaned = text; + + // ------------------------- + // 1. Eliminar emojis + // ------------------------- + cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, ""); + + // ------------------------- + // 2. Eliminar separadores tipo --- + // ------------------------- + cleaned = cleaned.replace(/^---+$/gm, ""); + + // ------------------------- + // 3. Eliminar saludos y frases meta + // ------------------------- + const metaPatterns = [ + /^hola[!¡., ]*/i, + /^buen(os|as) (días|tardes|noches)[!¡., ]*/i, + /estoy aquí para ayudarte[.! ]*/gi, + /aquí tienes[,:]*/gi, + /claro[,:]*/gi, + /como pediste[,:]*/gi, + /como solicitaste[,:]*/gi, + /el texto íntegro que compartiste.*$/gi, + /te lo dejo a continuación.*$/gi, + /¿te gustaría.*$/gi, + /¿en qué más puedo.*$/gi, + /si necesitas algo más.*$/gi, + /con gusto.*$/gi, + ]; + + metaPatterns.forEach(p => { + cleaned = cleaned.replace(p, "").trim(); + }); + + // ------------------------- + // 4. Extraer solo contenido útil + // ------------------------- + const startMarker = "CONTEXTO DEL CAMPO"; + const startIndex = cleaned.indexOf(startMarker); + + if (startIndex !== -1) { + cleaned = cleaned.substring(startIndex).trim(); + } + + // ------------------------- + // 5. Eliminar líneas vacías múltiples + // ------------------------- + cleaned = cleaned.replace(/\n{2,}/g, "\n\n"); + + // ------------------------- + // 6. Quitar numeraciones de cortesía (opcional) + // Ejemplo: “1. ” al inicio de líneas + // ------------------------- + cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, ""); + + return cleaned.trim(); +} + + +const handleApply = () => { + const last = [...messages].reverse().find(m => m.role === "assistant"); + + if (last && onAccept) { + const cleaned = cleanAIResponse(last.content); + onAccept(cleaned); + onClose(); + } +}; + + + + const cleanAssistantResponse = (text) => { + if (!text) return text; + const patterns = [/^claro[, ]*/i, /^por supuesto[, ]*/i, /^aquí tienes[, ]*/i, /^con gusto[, ]*/i, /^hola[, ]*/i, /^perfecto[, ]*/i, /^entendido[, ]*/i, /^muy bien[, ]*/i, /^ok[, ]*/i]; + let cleaned = text.trim(); + for (const p of patterns) cleaned = cleaned.replace(p, "").trim(); + return cleaned; }; - // ------------------------------------ - // UI - // ------------------------------------ return ( - - + + + Asistente Inteligente -
-
- - {/* LEFT: VECTOR STORES */} - - -

Vector Stores

- +
+
+ {/* Left: vectors */} + + +

Repositorio de archivos

- {loading ? ( -

Cargando...

+ {loadingVectors ? ( +

Cargando Repositorio de archivos...

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

No hay vector stores

+

No hay Repositorio de archivos.

) : ( -
    - {vectorStores.map(store => ( -
  • { - setSelectedVector(store); - loadFilesForVector(store.id); - }} - className={`border p-2 rounded-lg cursor-pointer - ${selectedVector?.id === store.id ? "bg-blue-100" : "bg-white"}`} - > - {store.name || store.id} -

    {store.description}

    -
  • +
    + {vectorStores.map((vector) => ( +
    + {/* 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) => ( + + )) + )} +
    + )} +
    ))} -
- )} -
- -

Archivos

- - {loadingFiles ? ( -

Cargando archivos...

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

No hay archivos

- ) : ( -
    - {vectorFiles.map(f => ( -
  • - {f.id} -
  • - ))} -
- )} -
-
-
- - {/* RIGHT: CHAT */} - - -

Chat

- -
- {messages.length === 0 ? ( -

Inicia la conversación

- ) : ( - messages.map((msg, idx) => ( -
- {msg.role === "user" ? "Tú:" : "IA:"} -

{msg.content}

-
- )) - )} - - {loading && ( -
- La IA está respondiendo...
)} -
+ + + {/* 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 */} + +
    +
  • + ))} +
+ )}
- {attachedPreview && ( -
- - {attachedPreview} - + {/*
+ +
*/} + + + + {/* 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} +
+ )) + )} + + {loading && ( +
+ + + + + La IA está respondiendo... +
+ )} + +
+
+ + {attachedPreviews.length > 0 && ( +
    + {attachedPreviews.map((name, i) => ( +
  • 📄 {name}
  • + ))} +
)} - {/* Input */} -
-