Compare commits
11 Commits
componente
...
29231206c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 29231206c0 | |||
| 93c79eee77 | |||
| 6f97a83eb0 | |||
| 4ec2c2d533 | |||
| efe7faa65f | |||
| c9d66ce2e5 | |||
| f7a29ad510 | |||
| e7a47f56f8 | |||
| 214d17cf98 | |||
| 8c890d76e0 | |||
| 6d264a8214 |
4
.env.local2
Normal file
4
.env.local2
Normal 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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,6 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
count.txt
|
count.txt
|
||||||
.env
|
.env*
|
||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
|
|||||||
585
src/components/ai/AIChatModal.jsx
Normal file
585
src/components/ai/AIChatModal.jsx
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { supabase } from "@/auth/supabase";
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
|
||||||
|
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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()}>{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 Button = ({ onClick, disabled, className, variant, children }) => (
|
||||||
|
<button onClick={onClick} disabled={disabled}
|
||||||
|
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"}
|
||||||
|
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
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 ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||||
|
|
||||||
|
/* ------------- COMPONENT ------------- */
|
||||||
|
export default function AIChatModal({ open, onClose, context, onAccept }) {
|
||||||
|
const [vectorStores, setVectorStores] = useState([]);
|
||||||
|
const [vectorFiles, setVectorFiles] = useState([]);
|
||||||
|
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
|
||||||
|
|
||||||
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||||
|
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||||
|
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||||
|
|
||||||
|
const [conversationId, setConversationId] = useState(null);
|
||||||
|
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
|
||||||
|
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const scrollToBottom = () => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// si ya viene como objeto
|
||||||
|
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);
|
||||||
|
setAttachedFiles([]);
|
||||||
|
setAttachedPreviews([]);
|
||||||
|
setConversationId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inyectar contexto como system message
|
||||||
|
if (context) {
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
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) ----------
|
||||||
|
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) => {
|
||||||
|
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 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading vector files:", err);
|
||||||
|
setVectorFiles([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingFiles(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- UI helpers ----------
|
||||||
|
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)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSelectVectorFile = (file) => {
|
||||||
|
setSelectedVectorFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Send flow ----------
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) 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
|
||||||
|
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." }]);
|
||||||
|
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 = () => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<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>
|
||||||
|
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 pt-4 min-h-0">
|
||||||
|
<div className="flex gap-6 h-full min-h-0">
|
||||||
|
|
||||||
|
{/* Left: vectors */}
|
||||||
|
<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 p-4">
|
||||||
|
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loadingVectors ? (
|
||||||
|
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
|
||||||
|
) : vectorStores.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{vectorStores.map((vector) => (
|
||||||
|
<li key={vector.id}
|
||||||
|
onClick={() => loadFilesForVector(vector.id)}
|
||||||
|
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<strong className="truncate">{vector.name || vector.id}</strong>
|
||||||
|
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4>
|
||||||
|
{loadingFiles ? (
|
||||||
|
<p className="text-sm text-gray-500">Cargando archivos...</p>
|
||||||
|
) : selectedVectorFile ? (
|
||||||
|
<div className="text-sm text-gray-700 mb-2">
|
||||||
|
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Selecciona un archivo del vector</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Right: Chat */}
|
||||||
|
<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 p-4 min-h-0">
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||||
|
|
||||||
|
<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((m, i) => (
|
||||||
|
<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"}`}>
|
||||||
|
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||||
|
<ReactMarkdown>{m.content}</ReactMarkdown>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<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">
|
||||||
|
<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 ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachedPreviews.length > 0 && (
|
||||||
|
<ul className="text-xs text-gray-600 mt-2">
|
||||||
|
{attachedPreviews.map((name, i) => (
|
||||||
|
<li key={i}>📄 {name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||||
|
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Escribe tu pregunta..."
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
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 onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
|
||||||
|
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
|
||||||
|
Aplicar mejora
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react"
|
|
||||||
import { supabase } from "@/auth/supabase"
|
|
||||||
import type { PlanTextFields } from "../planes/academic-sections";
|
|
||||||
|
|
||||||
// 🔹 SIMULACIÓN DE ICONO LUCIDE-REACT
|
|
||||||
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" className="lucide lucide-paperclip">
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 🔹 SIMULACIÓN DE SHADCN/UI
|
|
||||||
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;
|
|
||||||
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()}>{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 Button = ({ onClick, disabled, className, variant, children }) => (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
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"
|
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
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 ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
|
||||||
// ====================================================================
|
|
||||||
|
|
||||||
|
|
||||||
type AIChatModalProps = {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
edgeFunctionUrl: string
|
|
||||||
context?: {
|
|
||||||
section?: string
|
|
||||||
fieldKey?: keyof PlanTextFields
|
|
||||||
originalText?: string
|
|
||||||
}
|
|
||||||
onAccept?: (newText: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AIChatModal({ open, onClose, edgeFunctionUrl, context, onAccept }: AIChatModalProps) {
|
|
||||||
const [files, setFiles] = useState<any[]>([])
|
|
||||||
const [attachedFile, setAttachedFile] = useState<File | null>(null)
|
|
||||||
const [attachedPreview, setAttachedPreview] = useState<string | null>(null)
|
|
||||||
const [messages, setMessages] = useState<{ role: string; content: string }[]>([])
|
|
||||||
const [input, setInput] = useState("")
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
// Referencia para desplazar al final del chat
|
|
||||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
||||||
useEffect(scrollToBottom, [messages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
// 🧹 Limpia mensajes y archivos al cerrar
|
|
||||||
setMessages([])
|
|
||||||
setInput("")
|
|
||||||
setAttachedFile(null)
|
|
||||||
setAttachedPreview(null)
|
|
||||||
} else if (context) {
|
|
||||||
// 🧩 Muestra el contexto inicial al abrir
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}, [open, context])
|
|
||||||
|
|
||||||
|
|
||||||
// 🔹 Obtener lista de archivos del Vector Store (Lógica de API original)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const fetchVectorFiles = async () => {
|
|
||||||
// Nota: La verificación de Supabase ahora pasa por el mock.
|
|
||||||
if (typeof supabase === 'undefined' || !supabase.auth) {
|
|
||||||
console.error("Supabase no está disponible (Simulación). Saltando fetch de archivos.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
const { data: { session } } = await supabase.auth.getSession()
|
|
||||||
const token = session?.access_token
|
|
||||||
|
|
||||||
// 🟢 TU LÓGICA DE FETCH ORIGINAL
|
|
||||||
const res = await fetch(`${edgeFunctionUrl}?action=list_files`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.files) setFiles(data.files)
|
|
||||||
else console.warn("No se encontraron archivos en el vector store.")
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error al cargar archivos del vector store:", err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchVectorFiles()
|
|
||||||
}, [open, edgeFunctionUrl])
|
|
||||||
|
|
||||||
// 📎 Adjuntar archivo
|
|
||||||
const handleAttach = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
setAttachedFile(file)
|
|
||||||
setAttachedPreview(file.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🚀 Enviar prompt o archivo al Edge Function (Lógica de API original)
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!input.trim() && !attachedFile) return
|
|
||||||
|
|
||||||
// 🧩 Crear el mensaje visible del usuario
|
|
||||||
let userMessageContent = input.trim()
|
|
||||||
if (attachedFile) {
|
|
||||||
userMessageContent += (userMessageContent ? " | " : "") + `Adjunto: ${attachedFile.name}`
|
|
||||||
}
|
|
||||||
if (!userMessageContent && attachedFile) {
|
|
||||||
userMessageContent = `Consulta de archivo: ${attachedFile.name}`
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, { role: "user", content: userMessageContent }])
|
|
||||||
setInput("")
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof supabase === "undefined" || !supabase.functions) {
|
|
||||||
throw new Error("Supabase no está disponible o no soporta Edge Functions.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
|
|
||||||
// 🧠 Construimos un prompt limpio con el contexto del campo
|
|
||||||
const contextText = context?.originalText || "Sin texto original"
|
|
||||||
const section = context?.section ? `Sección: ${context.section}` : ""
|
|
||||||
const field = context?.fieldKey ? `Campo: ${context.fieldKey}` : ""
|
|
||||||
|
|
||||||
const fullPrompt = `
|
|
||||||
${section}
|
|
||||||
${field}
|
|
||||||
|
|
||||||
Texto original:
|
|
||||||
${contextText}
|
|
||||||
|
|
||||||
Solicitud del usuario:
|
|
||||||
${input}
|
|
||||||
|
|
||||||
Responde con una versión mejorada del texto, sin agregar frases como “Aquí tienes” ni explicaciones.
|
|
||||||
`.trim()
|
|
||||||
|
|
||||||
formData.append("prompt", fullPrompt)
|
|
||||||
if (attachedFile) formData.append("file", attachedFile)
|
|
||||||
|
|
||||||
// 🟢 Llamada a la Edge Function
|
|
||||||
const { data, error } = await supabase.functions.invoke("simple-chat", {
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ role: "assistant", content: data?.text || "Sin respuesta del modelo." },
|
|
||||||
])
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Error al enviar prompt:", err)
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ role: "assistant", content: "Ocurrió un error al conectar con la API." },
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setAttachedFile(null)
|
|
||||||
setAttachedPreview(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
|
||||||
{/* DialogContent ya define el tamaño h-[85vh] y es flex-col */}
|
|
||||||
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col">
|
|
||||||
|
|
||||||
{/* Encabezado fijo (flex-shrink-0) */}
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-lg font-semibold">Asistente Inteligente</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* CONTENEDOR PRINCIPAL QUE AHORA GESTIONA EL SCROLL DEL CONTENIDO */}
|
|
||||||
{/* flex-1: toma el espacio restante. overflow-y-auto: permite scroll si el contenido se desborda */}
|
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
|
||||||
|
|
||||||
{/* Contenido que originalmente estaba justo debajo del header */}
|
|
||||||
<div className="flex gap-6 min-h-full">
|
|
||||||
{/* 📂 Archivos del Vector Store */}
|
|
||||||
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
|
||||||
{/* Se mantiene flex-1 en CardContent para el ScrollArea */}
|
|
||||||
<CardContent className="flex flex-col flex-1 p-4">
|
|
||||||
<h3 className="font-semibold text-sm mb-3">Archivos del Vector Store</h3>
|
|
||||||
|
|
||||||
{/* ScrollArea toma el espacio disponible (flex-1) */}
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
{files.length === 0 ? (
|
|
||||||
<p className="text-gray-500 text-sm text-center mt-10">
|
|
||||||
{loading ? "Cargando archivos..." : "No hay archivos cargados."}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{files.map((file) => (
|
|
||||||
<li
|
|
||||||
key={file.id}
|
|
||||||
className="border border-gray-200 bg-white rounded-lg p-2 text-sm shadow-sm hover:shadow-lg transition-shadow cursor-pointer"
|
|
||||||
>
|
|
||||||
<strong className="block text-gray-700 truncate">{file.name}</strong>
|
|
||||||
<p className="text-xs text-gray-400 truncate">{file.path}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="mt-4 flex-shrink-0">
|
|
||||||
<Button variant="outline" className="w-full">
|
|
||||||
Subir archivo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 💬 Chat con GPT */}
|
|
||||||
<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 p-4">
|
|
||||||
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
|
||||||
|
|
||||||
{/* Mensajes - EL CONTENEDOR QUE DEBE HACER SCROLL */}
|
|
||||||
<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((m, i) => (
|
|
||||||
<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"
|
|
||||||
: "bg-white text-gray-800 mr-auto border border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<strong className="font-bold">{m.role === "user" ? "Tú:" : "IA:"}</strong>{" "}
|
|
||||||
{m.content}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{loading && (
|
|
||||||
<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">
|
|
||||||
<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 ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Archivo adjunto - flex-shrink-0 */}
|
|
||||||
{attachedPreview && (
|
|
||||||
<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" />
|
|
||||||
{attachedPreview}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-500 hover:bg-gray-200"
|
|
||||||
onClick={() => {
|
|
||||||
setAttachedFile(null)
|
|
||||||
setAttachedPreview(null)
|
|
||||||
setInput("")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Quitar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Entrada de texto y enviar - flex-shrink-0 */}
|
|
||||||
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
|
||||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
|
||||||
<Paperclip className="w-5 h-5" />
|
|
||||||
<input type="file" className="hidden" onChange={handleAttach} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
placeholder="Escribe tu pregunta..."
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
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
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={loading || (!input.trim() && !attachedFile)}
|
|
||||||
className="shadow-md"
|
|
||||||
>
|
|
||||||
{loading ? "Enviando..." : "Enviar"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
|
||||||
if (onAccept && lastMessage?.role === "assistant") {
|
|
||||||
onAccept(lastMessage.content)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!messages.some((m) => m.role === "assistant")}
|
|
||||||
className="shadow-md"
|
|
||||||
>
|
|
||||||
Aplicar mejora
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,8 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
|||||||
unit: "mm",
|
unit: "mm",
|
||||||
format: "letter",
|
format: "letter",
|
||||||
})
|
})
|
||||||
|
console.log(plan);
|
||||||
|
|
||||||
const pageWidth = doc.internal.pageSize.getWidth()
|
const pageWidth = doc.internal.pageSize.getWidth()
|
||||||
const pageHeight = doc.internal.pageSize.getHeight()
|
const pageHeight = doc.internal.pageSize.getHeight()
|
||||||
const margin = 20
|
const margin = 20
|
||||||
@@ -229,7 +230,7 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
|||||||
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
|
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
|
||||||
doc.setFontSize(18)
|
doc.setFontSize(18)
|
||||||
// Manejamos la conversión a string si es necesario
|
// Manejamos la conversión a string si es necesario
|
||||||
const mainTitle = (plan["titulo"] !== null && plan["titulo"] !== undefined ? String(plan["titulo"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
|
const mainTitle = (plan["nombre"] !== null && plan["nombre"] !== undefined ? String(plan["nombre"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
|
||||||
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
|
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
|
||||||
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
|
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
|
||||||
cursorY += mainTitleLines.length * 8
|
cursorY += mainTitleLines.length * 8
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
||||||
import AIChatModal from "../ai/AIChatModal"
|
// @ts-ignore
|
||||||
|
import AIChatModal from "../ai/AIChatModal"
|
||||||
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
@@ -306,14 +307,18 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
|||||||
updateField.mutate({ key, value })
|
updateField.mutate({ key, value })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// dummy test
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user