Remove local environment file and implement API calls for managing vector stores and their files
This commit is contained in:
39
src/lib/filesAndVectorStoresClient.ts
Normal file
39
src/lib/filesAndVectorStoresClient.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { supabase } from "@/auth/supabase"
|
||||
|
||||
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
|
||||
|
||||
type EdgeArgs = {
|
||||
module: EdgeModule
|
||||
action: string
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
export async function callFilesAndVectorStoresApi<T = unknown>(
|
||||
args: EdgeArgs,
|
||||
): Promise<T> {
|
||||
const { data, error } = await supabase.functions.invoke<any>(
|
||||
"files-and-vector-stores-api",
|
||||
{
|
||||
body: args,
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const payload = data ?? {}
|
||||
|
||||
if (payload.error) {
|
||||
const msg =
|
||||
typeof payload.error === "string"
|
||||
? payload.error
|
||||
: payload.error.message ?? "Error en la función Edge"
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
// Soporta tanto `{ data: [...] }` como `[...]`
|
||||
const result = payload.data !== undefined ? payload.data : payload
|
||||
return result as T
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// routes/_authenticated/archivos.tsx
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { use, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import * as Icons from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -9,92 +9,202 @@ import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
import type { RefRow } from "@/types/RefRow"
|
||||
import { uuid } from "zod"
|
||||
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
|
||||
|
||||
interface VectorStore {
|
||||
id: string
|
||||
object: "vector_store"
|
||||
created_at: number
|
||||
name: string | null
|
||||
description?: string | null
|
||||
usage_bytes: number
|
||||
file_counts: {
|
||||
in_progress: number
|
||||
completed: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
total: number
|
||||
}
|
||||
status: string
|
||||
last_active_at?: number | null
|
||||
metadata?: Record<string, any> | null
|
||||
}
|
||||
|
||||
interface VectorStoreFile {
|
||||
id: string
|
||||
object: string
|
||||
created_at: number
|
||||
vector_store_id: string
|
||||
status: string
|
||||
usage_bytes: number
|
||||
last_error?: { code: string; message: string } | null
|
||||
}
|
||||
|
||||
interface VectorStoreFileMeta {
|
||||
id: string
|
||||
user_id: string | null
|
||||
vector_store_id: string
|
||||
openai_file_id: string
|
||||
label: string | null
|
||||
tags: string[] | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type EdgeArgs = {
|
||||
module: EdgeModule
|
||||
action: string
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
async function callFilesAndVectorStoresApi<T = unknown>(
|
||||
args: EdgeArgs,
|
||||
): Promise<T> {
|
||||
const { data, error } = await supabase.functions.invoke<any>(
|
||||
"files-and-vector-stores-api",
|
||||
{
|
||||
body: args,
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const payload = data ?? {}
|
||||
if (payload.error) {
|
||||
const msg =
|
||||
typeof payload.error === "string"
|
||||
? payload.error
|
||||
: payload.error.message ?? "Error en la función Edge"
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
const result = payload.data !== undefined ? payload.data : payload
|
||||
return result as T
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("documentos")
|
||||
.select("*")
|
||||
.order("fecha_subida", { ascending: false })
|
||||
.limit(200)
|
||||
if (error) throw error
|
||||
return (data ?? []) as RefRow[]
|
||||
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
|
||||
module: "vectorStores",
|
||||
action: "list",
|
||||
params: {},
|
||||
})
|
||||
return stores ?? []
|
||||
},
|
||||
})
|
||||
|
||||
function chipTint(ok?: boolean | null) {
|
||||
return ok
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: "bg-amber-50 text-amber-800 border-amber-200"
|
||||
/* ====== UI helpers ====== */
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const label =
|
||||
status === "completed"
|
||||
? "Completado"
|
||||
: status === "in_progress"
|
||||
? "Procesando"
|
||||
: status
|
||||
const base = "text-[10px] px-2 py-0.5 rounded-full border"
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-emerald-50 text-emerald-700 border-emerald-200`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-amber-50 text-amber-800 border-amber-200`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`${base} bg-neutral-50 text-neutral-700 border-neutral-200`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter()
|
||||
const rows = Route.useLoaderData() as RefRow[]
|
||||
const vectorStores = Route.useLoaderData() as VectorStore[]
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
|
||||
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
|
||||
|
||||
const [viewing, setViewing] = useState<RefRow | null>(null)
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
|
||||
const [selected, setSelected] = useState<VectorStore | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
return rows.filter((r) => {
|
||||
if (estado === "proc" && !r.procesado) return false
|
||||
if (estado === "pend" && r.procesado) return false
|
||||
if (scope === "internos" && !r.interno) return false
|
||||
if (scope === "externos" && r.interno) return false
|
||||
|
||||
if (!t) return true
|
||||
const hay =
|
||||
[r.titulo_archivo, r.descripcion, r.fuente_autoridad, r.tipo_contenido, ...(r.tags ?? [])]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(t))
|
||||
return hay
|
||||
const term = q.trim().toLowerCase()
|
||||
return vectorStores.filter((vs) => {
|
||||
if (statusFilter !== "all" && vs.status !== statusFilter) return false
|
||||
if (!term) return true
|
||||
return (
|
||||
(vs.name ?? "").toLowerCase().includes(term) ||
|
||||
(vs.description ?? "").toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
}, [rows, q, estado, scope])
|
||||
}, [vectorStores, q, statusFilter])
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||
const { error } = await supabase
|
||||
.from("documentos")
|
||||
.delete()
|
||||
.eq("documentos_id", id)
|
||||
if (error) return alert(error.message)
|
||||
function openDetails(vs: VectorStore) {
|
||||
setSelected(vs)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
|
||||
setDeletingId(id)
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentos_id: id }),
|
||||
await callFilesAndVectorStoresApi({
|
||||
module: "vectorStores",
|
||||
action: "delete",
|
||||
params: { id },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Se falló al eliminar el documento")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error al eliminar el documento:", err)
|
||||
}
|
||||
await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.delete()
|
||||
.eq("vector_store_id", id)
|
||||
|
||||
router.invalidate()
|
||||
router.invalidate()
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? "Error al eliminar el repositorio")
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="font-mono">Archivos de referencia</CardTitle>
|
||||
<CardTitle className="font-mono">Repositorios de archivos (Vector Stores)</CardTitle>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
@@ -102,240 +212,499 @@ function RouteComponent() {
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por título, etiqueta, fuente…"
|
||||
placeholder="Buscar por nombre o descripción…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as "all" | "completed" | "in_progress")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
<SelectItem value="proc">Procesados</SelectItem>
|
||||
<SelectItem value="pend">Pendientes</SelectItem>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="completed">Completados</SelectItem>
|
||||
<SelectItem value="in_progress">En proceso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Ámbito" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
<SelectItem value="internos">Internos</SelectItem>
|
||||
<SelectItem value="externos">Externos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={() => setUploadOpen(true)}>
|
||||
<Icons.Upload className="w-4 h-4 mr-2" /> Nuevo
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Icons.FolderPlus className="w-4 h-4 mr-2" />
|
||||
Nuevo repositorio
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((r) => (
|
||||
<article
|
||||
key={r.documentos_id}
|
||||
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||
>
|
||||
<header className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
|
||||
{r.procesado ? "Procesado" : "Pendiente"}
|
||||
</span>
|
||||
{filtered.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((vs) => (
|
||||
<article
|
||||
key={vs.id}
|
||||
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||
>
|
||||
<header className="min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold truncate">
|
||||
{vs.name || "(Sin nombre)"}
|
||||
</h3>
|
||||
<StatusBadge status={vs.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
||||
<Badge variant="outline">
|
||||
Archivos: {vs.file_counts?.completed ?? 0}
|
||||
</Badge>
|
||||
{typeof vs.usage_bytes === "number" && (
|
||||
<span>
|
||||
{(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
)}
|
||||
{vs.last_active_at && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.Clock3 className="w-3 h-3" />
|
||||
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{vs.description && (
|
||||
<p className="text-sm text-neutral-700 line-clamp-3">
|
||||
{vs.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDetails(vs)}>
|
||||
<Icons.Eye className="w-4 h-4 mr-1" /> Abrir
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(vs.id)}
|
||||
disabled={deletingId === vs.id}
|
||||
>
|
||||
<Icons.Trash2 className="w-4 h-4 mr-1" />
|
||||
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
|
||||
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
|
||||
{r.interno != null && (
|
||||
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
|
||||
)}
|
||||
{r.fecha_subida && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.CalendarClock className="w-3 h-3" />
|
||||
{new Date(r.fecha_subida).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{r.descripcion && (
|
||||
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
||||
)}
|
||||
|
||||
{/* Tags
|
||||
{r.tags && r.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{r.tags.map((t, i) => (
|
||||
<span key={i} className="text-[10px] px-2 py-0.5 rounded-full border bg-white/60">
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
||||
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
|
||||
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!filtered.length && (
|
||||
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-neutral-500 py-10">
|
||||
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detalle */}
|
||||
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
|
||||
<CreateVectorStoreDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={() => router.invalidate()}
|
||||
/>
|
||||
|
||||
{/* Subida */}
|
||||
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
|
||||
<VectorStoreDialog
|
||||
store={selected}
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) setSelected(null)
|
||||
}}
|
||||
onUpdated={() => router.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ========= Subida ========= */
|
||||
function UploadDialog({
|
||||
open, onOpenChange, onDone,
|
||||
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [instrucciones, setInstrucciones] = useState("")
|
||||
const [tags, setTags] = useState("")
|
||||
const [interno, setInterno] = useState(true)
|
||||
const [fuente, setFuente] = useState("")
|
||||
const [subiendo, setSubiendo] = useState(false)
|
||||
/* ====== Crear repositorio ====== */
|
||||
|
||||
async function toBase64(f: File): Promise<string> {
|
||||
const buf = await f.arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ""
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||
return btoa(binary)
|
||||
}
|
||||
function CreateVectorStoreDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function upload() {
|
||||
if (!file) { alert("Selecciona un archivo"); return }
|
||||
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
|
||||
|
||||
setSubiendo(true)
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) {
|
||||
alert("Escribe un nombre para el repositorio")
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const fileBase64 = await toBase64(file)
|
||||
// Enviamos al motor (inserta en la tabla si insert=true)
|
||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: instrucciones,
|
||||
fileBase64,
|
||||
insert: true,
|
||||
uuid: supabaseAuth.user?.id ?? null,
|
||||
}),
|
||||
await callFilesAndVectorStoresApi<VectorStore>({
|
||||
module: "vectorStores",
|
||||
action: "create",
|
||||
params: { name: name.trim(), description: description.trim() || undefined },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text()
|
||||
throw new Error(txt || "Error al subir")
|
||||
}
|
||||
// Ajustes extra (tags, interno, fuente) si el motor no los llenó
|
||||
// Intentamos leer el id que regrese el servicio; si no, solo invalidamos.
|
||||
let createdId: string | null = null
|
||||
try {
|
||||
const payload = await res.json()
|
||||
createdId =
|
||||
payload?.documentos_id ||
|
||||
payload?.id ||
|
||||
payload?.data?.documentos_id ||
|
||||
null
|
||||
} catch { /* noop */ }
|
||||
|
||||
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
||||
await supabase
|
||||
.from("documentos")
|
||||
.update({
|
||||
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||
fuente_autoridad: fuente.trim() || undefined,
|
||||
interno,
|
||||
})
|
||||
.eq("documentos_id", createdId)
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
onDone()
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al subir el documento")
|
||||
setName("")
|
||||
setDescription("")
|
||||
onCreated()
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? "Error al crear el repositorio")
|
||||
} finally {
|
||||
setSubiendo(false)
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
|
||||
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
|
||||
<em> procesado </em> cuando termine el flujo.
|
||||
Crea un Vector Store para agrupar archivos relacionados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Archivo</Label>
|
||||
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
{file && (
|
||||
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Instrucciones</Label>
|
||||
<Textarea
|
||||
value={instrucciones}
|
||||
onChange={(e) => setInstrucciones(e.target.value)}
|
||||
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
|
||||
className="min-h-[120px]"
|
||||
<Label>Nombre</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Planeación curricular, Entrevistas…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Tags (separados por coma)</Label>
|
||||
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Fuente de autoridad</Label>
|
||||
<Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Ámbito</Label>
|
||||
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Interno</SelectItem>
|
||||
<SelectItem value="false">Externo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>Descripción (opcional)</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Breve descripción del contenido de este repositorio."
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
|
||||
{subiendo ? "Subiendo…" : "Subir"}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||
{creating ? "Creando…" : "Crear repositorio"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====== Detalle de un repositorio: archivos + subida ====== */
|
||||
|
||||
type FileRow = {
|
||||
file: VectorStoreFile
|
||||
meta: VectorStoreFileMeta | null
|
||||
}
|
||||
|
||||
function VectorStoreDialog({
|
||||
store,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdated,
|
||||
}: {
|
||||
store: VectorStore | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdated: () => void
|
||||
}) {
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [files, setFiles] = useState<FileRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [label, setLabel] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !store) return
|
||||
void refreshFiles()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, store?.id])
|
||||
|
||||
async function refreshFiles() {
|
||||
if (!store) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const vectorFiles = await callFilesAndVectorStoresApi<VectorStoreFile[]>({
|
||||
module: "vectorStoreFiles",
|
||||
action: "list",
|
||||
params: { vector_store_id: store.id },
|
||||
})
|
||||
|
||||
const { data: metaRows, error: metaError } = await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.select("*")
|
||||
.eq("vector_store_id", store.id)
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (metaError) throw metaError
|
||||
|
||||
const meta = (metaRows ?? []) as VectorStoreFileMeta[]
|
||||
|
||||
const merged: FileRow[] = (vectorFiles ?? []).map((vf) => ({
|
||||
file: vf,
|
||||
meta: meta.find((m) => m.openai_file_id === vf.id) ?? null,
|
||||
}))
|
||||
|
||||
setFiles(merged)
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err?.message ?? "No se pudieron cargar los archivos")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toBase64(f: File): Promise<string> {
|
||||
const buf = await f.arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ""
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!store || !file) {
|
||||
alert("Selecciona un archivo")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const fileBase64 = await toBase64(file)
|
||||
|
||||
// 1) Subir archivo a OpenAI vía Edge (módulo files)
|
||||
const uploaded: any = await callFilesAndVectorStoresApi<any>({
|
||||
module: "files",
|
||||
action: "upload",
|
||||
params: {
|
||||
fileBase64,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
},
|
||||
})
|
||||
|
||||
const openaiFileId: string =
|
||||
uploaded?.id ?? uploaded?.file?.id ?? uploaded?.data?.id
|
||||
|
||||
if (!openaiFileId) {
|
||||
throw new Error("La Edge Function no devolvió el id del archivo")
|
||||
}
|
||||
|
||||
// 2) Mapear archivo al Vector Store
|
||||
await callFilesAndVectorStoresApi<any>({
|
||||
module: "vectorStoreFiles",
|
||||
action: "create",
|
||||
params: {
|
||||
vector_store_id: store.id,
|
||||
file_id: openaiFileId,
|
||||
},
|
||||
})
|
||||
|
||||
// 3) Guardar metadata en Supabase
|
||||
const { error: insertError } = await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.insert({
|
||||
user_id: supabaseAuth.user?.id ?? null,
|
||||
vector_store_id: store.id,
|
||||
openai_file_id: openaiFileId,
|
||||
label: label.trim() || file.name,
|
||||
})
|
||||
|
||||
if (insertError) throw insertError
|
||||
|
||||
setFile(null)
|
||||
setLabel("")
|
||||
await refreshFiles()
|
||||
onUpdated()
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
alert(err?.message ?? "Error al subir el archivo")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFile(fileId: string) {
|
||||
if (!store) return
|
||||
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await callFilesAndVectorStoresApi<any>({
|
||||
module: "vectorStoreFiles",
|
||||
action: "delete",
|
||||
params: {
|
||||
vector_store_id: store.id,
|
||||
file_id: fileId,
|
||||
},
|
||||
})
|
||||
|
||||
// Opcional: eliminar también el archivo global de OpenAI
|
||||
await callFilesAndVectorStoresApi<any>({
|
||||
module: "files",
|
||||
action: "delete",
|
||||
params: { id: fileId },
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.delete()
|
||||
.eq("openai_file_id", fileId)
|
||||
|
||||
await refreshFiles()
|
||||
onUpdated()
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
alert(err?.message ?? "Error al eliminar el archivo")
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!store) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icons.Folder className="h-4 w-4" />
|
||||
{store.name || "(Sin nombre)"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gestiona los archivos asociados a este repositorio (Vector Store).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
|
||||
<StatusBadge status={store.status} />
|
||||
<Badge variant="outline">
|
||||
Archivos completados: {store.file_counts?.completed ?? 0}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
Total archivos: {store.file_counts?.total ?? 0}
|
||||
</Badge>
|
||||
{typeof store.usage_bytes === "number" && (
|
||||
<span>{(store.usage_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subida de archivo */}
|
||||
<div className="space-y-2 rounded-lg border bg-muted/50 p-4">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Agregar archivo al repositorio
|
||||
</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{file && (
|
||||
<div className="text-xs text-neutral-600">
|
||||
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título / etiqueta</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ej.: Plan 2025, Entrevista 3…"
|
||||
/>
|
||||
<Button
|
||||
className="mt-2 w-full sm:w-auto"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !file}
|
||||
>
|
||||
{uploading ? "Subiendo…" : "Subir al repositorio"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de archivos */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Archivos en este repositorio</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refreshFiles()}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
<Icons.RefreshCw className="h-4 w-4 mr-1" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-xs text-neutral-500 py-4">
|
||||
Cargando archivos…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500 py-4">{error}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-xs text-neutral-500 py-4">
|
||||
Todavía no hay archivos en este repositorio ⚡
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{files.map(({ file, meta }) => (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{meta?.label || file.id}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 truncate">
|
||||
{new Date(file.created_at * 1000).toLocaleString()} ·{" "}
|
||||
{(file.usage_bytes / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={file.status} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteFile(file.id)}
|
||||
>
|
||||
<Icons.Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user