Se agrega funcionalidades de crear conversacion, archivos y vectores ademas de MCP

This commit is contained in:
2025-11-24 16:17:49 -06:00
parent c9d66ce2e5
commit 4ec2c2d533
3 changed files with 401 additions and 224 deletions

4
.env.local2 Normal file
View File

@@ -0,0 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
#VITE_BACK_ORIGIN=http://localhost:3001
VITE_BACK_ORIGIN=http://localhost:3001

View File

@@ -1,45 +1,41 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase"; import { supabase } from "@/auth/supabase";
// ---------------- 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,planId }) {
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 [attachedFile, setAttachedFile] = useState(null);
const [attachedPreview, setAttachedPreview] = useState(null); const [attachedPreview, setAttachedPreview] = useState(null);
@@ -49,179 +45,368 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
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(planId);
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("");
setSelectedVectorFile(null);
setAttachedFile(null); setAttachedFile(null);
setAttachedPreview(null); setAttachedPreview(null);
setVectorStores([]); setConversationId(null);
setVectorFiles([]);
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();
})();
}, [open]);
// --------- CREATE CONVERSATION (robusto) ----------
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" }
});
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;
}
const fetchVectorStores = async () => {
try { try {
setLoading(true); setLoading(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( let filesInput = [];
"files-and-vector-stores-api", console.log(attachedFile);
if (attachedFile) {
const base64 = await fileToBase64(attachedFile);
console.log(attachedFile);
filesInput.push({
type: "input_file",
filename: attachedFile.name,
file_data: `data:application/pdf;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
]
}
]
};
console.log("handleConversation -> payload:", payload);
const { data: invokeData, error } = await supabase.functions.invoke(
"modal-conversation",
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { module: "vectorStores", action: "list" }, body: payload
} }
); );
if (error) throw error; if (error) throw error;
setVectorStores(Array.isArray(data) ? data : []);
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.";
console.log("💬 Respuesta detectada:", assistantText);
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
setAttachedFile(null);
setAttachedPreview(null);
} catch (err) { } catch (err) {
console.error("Error al obtener vector stores:", err); console.error("Error en handleConversation:", err);
setVectorStores([]); setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchVectorStores(); // ---------- 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]); }, [open]);
// ------------------------------------
// Cargar archivos del vector seleccionado
// ------------------------------------
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}` }, headers: { Authorization: `Bearer ${token}` },
body: { body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
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 handleAttach = (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
console.log(file);
if (!file) return; if (!file) return;
setAttachedFile(file); setAttachedFile(file);
setAttachedPreview(file.name); setAttachedPreview(file.name);
}; };
// ------------------------------------ const handleSelectVectorFile = (file) => {
// handleSend — versión final para Supabase Edge Function setSelectedVectorFile(file);
// ------------------------------------ };
const handleSend = async () => {
if (!input.trim() && !attachedFile) return; // ---------- Send flow ----------
const handleSend = async () => {
// Construir texto del mensaje del usuario if (!input.trim() && !attachedFile && !selectedVectorFile) return;
const userMessage = input.trim()
? input.trim() // esperar si aún se está creando la conversación
: attachedFile if (creatingConversation) {
? `Consulta sobre archivo: ${attachedFile.name}` console.log("Esperando a que se cree la conversación...");
: ""; // opcional: podrías mostrar un toast; aquí simplemente retornamos
return;
// Agregar mensaje al chat }
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
setInput(""); if (!conversationId) {
console.warn("No hay conversationId — intentaremos crear una ahora.");
setLoading(true); await createConversation();
if (!conversationId) {
try { setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
const formData = new FormData(); return;
}
const fullPrompt = ` }
${context?.section ? `Sección: ${context.section}` : ""}
${context?.fieldKey ? `Campo: ${context.fieldKey}` : ""} const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
setMessages(prev => [...prev, { role: "user", content: userText }]);
Texto original: setInput("");
${context?.originalText || "Sin texto original"}
await handleConversation({ text: userText });
Solicitud del usuario: };
${userMessage}
const handleApply = () => {
Responde con una versión mejorada en texto directo, sin explicaciones. const last = [...messages].reverse().find(m => m.role === "assistant");
`.trim(); if (last && onAccept) {
onAccept(last.content);
formData.append("prompt", fullPrompt); onClose();
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);
setAttachedFile(null);
setAttachedPreview(null);
}; };
// ------------------------------------
// UI
// ------------------------------------
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">
<DialogHeader> <DialogHeader>
<DialogTitle>Asistente Inteligente</DialogTitle> <DialogTitle>Asistente Inteligente</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -229,82 +414,85 @@ Responde con una versión mejorada en texto directo, sin explicaciones.
<div className="flex-1 overflow-y-auto pt-4"> <div className="flex-1 overflow-y-auto pt-4">
<div className="flex gap-6 min-h-full"> <div className="flex gap-6 min-h-full">
{/* 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} <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> </li>
))} ))}
</ul> </ul>
)} </div>
</ScrollArea>
<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">
<h3 className="font-semibold text-sm mb-3">Chat</h3> <h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
<div className="flex-1 overflow-y-auto border p-3 rounded-lg bg-gray-50 space-y-3"> <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 ? ( {messages.length === 0 ? (
<p className="text-gray-400 text-center text-sm">Inicia la conversación</p> <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 <div>{m.content}</div>
${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>
)} )}
@@ -312,61 +500,46 @@ Responde con una versión mejorada en texto directo, sin explicaciones.
</div> </div>
{attachedPreview && ( {attachedPreview && (
<div className="flex items-center mt-2 p-2 border rounded-lg bg-gray-100 text-sm"> <div className="flex items-center justify-between mt-2 p-3 border border-gray-300 rounded-xl text-sm bg-gray-100 shadow-inner flex-shrink-0">
<span className="truncate flex items-center gap-2 text-gray-700">
<Paperclip className="w-4 h-4 text-blue-500" /> <Paperclip className="w-4 h-4 text-blue-500" />
<span className="ml-2">{attachedPreview}</span> {attachedPreview}
<button </span>
onClick={() => { setAttachedFile(null); setAttachedPreview(null); }} <Button variant="outline" className="text-red-500" onClick={() => { setAttachedFile(null); setAttachedPreview(null); }}>Quitar</Button>
className="ml-auto text-red-500 text-xs"
>
Quitar
</button>
</div> </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" 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() && !attachedFile && !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>

View File

@@ -310,8 +310,8 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
<AIChatModal <AIChatModal
open={openModalIa} open={openModalIa}
planId={planId}
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,