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*
.nitro
.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 token = session?.access_token;
const resp = await supabase.functions.invoke("conversation-format", {
const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
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 token = session?.access_token;
await supabase.functions.invoke("conversation-format", {
await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` },
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(
"conversation-format",
"modal-conversation",
{
headers: { Authorization: `Bearer ${token}` },
body: payload

View File

@@ -330,7 +330,10 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
section: null,//,iaContext?.title,
fieldKey: null,//iaContext?.key,
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) => {
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
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 * as Icons from "lucide-react"
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 { 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,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import { DetailDialog } from "@/components/archivos/DetailDialog"
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
}
import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent,
loader: async () => {
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
module: "vectorStores",
action: "list",
params: {},
})
return stores ?? []
const { data, error } = await supabase
.from("documentos")
.select("*")
.order("fecha_subida", { ascending: false })
.limit(200)
if (error) throw error
return (data ?? []) as RefRow[]
},
})
/* ====== 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>
)
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"
}
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
function RouteComponent() {
const router = useRouter()
const vectorStores = Route.useLoaderData() as VectorStore[]
const rows = Route.useLoaderData() as RefRow[]
const [q, setQ] = useState("")
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 [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 filtered = useMemo(() => {
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)
)
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
})
}, [vectorStores, q, statusFilter])
}, [rows, q, estado, scope])
function openDetails(vs: VectorStore) {
setSelected(vs)
setDialogOpen(true)
}
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)
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
try {
await callFilesAndVectorStoresApi({
module: "vectorStores",
action: "delete",
params: { id },
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 supabase
.from("vector_store_files_meta")
.delete()
.eq("vector_store_id", id)
router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
if (!res.ok) {
throw new Error("Se falló al eliminar el documento")
}
} catch (err) {
console.error("Error al eliminar el documento:", err)
}
router.invalidate()
}
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">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="relative w-full sm:w-80">
@@ -212,499 +102,240 @@ function RouteComponent() {
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre o descripción…"
placeholder="Buscar por título, etiqueta, fuente…"
className="pl-8"
/>
</div>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
<SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="completed">Completados</SelectItem>
<SelectItem value="in_progress">En proceso</SelectItem>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="proc">Procesados</SelectItem>
<SelectItem value="pend">Pendientes</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setCreateOpen(true)}>
<Icons.FolderPlus className="w-4 h-4 mr-2" />
Nuevo repositorio
<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>
</div>
</CardHeader>
<CardContent>
{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 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>
</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>
<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>
)}
</CardContent>
</Card>
<CreateVectorStoreDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
{/* Detalle */}
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
<VectorStoreDialog
store={selected}
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => router.invalidate()}
/>
{/* Subida */}
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
</div>
)
}
/* ====== Crear repositorio ====== */
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 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
}) {
/* ========= Subida ========= */
function UploadDialog({
open, onOpenChange, onDone,
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => 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)
}
}
const [instrucciones, setInstrucciones] = useState("")
const [tags, setTags] = useState("")
const [interno, setInterno] = useState(true)
const [fuente, setFuente] = useState("")
const [subiendo, setSubiendo] = useState(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])
}
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)
async function upload() {
if (!file) { alert("Selecciona un archivo"); return }
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
setSubiendo(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: {
// 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,
filename: file.name,
mimeType: file.type,
},
insert: true,
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 =
uploaded?.id ?? uploaded?.file?.id ?? uploaded?.data?.id
if (!openaiFileId) {
throw new Error("La Edge Function no devolvió el id del archivo")
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)
}
// 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")
onOpenChange(false)
onDone()
} catch (e: any) {
alert(e?.message ?? "Error al subir el documento")
} 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.Folder className="h-4 w-4" />
{store.name || "(Sin nombre)"}
</DialogTitle>
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
<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>
</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 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>
{/* 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 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]"
/>
</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>
{/* 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 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>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cerrar
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
{subiendo ? "Subiendo…" : "Subir"}
</Button>
</DialogFooter>
</DialogContent>