From a2dddae5f31ef4ec5e9bb9fa8dd42b8a4797b8c5 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Thu, 27 Nov 2025 16:08:00 -0600 Subject: [PATCH] Remove local environment file and implement API calls for managing vector stores and their files --- .env.local2 | 4 - src/lib/filesAndVectorStoresClient.ts | 39 ++ src/routes/_authenticated/archivos.tsx | 847 ++++++++++++++++++------- 3 files changed, 647 insertions(+), 243 deletions(-) delete mode 100644 .env.local2 create mode 100644 src/lib/filesAndVectorStoresClient.ts diff --git a/.env.local2 b/.env.local2 deleted file mode 100644 index 08a9b4b..0000000 --- a/.env.local2 +++ /dev/null @@ -1,4 +0,0 @@ -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 diff --git a/src/lib/filesAndVectorStoresClient.ts b/src/lib/filesAndVectorStoresClient.ts new file mode 100644 index 0000000..605d815 --- /dev/null +++ b/src/lib/filesAndVectorStoresClient.ts @@ -0,0 +1,39 @@ +import { supabase } from "@/auth/supabase" + +type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles" + +type EdgeArgs = { + module: EdgeModule + action: string + params?: Record +} + +export async function callFilesAndVectorStoresApi( + args: EdgeArgs, +): Promise { + const { data, error } = await supabase.functions.invoke( + "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 +} diff --git a/src/routes/_authenticated/archivos.tsx b/src/routes/_authenticated/archivos.tsx index 3b572c3..b0908cb 100644 --- a/src/routes/_authenticated/archivos.tsx +++ b/src/routes/_authenticated/archivos.tsx @@ -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 | 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 +} + +async function callFilesAndVectorStoresApi( + args: EdgeArgs, +): Promise { + const { data, error } = await supabase.functions.invoke( + "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({ + 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 ( + + {label} + + ) + } + if (status === "in_progress") { + return ( + + {label} + + ) + } + return ( + + {label} + + ) } +/* ====== 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(null) - const [uploadOpen, setUploadOpen] = useState(false) + const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all") + const [selected, setSelected] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [deletingId, setDeletingId] = useState(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 (
- Archivos de referencia + Repositorios de archivos (Vector Stores)
@@ -102,240 +212,499 @@ function RouteComponent() { setQ(e.target.value)} - placeholder="Buscar por título, etiqueta, fuente…" + placeholder="Buscar por nombre o descripción…" className="pl-8" />
- + setStatusFilter(v as "all" | "completed" | "in_progress") + } + > - Todos - Procesados - Pendientes + Todos + Completados + En proceso - - -
-
- {filtered.map((r) => ( -
-
-
-

{r.titulo_archivo ?? "(Sin título)"}

- - {r.procesado ? "Procesado" : "Pendiente"} - + {filtered.length ? ( +
+ {filtered.map((vs) => ( +
+
+
+

+ {vs.name || "(Sin nombre)"} +

+ +
+
+ + Archivos: {vs.file_counts?.completed ?? 0} + + {typeof vs.usage_bytes === "number" && ( + + {(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB + + )} + {vs.last_active_at && ( + + + {new Date(vs.last_active_at * 1000).toLocaleDateString()} + + )} +
+
+ + {vs.description && ( +

+ {vs.description} +

+ )} + +
+ +
-
- {r.tipo_contenido && {r.tipo_contenido}} - {r.interno != null && ( - {r.interno ? "Interno" : "Externo"} - )} - {r.fecha_subida && ( - - - {new Date(r.fecha_subida).toLocaleDateString()} - - )} -
-
- - {r.descripcion && ( -

{r.descripcion}

- )} - - {/* Tags - {r.tags && r.tags.length > 0 && ( -
- {r.tags.map((t, i) => ( - - #{t} - - ))} -
- )} */} - -
- - -
-
- ))} -
- - {!filtered.length && ( -
No hay archivos
+ + ))} +
+ ) : ( +
+ No hay repositorios todavía. Crea uno nuevo para empezar 🚀 +
)} - {/* Detalle */} - setViewing(null)} /> + router.invalidate()} + /> - {/* Subida */} - router.invalidate()} /> + { + setDialogOpen(open) + if (!open) setSelected(null) + }} + onUpdated={() => router.invalidate()} + /> ) } -/* ========= Subida ========= */ -function UploadDialog({ - open, onOpenChange, onDone, -}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) { - const supabaseAuth = useSupabaseAuth() - const [file, setFile] = useState(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 { - 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({ + 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 ( - + - Nuevo archivo de referencia + Nuevo repositorio - Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como - procesado cuando termine el flujo. + Crea un Vector Store para agrupar archivos relacionados.
- - setFile(e.target.files?.[0] ?? null)} /> - {file && ( -
{file.name} · {(file.size / 1024).toFixed(1)} KB
- )} -
- -
- -