2 Commits

Author SHA1 Message Date
169599874e Se quitan respuestas amigables 2025-11-28 08:32:25 -06:00
9b3880a02f Se corrige edfunction 2025-11-28 07:48:02 -06:00
6 changed files with 237 additions and 639 deletions

4
.env.local2 Normal file
View File

@@ -0,0 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
#VITE_BACK_ORIGIN=http://localhost:3001
VITE_BACK_ORIGIN=http://localhost:3001

1
.gitignore vendored
View File

@@ -7,4 +7,3 @@ count.txt
.env* .env*
.nitro .nitro
.tanstack .tanstack
.cta.json

View File

@@ -111,7 +111,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
const resp = await supabase.functions.invoke("conversation-format", { const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" } body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
}); });
@@ -147,7 +147,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
await supabase.functions.invoke("conversation-format", { await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse } body: { action: "end", conversationId: convIdToUse }
}); });
@@ -223,7 +223,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
}; };
const { data: invokeData, error } = await supabase.functions.invoke( const { data: invokeData, error } = await supabase.functions.invoke(
"conversation-format", "modal-conversation",
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: payload body: payload

View File

@@ -330,7 +330,10 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
section: null,//,iaContext?.title, section: null,//,iaContext?.title,
fieldKey: null,//iaContext?.key, fieldKey: null,//iaContext?.key,
originalText: iaContext?.content, originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`, cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
No uses introducciones, despedidas ni texto de relleno.
Entrega solo el contenido útil.`,
}} }}
onAccept={(newText: string) => { onAccept={(newText: string) => {
if (iaContext) { if (iaContext) {

View File

@@ -1,39 +0,0 @@
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
}

View File

@@ -1,6 +1,6 @@
// routes/_authenticated/archivos.tsx // routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router" import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react" import { use, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -9,202 +9,92 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
Dialog, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
Select, import { DetailDialog } from "@/components/archivos/DetailDialog"
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles" import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
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")({ export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async () => {
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({ const { data, error } = await supabase
module: "vectorStores", .from("documentos")
action: "list", .select("*")
params: {}, .order("fecha_subida", { ascending: false })
}) .limit(200)
return stores ?? [] if (error) throw error
return (data ?? []) as RefRow[]
}, },
}) })
/* ====== UI helpers ====== */ function chipTint(ok?: boolean | null) {
return ok
function StatusBadge({ status }: { status: string }) { ? "bg-emerald-50 text-emerald-700 border-emerald-200"
const label = : "bg-amber-50 text-amber-800 border-amber-200"
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() { function RouteComponent() {
const router = useRouter() const router = useRouter()
const vectorStores = Route.useLoaderData() as VectorStore[] const rows = Route.useLoaderData() as RefRow[]
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all") const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
const [selected, setSelected] = useState<VectorStore | null>(null) const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [viewing, setViewing] = useState<RefRow | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null) const [uploadOpen, setUploadOpen] = useState(false)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const term = q.trim().toLowerCase() const t = q.trim().toLowerCase()
return vectorStores.filter((vs) => { return rows.filter((r) => {
if (statusFilter !== "all" && vs.status !== statusFilter) return false if (estado === "proc" && !r.procesado) return false
if (!term) return true if (estado === "pend" && r.procesado) return false
return ( if (scope === "internos" && !r.interno) return false
(vs.name ?? "").toLowerCase().includes(term) || if (scope === "externos" && r.interno) return false
(vs.description ?? "").toLowerCase().includes(term)
) 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
}) })
}, [vectorStores, q, statusFilter]) }, [rows, q, estado, scope])
function openDetails(vs: VectorStore) { async function remove(id: string) {
setSelected(vs) if (!confirm("¿Eliminar archivo de referencia?")) return
setDialogOpen(true) const { error } = await supabase
} .from("documentos")
.delete()
.eq("documentos_id", id)
if (error) return alert(error.message)
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
try { try {
await callFilesAndVectorStoresApi({ const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
module: "vectorStores", method: "DELETE",
action: "delete", headers: { "Content-Type": "application/json" },
params: { id }, body: JSON.stringify({ documentos_id: id }),
}) })
await supabase if (!res.ok) {
.from("vector_store_files_meta") throw new Error("Se falló al eliminar el documento")
.delete() }
.eq("vector_store_id", id) } catch (err) {
console.error("Error al eliminar el documento:", err)
router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
} }
router.invalidate()
} }
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Card> <Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono">Repositorios de archivos</CardTitle> <CardTitle className="font-mono">Archivos de referencia</CardTitle>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center"> <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"> <div className="relative w-full sm:w-80">
@@ -212,499 +102,240 @@ function RouteComponent() {
<Input <Input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre o descripción…" placeholder="Buscar por título, etiqueta, fuente…"
className="pl-8" className="pl-8"
/> />
</div> </div>
<Select <Select value={estado} onValueChange={(v: any) => setEstado(v)}>
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<SelectTrigger className="sm:w-[160px]"> <SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Estado" /> <SelectValue placeholder="Estado" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="todos">Todos</SelectItem>
<SelectItem value="completed">Completados</SelectItem> <SelectItem value="proc">Procesados</SelectItem>
<SelectItem value="in_progress">En proceso</SelectItem> <SelectItem value="pend">Pendientes</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={() => setCreateOpen(true)}> <Select value={scope} onValueChange={(v: any) => setScope(v)}>
<Icons.FolderPlus className="w-4 h-4 mr-2" /> <SelectTrigger className="sm:w-[160px]">
Nuevo repositorio <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> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{filtered.length ? ( <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {filtered.map((r) => (
{filtered.map((vs) => ( <article
<article key={r.documentos_id}
key={vs.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3" >
> <header className="min-w-0">
<header className="min-w-0 space-y-1"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2"> <h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
<h3 className="font-semibold truncate"> <span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
{vs.name || "(Sin nombre)"} {r.procesado ? "Procesado" : "Pendiente"}
</h3> </span>
<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>
</article> <div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
))} {r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
</div> {r.interno != null && (
) : ( <Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
<div className="text-center text-sm text-neutral-500 py-10"> )}
No hay repositorios todavía. Crea uno nuevo para empezar 🚀 {r.fecha_subida && (
</div> <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>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<CreateVectorStoreDialog {/* Detalle */}
open={createOpen} <DetailDialog row={viewing} onClose={() => setViewing(null)} />
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
<VectorStoreDialog {/* Subida */}
store={selected} <UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => router.invalidate()}
/>
</div> </div>
) )
} }
/* ====== Crear repositorio ====== */ /* ========= Subida ========= */
function UploadDialog({
function CreateVectorStoreDialog({ open, onOpenChange, onDone,
open, }: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
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 handleCreate() {
if (!name.trim()) {
alert("Escribe un nombre para el repositorio")
return
}
setCreating(true)
try {
await callFilesAndVectorStoresApi<VectorStore>({
module: "vectorStores",
action: "create",
params: { name: name.trim(), description: description.trim() || undefined },
})
onOpenChange(false)
setName("")
setDescription("")
onCreated()
} catch (err: any) {
alert(err?.message ?? "Error al crear el repositorio")
} finally {
setCreating(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
<DialogDescription>
Crea un Vector Store para agrupar archivos relacionados.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1">
<Label>Nombre</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Planeación curricular, Entrevistas…"
/>
</div>
<div className="space-y-1">
<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={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 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 [file, setFile] = useState<File | null>(null)
const [label, setLabel] = useState("") const [instrucciones, setInstrucciones] = useState("")
const [tags, setTags] = useState("")
useEffect(() => { const [interno, setInterno] = useState(true)
if (!open || !store) return const [fuente, setFuente] = useState("")
void refreshFiles() const [subiendo, setSubiendo] = useState(false)
// 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> { async function toBase64(f: File): Promise<string> {
const buf = await f.arrayBuffer() const buf = await f.arrayBuffer()
const bytes = new Uint8Array(buf) const bytes = new Uint8Array(buf)
let binary = "" let binary = ""
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
binary += String.fromCharCode(bytes[i])
}
return btoa(binary) return btoa(binary)
} }
async function handleUpload() { async function upload() {
if (!store || !file) { if (!file) { alert("Selecciona un archivo"); return }
alert("Selecciona un archivo") if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
return
} setSubiendo(true)
setUploading(true)
try { try {
const fileBase64 = await toBase64(file) const fileBase64 = await toBase64(file)
// Enviamos al motor (inserta en la tabla si insert=true)
// 1) Subir archivo a OpenAI vía Edge (módulo files) const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
const uploaded: any = await callFilesAndVectorStoresApi<any>({ method: "POST",
module: "files", headers: { "Content-Type": "application/json" },
action: "upload", body: JSON.stringify({
params: { prompt: instrucciones,
fileBase64, fileBase64,
filename: file.name, insert: true,
mimeType: file.type, uuid: supabaseAuth.user?.id ?? null,
}, }),
}) })
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 */ }
const openaiFileId: string = if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
uploaded?.id ?? uploaded?.file?.id ?? uploaded?.data?.id await supabase
.from("documentos")
if (!openaiFileId) { .update({
throw new Error("La Edge Function no devolvió el id del archivo") tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
fuente_autoridad: fuente.trim() || undefined,
interno,
})
.eq("documentos_id", createdId)
} }
// 2) Mapear archivo al Vector Store onOpenChange(false)
await callFilesAndVectorStoresApi<any>({ onDone()
module: "vectorStoreFiles", } catch (e: any) {
action: "create", alert(e?.message ?? "Error al subir el documento")
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 { } finally {
setUploading(false) setSubiendo(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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
<Icons.Folder className="h-4 w-4" />
{store.name || "(Sin nombre)"}
</DialogTitle>
<DialogDescription> <DialogDescription>
Gestiona los archivos asociados a este repositorio (Vector Store). 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.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="grid gap-3">
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600"> <div className="space-y-1">
<StatusBadge status={store.status} /> <Label>Archivo</Label>
<Badge variant="outline"> <Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
Archivos completados: {store.file_counts?.completed ?? 0} {file && (
</Badge> <div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
<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> </div>
{/* Subida de archivo */} <div className="space-y-1">
<div className="space-y-2 rounded-lg border bg-muted/50 p-4"> <Label>Instrucciones</Label>
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500"> <Textarea
Agregar archivo al repositorio value={instrucciones}
</Label> onChange={(e) => setInstrucciones(e.target.value)}
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end"> placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
<div className="space-y-1"> className="min-h-[120px]"
<Input />
type="file" </div>
accept=".pdf,.doc,.docx,.txt,.md"
onChange={(e) => setFile(e.target.files?.[0] ?? null)} <div className="grid sm:grid-cols-2 gap-3">
/> <div className="space-y-1">
{file && ( <Label>Tags (separados por coma)</Label>
<div className="text-xs text-neutral-600"> <Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
{file.name} · {(file.size / 1024).toFixed(1)} KB </div>
</div> <div className="space-y-1">
)} <Label>Fuente de autoridad</Label>
</div> <Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
<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>
</div> </div>
{/* Lista de archivos */} <div className="space-y-1">
<div className="space-y-2"> <Label>Ámbito</Label>
<div className="flex items-center justify-between"> <Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
<Label>Archivos en este repositorio</Label> <SelectTrigger><SelectValue /></SelectTrigger>
<Button <SelectContent>
variant="ghost" <SelectItem value="true">Interno</SelectItem>
size="sm" <SelectItem value="false">Externo</SelectItem>
onClick={() => refreshFiles()} </SelectContent>
disabled={loading || refreshing} </Select>
>
<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>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
Cerrar <Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
{subiendo ? "Subiendo…" : "Subir"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>