Compare commits
7 Commits
componente
...
efe7faa65f
| Author | SHA1 | Date | |
|---|---|---|---|
| efe7faa65f | |||
| c9d66ce2e5 | |||
| f7a29ad510 | |||
| e7a47f56f8 | |||
| 214d17cf98 | |||
| 8c890d76e0 | |||
| 6d264a8214 |
375
src/components/ai/AIChatModal.jsx
Normal file
375
src/components/ai/AIChatModal.jsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
|
||||
// ---------------- UI MOCKS ---------------- //
|
||||
// Puedes reemplazarlos por tus propios componentes UI
|
||||
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>;
|
||||
|
||||
// ---------------- COMPONENTE ---------------- //
|
||||
export default function AIChatModal({ open, onClose, context, onAccept }) {
|
||||
const [vectorStores, setVectorStores] = useState([]);
|
||||
const [vectorFiles, setVectorFiles] = useState([]);
|
||||
|
||||
const [attachedFile, setAttachedFile] = useState(null);
|
||||
const [attachedPreview, setAttachedPreview] = useState(null);
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [selectedVector, setSelectedVector] = useState(null);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
useEffect(scrollToBottom, [messages]);
|
||||
|
||||
// ------------------------------------
|
||||
// Reset al abrir o cerrar modal
|
||||
// ------------------------------------
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachedFile(null);
|
||||
setAttachedPreview(null);
|
||||
setVectorStores([]);
|
||||
setVectorFiles([]);
|
||||
setSelectedVector(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
setMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: `Contexto: ${context.section}\nTexto original:\n${context.originalText || "—"}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [open, context]);
|
||||
|
||||
// ------------------------------------
|
||||
// Cargar vector stores
|
||||
// ------------------------------------
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
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
|
||||
// ------------------------------------
|
||||
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 : []);
|
||||
} catch (err) {
|
||||
console.error("Error al obtener archivos del vector store:", err);
|
||||
setVectorFiles([]);
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------
|
||||
// Adjuntar archivo
|
||||
// ------------------------------------
|
||||
const handleAttach = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttachedFile(file);
|
||||
setAttachedPreview(file.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." }]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setAttachedFile(null);
|
||||
setAttachedPreview(null);
|
||||
};
|
||||
|
||||
// ------------------------------------
|
||||
// UI
|
||||
// ------------------------------------
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
<div className="flex gap-6 min-h-full">
|
||||
|
||||
{/* LEFT: VECTOR STORES */}
|
||||
<Card className="w-1/3 max-w-sm flex flex-col bg-muted/20 border border-gray-200">
|
||||
<CardContent className="flex flex-col flex-1">
|
||||
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-400">Cargando...</p>
|
||||
) : vectorStores.length === 0 ? (
|
||||
<p className="text-center text-gray-400">No hay vector stores</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{vectorStores.map(store => (
|
||||
<li
|
||||
key={store.id}
|
||||
onClick={() => {
|
||||
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>
|
||||
<p className="text-xs text-gray-500 truncate">{store.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<h4 className="mt-4 font-semibold text-sm">Archivos</h4>
|
||||
<ScrollArea className="mt-2 max-h-40">
|
||||
{loadingFiles ? (
|
||||
<p className="text-gray-400 text-sm">Cargando archivos...</p>
|
||||
) : vectorFiles.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No hay archivos</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{vectorFiles.map(f => (
|
||||
<li key={f.id} className="border bg-white p-2 rounded-lg text-sm">
|
||||
{f.id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RIGHT: CHAT */}
|
||||
<Card className="flex-1 flex flex-col border border-gray-200">
|
||||
<CardContent className="flex flex-col flex-1">
|
||||
<h3 className="font-semibold text-sm mb-3">Chat</h3>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border p-3 rounded-lg bg-gray-50 space-y-3">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400 text-center text-sm">Inicia la conversación</p>
|
||||
) : (
|
||||
messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-3 rounded-xl max-w-[85%] shadow-sm whitespace-pre-wrap
|
||||
${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>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="p-3 bg-white border rounded-xl max-w-fit">
|
||||
<span className="text-gray-600 text-sm">La IA está respondiendo...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{attachedPreview && (
|
||||
<div className="flex items-center mt-2 p-2 border rounded-lg bg-gray-100 text-sm">
|
||||
<Paperclip className="w-4 h-4 text-blue-500" />
|
||||
<span className="ml-2">{attachedPreview}</span>
|
||||
<button
|
||||
onClick={() => { setAttachedFile(null); setAttachedPreview(null); }}
|
||||
className="ml-auto text-red-500 text-xs"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600">
|
||||
<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..."
|
||||
className="flex-1 border rounded-lg p-3 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={loading || (!input.trim() && !attachedFile)}
|
||||
>
|
||||
Enviar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
onAccept(last.content);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
disabled={!messages.some(m => m.role === "assistant")}
|
||||
>
|
||||
Aplicar
|
||||
</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",
|
||||
format: "letter",
|
||||
})
|
||||
|
||||
console.log(plan);
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth()
|
||||
const pageHeight = doc.internal.pageSize.getHeight()
|
||||
const margin = 20
|
||||
@@ -229,7 +230,7 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
||||
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
|
||||
doc.setFontSize(18)
|
||||
// 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)
|
||||
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
|
||||
cursorY += mainTitleLines.length * 8
|
||||
|
||||
@@ -8,7 +8,8 @@ import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||
import { toast } from "sonner"
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
||||
import AIChatModal from "../ai/AIChatModal"
|
||||
// @ts-ignore
|
||||
import AIChatModal from "../ai/AIChatModal"
|
||||
|
||||
|
||||
/* =====================================================
|
||||
@@ -306,6 +307,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
updateField.mutate({ key, value })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatModal
|
||||
open={openModalIa}
|
||||
onClose={() => setopenModalIa(false)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// dummy test
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
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": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user