Compare commits
5 Commits
main
...
Repostorio
| Author | SHA1 | Date | |
|---|---|---|---|
| 31cd071175 | |||
| c44698d0c7 | |||
| 85057e0f85 | |||
| 169599874e | |||
| 9b3880a02f |
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authentic
|
|||||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
||||||
import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras'
|
import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras'
|
||||||
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
||||||
|
import { Route as AuthenticatedArchivos2RouteImport } from './routes/_authenticated/archivos2'
|
||||||
import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos'
|
import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos'
|
||||||
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
|
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
|
||||||
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
|
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
|
||||||
@@ -69,6 +70,11 @@ const AuthenticatedAsignaturasRoute =
|
|||||||
path: '/asignaturas',
|
path: '/asignaturas',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedArchivos2Route = AuthenticatedArchivos2RouteImport.update({
|
||||||
|
id: '/archivos2',
|
||||||
|
path: '/archivos2',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedArchivosRoute = AuthenticatedArchivosRouteImport.update({
|
const AuthenticatedArchivosRoute = AuthenticatedArchivosRouteImport.update({
|
||||||
id: '/archivos',
|
id: '/archivos',
|
||||||
path: '/archivos',
|
path: '/archivos',
|
||||||
@@ -102,6 +108,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/archivos': typeof AuthenticatedArchivosRoute
|
'/archivos': typeof AuthenticatedArchivosRoute
|
||||||
|
'/archivos2': typeof AuthenticatedArchivos2Route
|
||||||
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
'/carreras': typeof AuthenticatedCarrerasRoute
|
'/carreras': typeof AuthenticatedCarrerasRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
@@ -117,6 +124,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/archivos': typeof AuthenticatedArchivosRoute
|
'/archivos': typeof AuthenticatedArchivosRoute
|
||||||
|
'/archivos2': typeof AuthenticatedArchivos2Route
|
||||||
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
'/carreras': typeof AuthenticatedCarrerasRoute
|
'/carreras': typeof AuthenticatedCarrerasRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
@@ -134,6 +142,7 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/_authenticated/archivos': typeof AuthenticatedArchivosRoute
|
'/_authenticated/archivos': typeof AuthenticatedArchivosRoute
|
||||||
|
'/_authenticated/archivos2': typeof AuthenticatedArchivos2Route
|
||||||
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
'/_authenticated/carreras': typeof AuthenticatedCarrerasRoute
|
'/_authenticated/carreras': typeof AuthenticatedCarrerasRoute
|
||||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
@@ -151,6 +160,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/archivos'
|
| '/archivos'
|
||||||
|
| '/archivos2'
|
||||||
| '/asignaturas'
|
| '/asignaturas'
|
||||||
| '/carreras'
|
| '/carreras'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
@@ -166,6 +176,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/archivos'
|
| '/archivos'
|
||||||
|
| '/archivos2'
|
||||||
| '/asignaturas'
|
| '/asignaturas'
|
||||||
| '/carreras'
|
| '/carreras'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
@@ -182,6 +193,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated'
|
| '/_authenticated'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/_authenticated/archivos'
|
| '/_authenticated/archivos'
|
||||||
|
| '/_authenticated/archivos2'
|
||||||
| '/_authenticated/asignaturas'
|
| '/_authenticated/asignaturas'
|
||||||
| '/_authenticated/carreras'
|
| '/_authenticated/carreras'
|
||||||
| '/_authenticated/dashboard'
|
| '/_authenticated/dashboard'
|
||||||
@@ -265,6 +277,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
|
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/archivos2': {
|
||||||
|
id: '/_authenticated/archivos2'
|
||||||
|
path: '/archivos2'
|
||||||
|
fullPath: '/archivos2'
|
||||||
|
preLoaderRoute: typeof AuthenticatedArchivos2RouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_authenticated/archivos': {
|
'/_authenticated/archivos': {
|
||||||
id: '/_authenticated/archivos'
|
id: '/_authenticated/archivos'
|
||||||
path: '/archivos'
|
path: '/archivos'
|
||||||
@@ -319,6 +338,7 @@ const AuthenticatedAsignaturasRouteWithChildren =
|
|||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute
|
AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute
|
||||||
|
AuthenticatedArchivos2Route: typeof AuthenticatedArchivos2Route
|
||||||
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren
|
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute
|
AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute
|
||||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
||||||
@@ -332,6 +352,7 @@ interface AuthenticatedRouteChildren {
|
|||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
AuthenticatedArchivosRoute: AuthenticatedArchivosRoute,
|
AuthenticatedArchivosRoute: AuthenticatedArchivosRoute,
|
||||||
|
AuthenticatedArchivos2Route: AuthenticatedArchivos2Route,
|
||||||
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren,
|
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren,
|
||||||
AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute,
|
AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute,
|
||||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const nav = [
|
|||||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
|
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
|
||||||
{ to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D },
|
{ to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D },
|
||||||
|
{ to: "/archivos2", label: "Repositorio de archivos", icon: FileAxis3D },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function getInitials(name?: string) {
|
function getInitials(name?: string) {
|
||||||
|
|||||||
718
src/routes/_authenticated/archivos2.tsx
Normal file
718
src/routes/_authenticated/archivos2.tsx
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
// routes/_authenticated/archivos.tsx
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
|
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"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
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,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
|
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/archivos2")({
|
||||||
|
component: RouteComponent,
|
||||||
|
loader: async () => {
|
||||||
|
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
|
||||||
|
module: "vectorStores",
|
||||||
|
action: "list",
|
||||||
|
params: {
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return stores ?? []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ====== 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 vectorStores = Route.useLoaderData() as VectorStore[]
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [vectorStores, q, statusFilter])
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await callFilesAndVectorStoresApi({
|
||||||
|
module: "vectorStores",
|
||||||
|
action: "delete",
|
||||||
|
params: { vector_store_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Buscar por nombre o descripción…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setStatusFilter(v as "all" | "completed" | "in_progress")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Icons.FolderPlus className="w-4 h-4 mr-2" />
|
||||||
|
Nuevo repositorio
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<CreateVectorStoreDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={() => router.invalidate()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VectorStoreDialog
|
||||||
|
store={selected}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open)
|
||||||
|
if (!open) setSelected(null)
|
||||||
|
}}
|
||||||
|
onUpdated={() => 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
|
||||||
|
}) {
|
||||||
|
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 handleUpload() {
|
||||||
|
if (!store || !file) {
|
||||||
|
alert("Selecciona un archivo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
// 1) Subir archivo a OpenAI vía Edge con FormData (igual que en tu script)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("module", "files")
|
||||||
|
formData.append("action", "upload")
|
||||||
|
formData.append("file", file)
|
||||||
|
formData.append("purpose", "assistants") // o lo que uses en tu flujo
|
||||||
|
|
||||||
|
const { data, error } = await supabase.functions.invoke<any>(
|
||||||
|
"files-and-vector-stores-api",
|
||||||
|
{
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = data
|
||||||
|
// La respuesta es el objeto "file" de OpenAI:
|
||||||
|
// { object: "file", id: "file-xxx", ... }
|
||||||
|
const openaiFileId: string | undefined = uploaded?.id
|
||||||
|
|
||||||
|
if (!openaiFileId) {
|
||||||
|
console.error("Respuesta Edge inesperada:", uploaded)
|
||||||
|
throw new Error("La Edge Function no devolvió el id del archivo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Mapear archivo al Vector Store (JSON normal)
|
||||||
|
await callFilesAndVectorStoresApi<any>({
|
||||||
|
module: "vectorStoreFiles",
|
||||||
|
action: "create",
|
||||||
|
params: {
|
||||||
|
vector_store_id: store.id,
|
||||||
|
body: {
|
||||||
|
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>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -265,7 +265,7 @@ function Page() {
|
|||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{/^\s*\d+/.test(String(u.key))
|
{/^\s*\d+/.test(String(u.key))
|
||||||
? `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}${u.__title ? `: ${u.__title}` : ""}`
|
? `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}${u.__title ? `: ${u.__title}` : ""}`
|
||||||
: u.__title}
|
: u.__title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user