Primera version de chunkeo completo crud

This commit is contained in:
Sebastian
2025-11-05 19:18:11 +00:00
parent df2c184814
commit 7c6e8c4858
36 changed files with 6242 additions and 5 deletions

View File

@@ -11,7 +11,10 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.543.0",
@@ -878,6 +881,44 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1204,12 +1245,41 @@
"node": ">= 8"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
@@ -1240,6 +1310,32 @@
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -1305,6 +1401,21 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
@@ -1413,6 +1524,38 @@
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@@ -1484,6 +1627,80 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -1502,6 +1719,65 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -1602,6 +1878,24 @@
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
@@ -1620,6 +1914,35 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.34",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz",

View File

@@ -13,7 +13,10 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.543.0",

View File

@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Button } from './ui/button'
import { AlertCircle, Loader2, FileText, CheckCircle2, XCircle } from 'lucide-react'
import type { ChunkingConfig } from './ChunkingConfigModal'
interface ChunkPreviewPanelProps {
isOpen: boolean
onClose: () => void
config: ChunkingConfig | null
onAccept: (config: ChunkingConfig) => void
onCancel: () => void
}
interface PreviewChunk {
index: number
text: string
page: number
file_name: string
tokens: number
}
export function ChunkPreviewPanel({
isOpen,
onClose,
config,
onAccept,
onCancel,
}: ChunkPreviewPanelProps) {
const [chunks, setChunks] = useState<PreviewChunk[]>([])
const [loading, setLoading] = useState(false)
const [processing, setProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
// Auto-cargar preview cuando se abre el modal
useEffect(() => {
if (isOpen && config && chunks.length === 0) {
loadPreview()
}
}, [isOpen, config])
const loadPreview = async () => {
if (!config) return
setLoading(true)
setError(null)
setSuccess(false)
try {
const result = await api.generateChunkPreview(config)
setChunks(result.chunks)
} catch (err) {
console.error('Error loading preview:', err)
setError(err instanceof Error ? err.message : 'Error generando preview')
} finally {
setLoading(false)
}
}
const handleAccept = async () => {
if (!config) return
setProcessing(true)
setError(null)
try {
await onAccept(config)
setSuccess(true)
// Cerrar después de 2 segundos
setTimeout(() => {
handleClose()
}, 2000)
} catch (err) {
console.error('Error processing:', err)
setError(err instanceof Error ? err.message : 'Error procesando PDF')
} finally {
setProcessing(false)
}
}
const handleCancel = () => {
onCancel()
handleClose()
}
const handleClose = () => {
setChunks([])
setError(null)
setSuccess(false)
onClose()
}
if (!config) return null
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Preview de Chunks
</DialogTitle>
<DialogDescription>
Vista previa de chunks para <strong>{config.file_name}</strong>
</DialogDescription>
</DialogHeader>
{/* Contenido */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-500">Generando preview...</span>
</div>
) : error ? (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-4 rounded">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
) : success ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="w-16 h-16 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-green-700">
Procesamiento Completado
</h3>
<p className="text-sm text-gray-600 mt-2">
El PDF ha sido procesado y subido a Qdrant exitosamente
</p>
</div>
) : chunks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>No hay chunks para mostrar</p>
</div>
) : (
<div className="space-y-4">
{/* Información de configuración */}
<div className="bg-blue-50 p-3 rounded">
<p className="text-sm text-blue-800">
<strong>Configuración:</strong> Max {config.max_tokens} tokens, Target{' '}
{config.target_tokens} tokens
{config.use_llm && ' | LLM Habilitado'}
</p>
</div>
{/* Lista de chunks */}
{chunks.map((chunk) => (
<div key={chunk.index} className="border rounded-lg p-4 space-y-2">
{/* Header del chunk */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-700">
Chunk #{chunk.index + 1}
</span>
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
Página {chunk.page}
</span>
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-1 rounded">
~{chunk.tokens} tokens
</span>
</div>
</div>
{/* Texto del chunk */}
<div className="bg-gray-50 p-3 rounded text-sm">
<p className="text-gray-700 whitespace-pre-wrap leading-relaxed">
{chunk.text}
</p>
</div>
{/* Indicador de longitud */}
<div className="text-xs text-gray-500">
Longitud: {chunk.text.length} caracteres
</div>
</div>
))}
{/* Información adicional */}
<div className="bg-yellow-50 border border-yellow-200 p-3 rounded">
<p className="text-sm text-yellow-800">
<strong>Nota:</strong> Estos son chunks de ejemplo (hasta 3). El documento
completo generará más chunks según su tamaño.
</p>
</div>
</div>
)}
</div>
{/* Footer con acciones */}
<DialogFooter className="flex justify-between items-center pt-4 border-t">
<Button
variant="outline"
onClick={handleCancel}
disabled={processing || success}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<XCircle className="w-4 h-4 mr-2" />
Cancelar
</Button>
<Button onClick={handleAccept} disabled={processing || loading || chunks.length === 0 || success}>
{processing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Procesando...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Aceptar y Procesar
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Button } from './ui/button'
import { AlertCircle, Loader2, FileText, Trash2 } from 'lucide-react'
interface ChunkViewerModalProps {
isOpen: boolean
onClose: () => void
fileName: string
tema: string
}
interface Chunk {
id: string
payload: {
page_content: string
metadata: {
file_name: string
page: number
[key: string]: any
}
[key: string]: any
}
vector?: number[]
}
export function ChunkViewerModal({ isOpen, onClose, fileName, tema }: ChunkViewerModalProps) {
const [chunks, setChunks] = useState<Chunk[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (isOpen && fileName && tema) {
loadChunks()
}
}, [isOpen, fileName, tema])
const loadChunks = async () => {
setLoading(true)
setError(null)
try {
const result = await api.getChunksByFile(tema, fileName)
setChunks(result.chunks)
} catch (err) {
console.error('Error loading chunks:', err)
setError(err instanceof Error ? err.message : 'Error al cargar chunks')
} finally {
setLoading(false)
}
}
const handleDeleteFile = async () => {
if (!confirm(`¿Estás seguro de eliminar todos los chunks del archivo "${fileName}" de la colección "${tema}"?`)) {
return
}
setDeleting(true)
setError(null)
try {
await api.deleteFileFromCollection(tema, fileName)
alert('Archivo eliminado de la colección exitosamente')
onClose()
} catch (err) {
console.error('Error deleting file from collection:', err)
setError(err instanceof Error ? err.message : 'Error al eliminar archivo')
} finally {
setDeleting(false)
}
}
const handleClose = () => {
setChunks([])
setError(null)
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Chunks de "{fileName}"
</DialogTitle>
<DialogDescription>
Colección: <strong>{tema}</strong>
</DialogDescription>
</DialogHeader>
{/* Contenido */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-500">Cargando chunks...</span>
</div>
) : error ? (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-4 rounded">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
) : chunks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>No se encontraron chunks para este archivo.</p>
<p className="text-sm mt-1">El archivo aún no ha sido procesado o no existe en la colección.</p>
</div>
) : (
<div className="space-y-4">
{/* Estadísticas */}
<div className="bg-blue-50 p-3 rounded">
<p className="text-sm text-blue-800">
<strong>Total de chunks:</strong> {chunks.length}
</p>
</div>
{/* Lista de chunks */}
{chunks.map((chunk, index) => (
<div key={chunk.id} className="border rounded-lg p-4 space-y-2">
{/* Header del chunk */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-700">
Chunk #{index + 1}
</span>
<span className="text-xs text-gray-500">
Página {chunk.payload.metadata.page}
</span>
</div>
<span className="text-xs text-gray-400 font-mono">
ID: {chunk.id.substring(0, 8)}...
</span>
</div>
{/* Texto del chunk */}
{chunk.payload.page_content && (
<div className="bg-gray-50 p-3 rounded text-sm">
<p className="text-gray-700 whitespace-pre-wrap">
{chunk.payload.page_content}
</p>
<div className="mt-2 text-xs text-gray-500">
<strong>Caracteres:</strong> {chunk.payload.page_content.length}
</div>
</div>
)}
{/* Metadata */}
<div className="text-xs text-gray-500">
<strong>Metadata:</strong>
<pre className="mt-1 bg-gray-100 p-2 rounded overflow-x-auto">
{JSON.stringify(chunk.payload.metadata, null, 2)}
</pre>
</div>
{/* Información del vector (opcional) */}
{chunk.vector && (
<div className="text-xs text-gray-400">
Vector dimension: {chunk.vector.length}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Footer con acciones */}
<div className="flex justify-between items-center pt-4 border-t">
<Button
variant="outline"
onClick={handleDeleteFile}
disabled={deleting || chunks.length === 0}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{deleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Eliminando...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Eliminar de colección
</>
)}
</Button>
<Button onClick={handleClose}>
Cerrar
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,307 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Button } from './ui/button'
import { Label } from './ui/label'
import { Input } from './ui/input'
import { Textarea } from './ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import { Switch } from './ui/switch'
import { AlertCircle, Loader2, Settings, Sparkles } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
interface ChunkingConfigModalProps {
isOpen: boolean
onClose: () => void
fileName: string
tema: string
collectionName: string
onPreview: (config: ChunkingConfig) => void
}
export interface ChunkingConfig {
file_name: string
tema: string
collection_name: string
max_tokens: number
target_tokens: number
chunk_size: number
chunk_overlap: number
use_llm: boolean
custom_instructions: string
}
interface ChunkingProfile {
id: string
name: string
description: string
max_tokens: number
target_tokens: number
chunk_size: number
chunk_overlap: number
use_llm: boolean
}
export function ChunkingConfigModal({
isOpen,
onClose,
fileName,
tema,
collectionName,
onPreview,
}: ChunkingConfigModalProps) {
const [profiles, setProfiles] = useState<ChunkingProfile[]>([])
const [selectedProfile, setSelectedProfile] = useState<string>('balanced')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Custom configuration
const [maxTokens, setMaxTokens] = useState(950)
const [targetTokens, setTargetTokens] = useState(800)
const [chunkSize, setChunkSize] = useState(1000)
const [chunkOverlap, setChunkOverlap] = useState(200)
const [useLLM, setUseLLM] = useState(true)
const [customInstructions, setCustomInstructions] = useState('')
useEffect(() => {
if (isOpen) {
loadProfiles()
}
}, [isOpen])
const loadProfiles = async () => {
setLoading(true)
setError(null)
try {
const result = await api.getChunkingProfiles()
setProfiles(result.profiles)
} catch (err) {
console.error('Error loading profiles:', err)
setError(err instanceof Error ? err.message : 'Error cargando perfiles')
} finally {
setLoading(false)
}
}
const handleProfileChange = (profileId: string) => {
setSelectedProfile(profileId)
const profile = profiles.find((p) => p.id === profileId)
if (profile) {
setMaxTokens(profile.max_tokens)
setTargetTokens(profile.target_tokens)
setChunkSize(profile.chunk_size)
setChunkOverlap(profile.chunk_overlap)
setUseLLM(profile.use_llm)
}
}
const handlePreview = () => {
const config: ChunkingConfig = {
file_name: fileName,
tema: tema,
collection_name: collectionName,
max_tokens: maxTokens,
target_tokens: targetTokens,
chunk_size: chunkSize,
chunk_overlap: chunkOverlap,
use_llm: useLLM,
custom_instructions: useLLM ? customInstructions : '',
}
onPreview(config)
}
const handleClose = () => {
setError(null)
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Configurar Chunking
</DialogTitle>
<DialogDescription>
Configura cómo se procesará el archivo <strong>{fileName}</strong>
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-500">Cargando perfiles...</span>
</div>
) : error ? (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-4 rounded">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
) : (
<Tabs defaultValue="profiles" className="flex-1">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="profiles">Perfiles</TabsTrigger>
<TabsTrigger value="custom">Personalizado</TabsTrigger>
</TabsList>
{/* Tab de Perfiles */}
<TabsContent value="profiles" className="space-y-4">
<div className="space-y-2">
<Label>Perfil de Configuración</Label>
<Select value={selectedProfile} onValueChange={handleProfileChange}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un perfil" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<div className="flex flex-col">
<span className="font-medium">{profile.name}</span>
<span className="text-xs text-gray-500">{profile.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Mostrar detalles del perfil seleccionado */}
{selectedProfile && (
<div className="bg-gray-50 p-4 rounded-lg space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="font-medium">Max Tokens:</span> {maxTokens}
</div>
<div>
<span className="font-medium">Target Tokens:</span> {targetTokens}
</div>
<div>
<span className="font-medium">Chunk Size:</span> {chunkSize}
</div>
<div>
<span className="font-medium">Overlap:</span> {chunkOverlap}
</div>
<div className="col-span-2">
<span className="font-medium">LLM:</span>{' '}
{useLLM ? '✅ Habilitado' : '❌ Deshabilitado'}
</div>
</div>
</div>
)}
</TabsContent>
{/* Tab Personalizado */}
<TabsContent value="custom" className="space-y-4 overflow-y-auto max-h-[50vh]">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxTokens">Max Tokens</Label>
<Input
id="maxTokens"
type="number"
min={100}
max={2000}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="targetTokens">Target Tokens</Label>
<Input
id="targetTokens"
type="number"
min={100}
max={2000}
value={targetTokens}
onChange={(e) => setTargetTokens(Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="chunkSize">Chunk Size</Label>
<Input
id="chunkSize"
type="number"
min={100}
max={3000}
value={chunkSize}
onChange={(e) => setChunkSize(Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="chunkOverlap">Chunk Overlap</Label>
<Input
id="chunkOverlap"
type="number"
min={0}
max={1000}
value={chunkOverlap}
onChange={(e) => setChunkOverlap(Number(e.target.value))}
/>
</div>
</div>
{/* Toggle LLM */}
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-blue-600" />
<div>
<Label htmlFor="useLLM" className="font-medium cursor-pointer">
Usar LLM (Gemini)
</Label>
<p className="text-xs text-gray-600">
Procesamiento inteligente con IA
</p>
</div>
</div>
<Switch
id="useLLM"
checked={useLLM}
onCheckedChange={setUseLLM}
/>
</div>
{/* Custom Instructions (solo si LLM está habilitado) */}
{useLLM && (
<div className="space-y-2">
<Label htmlFor="customInstructions">
Instrucciones Personalizadas (Opcional)
</Label>
<Textarea
id="customInstructions"
placeholder="Ej: Mantén todos los términos técnicos en inglés..."
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
rows={3}
/>
<p className="text-xs text-gray-500">
Instrucciones adicionales para guiar el procesamiento con IA
</p>
</div>
)}
</TabsContent>
</Tabs>
)}
<DialogFooter className="flex justify-between items-center pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button onClick={handlePreview} disabled={loading}>
Generar Preview
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Button } from './ui/button'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
interface CollectionVerifierProps {
tema: string | null
onVerified?: (exists: boolean) => void
}
export function CollectionVerifier({ tema, onVerified }: CollectionVerifierProps) {
const [isChecking, setIsChecking] = useState(false)
const [collectionExists, setCollectionExists] = useState<boolean | null>(null)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (tema) {
checkCollection()
} else {
setCollectionExists(null)
}
}, [tema])
const checkCollection = async () => {
if (!tema) return
setIsChecking(true)
setError(null)
try {
const result = await api.checkCollectionExists(tema)
setCollectionExists(result.exists)
// Si no existe, mostrar el diálogo de confirmación
if (!result.exists) {
setShowCreateDialog(true)
}
onVerified?.(result.exists)
} catch (err) {
console.error('Error checking collection:', err)
setError(err instanceof Error ? err.message : 'Error al verificar colección')
setCollectionExists(null)
} finally {
setIsChecking(false)
}
}
const handleCreateCollection = async () => {
if (!tema) return
setIsCreating(true)
setError(null)
try {
const result = await api.createCollection(tema)
if (result.success) {
setCollectionExists(true)
setShowCreateDialog(false)
onVerified?.(true)
}
} catch (err) {
console.error('Error creating collection:', err)
setError(err instanceof Error ? err.message : 'Error al crear colección')
} finally {
setIsCreating(false)
}
}
const handleCancelCreate = () => {
setShowCreateDialog(false)
// Opcionalmente podemos notificar que no se creó la colección
onVerified?.(false)
}
// No renderizar nada si no hay tema seleccionado
if (!tema) {
return null
}
return (
<>
{/* Indicador de estado de la colección */}
{isChecking ? (
<div className="flex items-center gap-2 text-sm text-gray-500 mb-4">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Verificando colección...</span>
</div>
) : collectionExists === true ? (
<div className="flex items-center gap-2 text-sm text-green-600 mb-4">
<CheckCircle2 className="w-4 h-4" />
<span>Colección "{tema}" disponible en Qdrant</span>
</div>
) : collectionExists === false ? (
<div className="flex items-center gap-2 text-sm text-yellow-600 mb-4">
<AlertCircle className="w-4 h-4" />
<span>Colección "{tema}" no existe en Qdrant</span>
</div>
) : error ? (
<div className="flex items-center gap-2 text-sm text-red-600 mb-4">
<AlertCircle className="w-4 h-4" />
<span>{error}</span>
</div>
) : null}
{/* Diálogo de confirmación para crear colección */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Crear colección en Qdrant</DialogTitle>
<DialogDescription>
La colección "<strong>{tema}</strong>" no existe en la base de datos vectorial.
<br />
<br />
¿Deseas crear esta colección ahora? Esto permitirá almacenar y buscar chunks de
documentos para este tema.
</DialogDescription>
</DialogHeader>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-3 rounded">
<AlertCircle className="w-4 h-4" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={handleCancelCreate}
disabled={isCreating}
>
Cancelar
</Button>
<Button
onClick={handleCreateCollection}
disabled={isCreating}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creando...
</>
) : (
'Crear colección'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -15,6 +15,10 @@ import { Checkbox } from '@/components/ui/checkbox'
import { FileUpload } from './FileUpload'
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
import { PDFPreviewModal } from './PDFPreviewModal'
import { CollectionVerifier } from './CollectionVerifier'
import { ChunkViewerModal } from './ChunkViewerModal'
import { ChunkingConfigModal, type ChunkingConfig } from './ChunkingConfigModal'
import { ChunkPreviewPanel } from './ChunkPreviewPanel'
import {
Upload,
Download,
@@ -22,7 +26,8 @@ import {
Search,
FileText,
Eye,
MessageSquare
MessageSquare,
Scissors
} from 'lucide-react'
export function Dashboard() {
@@ -52,6 +57,20 @@ export function Dashboard() {
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(undefined)
const [loadingPreview, setLoadingPreview] = useState(false)
// Estados para el modal de chunks
const [chunkViewerOpen, setChunkViewerOpen] = useState(false)
const [chunkFileName, setChunkFileName] = useState('')
const [chunkFileTema, setChunkFileTema] = useState('')
// Estados para chunking
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false)
const [chunkingFileName, setChunkingFileName] = useState('')
const [chunkingFileTema, setChunkingFileTema] = useState('')
const [chunkingCollectionName, setChunkingCollectionName] = useState('')
const [chunkPreviewOpen, setChunkPreviewOpen] = useState(false)
const [chunkingConfig, setChunkingConfig] = useState<ChunkingConfig | null>(null)
useEffect(() => {
loadFiles()
}, [selectedTema])
@@ -173,6 +192,54 @@ export function Dashboard() {
}
}
// Abrir modal de chunks
const handleViewChunks = (filename: string, tema: string) => {
if (!tema) {
alert('No hay tema seleccionado. Por favor selecciona un tema primero.')
return
}
setChunkFileName(filename)
setChunkFileTema(tema)
setChunkViewerOpen(true)
}
// Handlers para chunking
const handleStartChunking = (filename: string, tema: string) => {
if (!tema) {
alert('No hay tema seleccionado. Por favor selecciona un tema primero.')
return
}
setChunkingFileName(filename)
setChunkingFileTema(tema)
setChunkingCollectionName(tema) // Usar el tema como nombre de colección
setChunkingConfigOpen(true)
}
const handlePreviewChunking = (config: ChunkingConfig) => {
setChunkingConfig(config)
setChunkingConfigOpen(false)
setChunkPreviewOpen(true)
}
const handleAcceptChunking = async (config: ChunkingConfig) => {
try {
const result = await api.processChunkingFull(config)
alert(`Procesamiento completado: ${result.chunks_added} chunks agregados a ${result.collection_name}`)
// Recargar archivos para actualizar el estado
loadFiles()
} catch (error) {
console.error('Error processing PDF:', error)
throw error
}
}
const handleCancelChunking = () => {
setChunkPreviewOpen(false)
setChunkingConfig(null)
// Opcionalmente volver al modal de configuración
// setChunkingConfigOpen(true)
}
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
)
@@ -350,10 +417,19 @@ export function Dashboard() {
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Procesar con chunking"
onClick={() => handleStartChunking(file.name, file.tema)}
>
<Scissors className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Ver chunks"
onClick={() => handleViewChunks(file.name, file.tema)}
>
<Eye className="w-4 h-4" />
</Button>
@@ -406,6 +482,41 @@ export function Dashboard() {
fileName={previewFileName}
onDownload={handleDownloadFromPreview}
/>
{/* Collection Verifier - Verifica/crea colección cuando se selecciona un tema */}
<CollectionVerifier
tema={selectedTema}
onVerified={(exists) => {
console.log(`Collection ${selectedTema} exists: ${exists}`)
}}
/>
{/* Chunk Viewer Modal */}
<ChunkViewerModal
isOpen={chunkViewerOpen}
onClose={() => setChunkViewerOpen(false)}
fileName={chunkFileName}
tema={chunkFileTema}
/>
{/* Modal de configuración de chunking */}
<ChunkingConfigModal
isOpen={chunkingConfigOpen}
onClose={() => setChunkingConfigOpen(false)}
fileName={chunkingFileName}
tema={chunkingFileTema}
collectionName={chunkingCollectionName}
onPreview={handlePreviewChunking}
/>
{/* Panel de preview de chunks */}
<ChunkPreviewPanel
isOpen={chunkPreviewOpen}
onClose={() => setChunkPreviewOpen(false)}
config={chunkingConfig}
onAccept={handleAcceptChunking}
onCancel={handleCancelChunking}
/>
</div>
)
}

View File

@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -188,4 +188,236 @@ export const api = {
return data.url
},
// ============================================================================
// Vector Database / Qdrant Operations
// ============================================================================
// Health check de la base de datos vectorial
vectorHealthCheck: async (): Promise<{ status: string; db_type: string; message: string }> => {
const response = await fetch(`${API_BASE_URL}/vectors/health`)
if (!response.ok) throw new Error('Error checking vector DB health')
return response.json()
},
// Verificar si una colección existe
checkCollectionExists: async (collectionName: string): Promise<{ exists: boolean; collection_name: string }> => {
const response = await fetch(`${API_BASE_URL}/vectors/collections/exists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ collection_name: collectionName }),
})
if (!response.ok) throw new Error('Error checking collection')
return response.json()
},
// Crear una nueva colección
createCollection: async (
collectionName: string,
vectorSize: number = 3072,
distance: string = 'Cosine'
): Promise<{ success: boolean; collection_name: string; message: string }> => {
const response = await fetch(`${API_BASE_URL}/vectors/collections/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
collection_name: collectionName,
vector_size: vectorSize,
distance: distance,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Error creating collection')
}
return response.json()
},
// Eliminar una colección
deleteCollection: async (collectionName: string): Promise<{ success: boolean; collection_name: string; message: string }> => {
const response = await fetch(`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Error deleting collection')
return response.json()
},
// Obtener información de una colección
getCollectionInfo: async (collectionName: string): Promise<{
name: string
vectors_count: number
vectors_config: { size: number; distance: string }
status: string
}> => {
const response = await fetch(`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/info`)
if (!response.ok) throw new Error('Error getting collection info')
return response.json()
},
// Verificar si un archivo existe en una colección
checkFileExistsInCollection: async (
collectionName: string,
fileName: string
): Promise<{ exists: boolean; collection_name: string; file_name: string; chunk_count?: number }> => {
const response = await fetch(`${API_BASE_URL}/vectors/files/exists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
collection_name: collectionName,
file_name: fileName,
}),
})
if (!response.ok) throw new Error('Error checking file in collection')
return response.json()
},
// Obtener chunks de un archivo
getChunksByFile: async (
collectionName: string,
fileName: string,
limit?: number
): Promise<{
collection_name: string
file_name: string
chunks: Array<{ id: string; payload: any; vector?: number[] }>
total_chunks: number
}> => {
const url = limit
? `${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}/chunks?limit=${limit}`
: `${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}/chunks`
const response = await fetch(url)
if (!response.ok) throw new Error('Error getting chunks')
return response.json()
},
// Eliminar archivo de colección
deleteFileFromCollection: async (
collectionName: string,
fileName: string
): Promise<{ success: boolean; collection_name: string; file_name: string; chunks_deleted: number; message: string }> => {
const response = await fetch(
`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}`,
{ method: 'DELETE' }
)
if (!response.ok) throw new Error('Error deleting file from collection')
return response.json()
},
// Agregar chunks a una colección
addChunks: async (
collectionName: string,
chunks: Array<{ id: string; vector: number[]; payload: any }>
): Promise<{ success: boolean; collection_name: string; chunks_added: number; message: string }> => {
const response = await fetch(`${API_BASE_URL}/vectors/chunks/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
collection_name: collectionName,
chunks: chunks,
}),
})
if (!response.ok) throw new Error('Error adding chunks')
return response.json()
},
// ============================================================================
// Chunking Operations
// ============================================================================
// Obtener perfiles de chunking predefinidos
getChunkingProfiles: async (): Promise<{
profiles: Array<{
id: string
name: string
description: string
max_tokens: number
target_tokens: number
chunk_size: number
chunk_overlap: number
use_llm: boolean
}>
}> => {
const response = await fetch(`${API_BASE_URL}/chunking/profiles`)
if (!response.ok) throw new Error('Error fetching chunking profiles')
return response.json()
},
// Generar preview de chunks (hasta 3 chunks)
generateChunkPreview: async (config: {
file_name: string
tema: string
max_tokens?: number
target_tokens?: number
chunk_size?: number
chunk_overlap?: number
use_llm?: boolean
custom_instructions?: string
}): Promise<{
success: boolean
file_name: string
tema: string
chunks: Array<{
index: number
text: string
page: number
file_name: string
tokens: number
}>
message: string
}> => {
const response = await fetch(`${API_BASE_URL}/chunking/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Error generating preview')
}
return response.json()
},
// Procesar PDF completo
processChunkingFull: async (config: {
file_name: string
tema: string
collection_name: string
max_tokens?: number
target_tokens?: number
chunk_size?: number
chunk_overlap?: number
use_llm?: boolean
custom_instructions?: string
}): Promise<{
success: boolean
collection_name: string
file_name: string
total_chunks: number
chunks_added: number
message: string
}> => {
const response = await fetch(`${API_BASE_URL}/chunking/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Error processing PDF')
}
return response.json()
},
}