Se agrega componente de ia y pdf
This commit is contained in:
371
src/components/ai/AIChatModal.tsx
Normal file
371
src/components/ai/AIChatModal.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
|
||||
|
||||
/* =====================================================
|
||||
@@ -115,6 +116,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
const qc = useQueryClient()
|
||||
const auth = useSupabaseAuth()
|
||||
const [openHistorial, setOpenHistorial] = useState(false)
|
||||
const [openModalIa, setopenModalIa] = useState(false)
|
||||
if(!planId) return <div>Cargando…</div>
|
||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||
|
||||
@@ -162,6 +164,8 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
],
|
||||
[]
|
||||
)
|
||||
const [iaContext, setIaContext] = useState<{ key: keyof PlanTextFields; title: string; content: string } | null>(null)
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -171,9 +175,14 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
{s.key === "historico" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
||||
Ver historial
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
||||
Ver historial
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setopenModalIa(true)}>
|
||||
Promt
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
@@ -190,7 +199,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
Copiar
|
||||
</Button>
|
||||
{s.key !== "prompt" && (
|
||||
<Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -200,7 +209,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -268,6 +277,24 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
>
|
||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
const current = draft
|
||||
setIaContext({
|
||||
key: editing.key,
|
||||
title: editing.title,
|
||||
content: current,
|
||||
})
|
||||
setopenModalIa(true)
|
||||
setEditing(null) // 🔹 Cierra el modal de edición
|
||||
}}
|
||||
>
|
||||
Mejorar con IA
|
||||
</Button>
|
||||
|
||||
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -279,6 +306,24 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
updateField.mutate({ key, value })
|
||||
}}
|
||||
/>
|
||||
<AIChatModal
|
||||
open={openModalIa}
|
||||
onClose={() => setopenModalIa(false)}
|
||||
edgeFunctionUrl="https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/simple-chat"
|
||||
context={{
|
||||
section: iaContext?.title,
|
||||
fieldKey: iaContext?.key,
|
||||
originalText: iaContext?.content,
|
||||
}}
|
||||
onAccept={(newText: string) => {
|
||||
if (iaContext) {
|
||||
updateField.mutate({ key: iaContext.key, value: newText })
|
||||
setIaContext(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
200
src/components/ui/prompt-input.tsx
Normal file
200
src/components/ui/prompt-input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type PromptInputContextType = {
|
||||
isLoading: boolean
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
maxHeight: number | string
|
||||
onSubmit?: () => void
|
||||
disabled?: boolean
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
const PromptInputContext = createContext<PromptInputContextType>({
|
||||
isLoading: false,
|
||||
value: "",
|
||||
setValue: () => {},
|
||||
maxHeight: 240,
|
||||
onSubmit: undefined,
|
||||
disabled: false,
|
||||
textareaRef: React.createRef<HTMLTextAreaElement>(),
|
||||
})
|
||||
|
||||
function usePromptInput() {
|
||||
const context = useContext(PromptInputContext)
|
||||
if (!context) {
|
||||
throw new Error("usePromptInput must be used within a PromptInput")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type PromptInputProps = {
|
||||
isLoading?: boolean
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
maxHeight?: number | string
|
||||
onSubmit?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function PromptInput({
|
||||
className,
|
||||
isLoading = false,
|
||||
maxHeight = 240,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
children,
|
||||
}: PromptInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || "")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<PromptInputContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
value: value ?? internalValue,
|
||||
setValue: onValueChange ?? handleChange,
|
||||
maxHeight,
|
||||
onSubmit,
|
||||
textareaRef,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PromptInputContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputTextareaProps = {
|
||||
disableAutosize?: boolean
|
||||
} & React.ComponentProps<typeof Textarea>
|
||||
|
||||
function PromptInputTextarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
disableAutosize = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
|
||||
usePromptInput()
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAutosize) return
|
||||
|
||||
if (!textareaRef.current) return
|
||||
|
||||
if (textareaRef.current.scrollTop === 0) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
}
|
||||
|
||||
textareaRef.current.style.height =
|
||||
typeof maxHeight === "number"
|
||||
? `${Math.min(textareaRef.current.scrollHeight, maxHeight)}px`
|
||||
: `min(${textareaRef.current.scrollHeight}px, ${maxHeight})`
|
||||
}, [value, maxHeight, disableAutosize])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function PromptInputActions({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PromptInputActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
function PromptInputAction({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: PromptInputActionProps) {
|
||||
const { disabled } = usePromptInput()
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger asChild disabled={disabled} onClick={event => event.stopPropagation()}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputActions,
|
||||
PromptInputAction,
|
||||
}
|
||||
@@ -44,13 +44,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-aurora {
|
||||
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
|
||||
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),
|
||||
|
||||
Reference in New Issue
Block a user