Landing AI integrado
This commit is contained in:
1615
frontend/package-lock.json
generated
1615
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
345
frontend/src/components/ChunkingConfigModalLandingAI.tsx
Normal file
345
frontend/src/components/ChunkingConfigModalLandingAI.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Chunking Config Modal con LandingAI
|
||||
* Reemplaza el modal anterior, ahora usa LandingAI con modos flexible y schemas
|
||||
*/
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||
import { AlertCircle, Loader2, Settings, Zap, Target, FileText, Table2, Image } from 'lucide-react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
|
||||
import type { CustomSchema } from '@/types/schema'
|
||||
|
||||
interface ChunkingConfigModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
fileName: string
|
||||
tema: string
|
||||
collectionName: string
|
||||
onProcess: (config: LandingAIConfig) => void
|
||||
}
|
||||
|
||||
export interface LandingAIConfig {
|
||||
file_name: string
|
||||
tema: string
|
||||
collection_name: string
|
||||
mode: 'quick' | 'extract'
|
||||
schema_id?: string
|
||||
include_chunk_types: string[]
|
||||
max_tokens_per_chunk: number
|
||||
merge_small_chunks: boolean
|
||||
}
|
||||
|
||||
export function ChunkingConfigModalLandingAI({
|
||||
isOpen,
|
||||
onClose,
|
||||
fileName,
|
||||
tema,
|
||||
collectionName,
|
||||
onProcess,
|
||||
}: ChunkingConfigModalProps) {
|
||||
const [mode, setMode] = useState<'quick' | 'extract'>('quick')
|
||||
const [schemas, setSchemas] = useState<CustomSchema[]>([])
|
||||
const [selectedSchema, setSelectedSchema] = useState<string | undefined>()
|
||||
const [chunkTypes, setChunkTypes] = useState<string[]>(['text', 'table'])
|
||||
const [maxTokens, setMaxTokens] = useState(1500)
|
||||
const [mergeSmall, setMergeSmall] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSchemas()
|
||||
}
|
||||
}, [isOpen, tema])
|
||||
|
||||
const loadSchemas = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const data = await api.listSchemas(tema)
|
||||
setSchemas(data)
|
||||
} catch (err) {
|
||||
console.error('Error loading schemas:', err)
|
||||
setError(err instanceof Error ? err.message : 'Error cargando schemas')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChunkType = (type: string) => {
|
||||
if (chunkTypes.includes(type)) {
|
||||
setChunkTypes(chunkTypes.filter(t => t !== type))
|
||||
} else {
|
||||
setChunkTypes([...chunkTypes, type])
|
||||
}
|
||||
}
|
||||
|
||||
const handleProcess = () => {
|
||||
if (mode === 'extract' && !selectedSchema) {
|
||||
setError('Debes seleccionar un schema en modo extracción')
|
||||
return
|
||||
}
|
||||
|
||||
if (chunkTypes.length === 0) {
|
||||
setError('Debes seleccionar al menos un tipo de contenido')
|
||||
return
|
||||
}
|
||||
|
||||
const config: LandingAIConfig = {
|
||||
file_name: fileName,
|
||||
tema: tema,
|
||||
collection_name: collectionName,
|
||||
mode: mode,
|
||||
schema_id: selectedSchema,
|
||||
include_chunk_types: chunkTypes,
|
||||
max_tokens_per_chunk: maxTokens,
|
||||
merge_small_chunks: mergeSmall,
|
||||
}
|
||||
|
||||
onProcess(config)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const selectedSchemaData = schemas.find(s => s.schema_id === selectedSchema)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Configurar Procesamiento con LandingAI
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configura cómo se procesará <strong>{fileName}</strong> usando LandingAI Document AI
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 flex-1 overflow-y-auto">
|
||||
{/* Modo de Procesamiento */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold">Modo de Procesamiento</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ModeCard
|
||||
icon={<Zap className="h-6 w-6" />}
|
||||
title="Rápido"
|
||||
description="Solo extracción de texto sin análisis estructurado"
|
||||
time="~5-10 seg"
|
||||
selected={mode === 'quick'}
|
||||
onClick={() => setMode('quick')}
|
||||
/>
|
||||
<ModeCard
|
||||
icon={<Target className="h-6 w-6" />}
|
||||
title="Con Extracción"
|
||||
description="Parse + extracción de datos estructurados con schema"
|
||||
time="~15-30 seg"
|
||||
selected={mode === 'extract'}
|
||||
onClick={() => setMode('extract')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema Selector (solo en modo extract) */}
|
||||
{mode === 'extract' && (
|
||||
<div className="space-y-3 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<Label className="text-base font-semibold">Schema a Usar</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Cargando schemas...</span>
|
||||
</div>
|
||||
) : schemas.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>No hay schemas disponibles para este tema.</p>
|
||||
<p className="mt-1">Crea uno primero en la sección de Schemas.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedSchema} onValueChange={setSelectedSchema}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un schema..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schemas.map((schema) => (
|
||||
<SelectItem key={schema.schema_id} value={schema.schema_id!}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{schema.schema_name}</span>
|
||||
<span className="text-xs text-gray-500">{schema.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Preview del schema seleccionado */}
|
||||
{selectedSchemaData && (
|
||||
<div className="mt-3 p-3 bg-white rounded border">
|
||||
<p className="text-sm font-medium mb-2">Campos a extraer:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedSchemaData.fields.map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded"
|
||||
>
|
||||
{field.name}
|
||||
{field.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tipos de Contenido */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold">Tipos de Contenido a Incluir</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ChunkTypeOption
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
label="Texto"
|
||||
selected={chunkTypes.includes('text')}
|
||||
onClick={() => toggleChunkType('text')}
|
||||
/>
|
||||
<ChunkTypeOption
|
||||
icon={<Table2 className="h-5 w-5" />}
|
||||
label="Tablas"
|
||||
selected={chunkTypes.includes('table')}
|
||||
onClick={() => toggleChunkType('table')}
|
||||
/>
|
||||
<ChunkTypeOption
|
||||
icon={<Image className="h-5 w-5" />}
|
||||
label="Figuras"
|
||||
selected={chunkTypes.includes('figure')}
|
||||
onClick={() => toggleChunkType('figure')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuración Avanzada */}
|
||||
<details className="border rounded-lg">
|
||||
<summary className="px-4 py-3 cursor-pointer font-medium hover:bg-gray-50">
|
||||
Configuración Avanzada
|
||||
</summary>
|
||||
<div className="p-4 space-y-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxTokens">
|
||||
Tokens máximos por chunk: <strong>{maxTokens}</strong>
|
||||
</Label>
|
||||
<input
|
||||
id="maxTokens"
|
||||
type="range"
|
||||
min="500"
|
||||
max="3000"
|
||||
step="100"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Tablas y figuras pueden exceder hasta 50% más
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mergeSmall"
|
||||
checked={mergeSmall}
|
||||
onChange={(e) => setMergeSmall(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="mergeSmall" className="cursor-pointer">
|
||||
Unir chunks pequeños de la misma página
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between items-center pt-4 border-t">
|
||||
<div className="text-sm text-gray-600">
|
||||
Tiempo estimado: <strong>{mode === 'quick' ? '~5-10 seg' : '~15-30 seg'}</strong>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleProcess} disabled={loading}>
|
||||
Procesar con LandingAI
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeCard({ icon, title, description, time, selected, onClick }: any) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
p-4 border-2 rounded-lg cursor-pointer transition-all
|
||||
${selected
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={selected ? 'text-blue-600' : 'text-gray-400'}>
|
||||
{icon}
|
||||
</div>
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-1">{description}</p>
|
||||
<p className="text-xs text-gray-500">{time}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChunkTypeOption({ icon, label, selected, onClick }: any) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
p-3 border-2 rounded-lg cursor-pointer transition-all text-center
|
||||
${selected
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`flex justify-center mb-2 ${selected ? 'text-blue-600' : 'text-gray-400'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,8 +17,7 @@ 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 { ChunkingConfigModalLandingAI, type LandingAIConfig } from './ChunkingConfigModalLandingAI'
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
@@ -30,7 +29,11 @@ import {
|
||||
Scissors
|
||||
} from 'lucide-react'
|
||||
|
||||
export function Dashboard() {
|
||||
interface DashboardProps {
|
||||
onProcessingChange?: (isProcessing: boolean) => void
|
||||
}
|
||||
|
||||
export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
|
||||
const {
|
||||
selectedTema,
|
||||
files,
|
||||
@@ -67,9 +70,7 @@ export function Dashboard() {
|
||||
const [chunkingFileName, setChunkingFileName] = useState('')
|
||||
const [chunkingFileTema, setChunkingFileTema] = useState('')
|
||||
const [chunkingCollectionName, setChunkingCollectionName] = useState('')
|
||||
|
||||
const [chunkPreviewOpen, setChunkPreviewOpen] = useState(false)
|
||||
const [chunkingConfig, setChunkingConfig] = useState<ChunkingConfig | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
@@ -215,29 +216,41 @@ export function Dashboard() {
|
||||
setChunkingConfigOpen(true)
|
||||
}
|
||||
|
||||
const handlePreviewChunking = (config: ChunkingConfig) => {
|
||||
setChunkingConfig(config)
|
||||
const handleProcessWithLandingAI = async (config: LandingAIConfig) => {
|
||||
setProcessing(true)
|
||||
onProcessingChange?.(true)
|
||||
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 result = await api.processWithLandingAI(config)
|
||||
|
||||
const handleCancelChunking = () => {
|
||||
setChunkPreviewOpen(false)
|
||||
setChunkingConfig(null)
|
||||
// Opcionalmente volver al modal de configuración
|
||||
// setChunkingConfigOpen(true)
|
||||
// Mensaje detallado
|
||||
let message = `Completado\n\n`
|
||||
message += `• Modo: ${result.mode === 'quick' ? 'Rápido' : 'Con Extracción'}\n`
|
||||
message += `• Chunks procesados: ${result.total_chunks}\n`
|
||||
message += `• Chunks agregados: ${result.chunks_added}\n`
|
||||
message += `• Colección: ${result.collection_name}\n`
|
||||
message += `• Tiempo: ${result.processing_time_seconds}s\n`
|
||||
|
||||
if (result.schema_used) {
|
||||
message += `• Schema usado: ${result.schema_used}\n`
|
||||
}
|
||||
|
||||
if (result.extracted_data) {
|
||||
message += `\nDatos extraídos disponibles en metadata`
|
||||
}
|
||||
|
||||
alert(message)
|
||||
|
||||
// Recargar archivos
|
||||
loadFiles()
|
||||
} catch (error: any) {
|
||||
console.error('Error processing with LandingAI:', error)
|
||||
alert(`❌ Error: ${error.message}`)
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
onProcessingChange?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredFiles = files.filter(file =>
|
||||
@@ -282,6 +295,18 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Processing Banner */}
|
||||
{processing && (
|
||||
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
Procesando archivo con LandingAI... Por favor no navegues ni realices otras acciones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -295,7 +320,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setUploadDialogOpen(true)}>
|
||||
<Button onClick={() => setUploadDialogOpen(true)} disabled={processing}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Subir archivo
|
||||
</Button>
|
||||
@@ -311,24 +336,26 @@ export function Dashboard() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFiles.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadMultiple}
|
||||
disabled={downloading}
|
||||
disabled={downloading || processing}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{downloading ? 'Descargando...' : `Descargar (${selectedFiles.size})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteMultiple}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Eliminar ({selectedFiles.size})
|
||||
@@ -358,7 +385,7 @@ export function Dashboard() {
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedFiles.size === filteredFiles.length && filteredFiles.length > 0}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
if (checked) {
|
||||
selectAllFiles()
|
||||
} else {
|
||||
@@ -499,23 +526,14 @@ export function Dashboard() {
|
||||
tema={chunkFileTema}
|
||||
/>
|
||||
|
||||
{/* Modal de configuración de chunking */}
|
||||
<ChunkingConfigModal
|
||||
{/* Modal de configuración de chunking con LandingAI */}
|
||||
<ChunkingConfigModalLandingAI
|
||||
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}
|
||||
onProcess={handleProcessWithLandingAI}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,32 +4,65 @@ import { Button } from '@/components/ui/button'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Dashboard } from './Dashboard'
|
||||
import { SchemaManagement } from '@/pages/SchemaManagement'
|
||||
|
||||
type View = 'dashboard' | 'schemas'
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [currentView, setCurrentView] = useState<View>('dashboard')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const handleNavigateToSchemas = () => {
|
||||
if (isProcessing) {
|
||||
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
|
||||
return
|
||||
}
|
||||
setCurrentView('schemas')
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
const handleNavigateToDashboard = () => {
|
||||
if (isProcessing) {
|
||||
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
|
||||
return
|
||||
}
|
||||
setCurrentView('dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-gray-50">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden md:flex md:w-64 md:flex-col">
|
||||
<Sidebar />
|
||||
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40">
|
||||
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40" disabled={isProcessing}>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<Sidebar />
|
||||
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Dashboard />
|
||||
{currentView === 'dashboard' ? (
|
||||
<Dashboard onProcessingChange={setIsProcessing} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SchemaManagement />
|
||||
<div className="fixed bottom-6 right-6">
|
||||
<Button onClick={handleNavigateToDashboard} disabled={isProcessing}>
|
||||
{isProcessing ? 'Procesando...' : 'Volver al Dashboard'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFileStore } from '@/stores/fileStore'
|
||||
import { api } from '@/services/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FolderIcon, FileText } from 'lucide-react'
|
||||
import { FolderIcon, FileText, Trash2, Database } from 'lucide-react'
|
||||
|
||||
export function Sidebar() {
|
||||
const {
|
||||
temas,
|
||||
selectedTema,
|
||||
setTemas,
|
||||
setSelectedTema,
|
||||
interface SidebarProps {
|
||||
onNavigateToSchemas?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Sidebar({ onNavigateToSchemas, disabled = false }: SidebarProps = {}) {
|
||||
const {
|
||||
temas,
|
||||
selectedTema,
|
||||
setTemas,
|
||||
setSelectedTema,
|
||||
loading,
|
||||
setLoading
|
||||
setLoading
|
||||
} = useFileStore()
|
||||
|
||||
const [deletingTema, setDeletingTema] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadTemas()
|
||||
}, [])
|
||||
@@ -34,26 +41,74 @@ export function Sidebar() {
|
||||
setSelectedTema(tema)
|
||||
}
|
||||
|
||||
const handleDeleteTema = async (tema: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation() // Evitar que se seleccione el tema al hacer clic en el icono
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`¿Estás seguro de que deseas eliminar el tema "${tema}"?\n\n` +
|
||||
`Esto eliminará:\n` +
|
||||
`• Todos los archivos del tema en Azure Blob Storage\n` +
|
||||
`• La colección "${tema}" en Qdrant (si existe)\n\n` +
|
||||
`Esta acción no se puede deshacer.`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
setDeletingTema(tema)
|
||||
|
||||
// 1. Eliminar todos los archivos del tema en Azure Blob Storage
|
||||
await api.deleteTema(tema)
|
||||
|
||||
// 2. Intentar eliminar la colección en Qdrant (si existe)
|
||||
try {
|
||||
const collectionExists = await api.checkCollectionExists(tema)
|
||||
if (collectionExists.exists) {
|
||||
await api.deleteCollection(tema)
|
||||
console.log(`Colección "${tema}" eliminada de Qdrant`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`No se pudo eliminar la colección "${tema}" de Qdrant:`, error)
|
||||
// Continuar aunque falle la eliminación de la colección
|
||||
}
|
||||
|
||||
// 3. Actualizar la lista de temas
|
||||
await loadTemas()
|
||||
|
||||
// 4. Si el tema eliminado estaba seleccionado, deseleccionar
|
||||
if (selectedTema === tema) {
|
||||
setSelectedTema(null)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error eliminando tema "${tema}":`, error)
|
||||
alert(`Error al eliminar el tema: ${error instanceof Error ? error.message : 'Error desconocido'}`)
|
||||
} finally {
|
||||
setDeletingTema(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border-r border-gray-200 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="h-6 w-6" />
|
||||
DoRa Banorte
|
||||
DoRa Luma
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Temas List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium text-gray-500 mb-3">TEMAS</h2>
|
||||
<h2 className="text-sm font-medium text-gray-500 mb-3">Collections</h2>
|
||||
|
||||
{/* Todos los archivos */}
|
||||
<Button
|
||||
variant={selectedTema === null ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleTemaSelect(null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
Todos los archivos
|
||||
@@ -64,27 +119,49 @@ export function Sidebar() {
|
||||
<div className="text-sm text-gray-500 px-3 py-2">Cargando...</div>
|
||||
) : (
|
||||
temas.map((tema) => (
|
||||
<Button
|
||||
key={tema}
|
||||
variant={selectedTema === tema ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleTemaSelect(tema)}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{tema}
|
||||
</Button>
|
||||
<div key={tema} className="relative group">
|
||||
<Button
|
||||
variant={selectedTema === tema ? "secondary" : "ghost"}
|
||||
className="w-full justify-start pr-10"
|
||||
onClick={() => handleTemaSelect(tema)}
|
||||
disabled={deletingTema === tema || disabled}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{tema}
|
||||
</Button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteTema(tema, e)}
|
||||
disabled={deletingTema === tema || disabled}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
title="Eliminar tema y colección"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="p-4 border-t border-gray-200 space-y-2">
|
||||
{onNavigateToSchemas && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onNavigateToSchemas}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
Gestionar Schemas
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTemas}
|
||||
disabled={loading}
|
||||
disabled={loading || disabled}
|
||||
className="w-full"
|
||||
>
|
||||
Actualizar temas
|
||||
|
||||
340
frontend/src/components/schemas/SchemaBuilder.tsx
Normal file
340
frontend/src/components/schemas/SchemaBuilder.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Schema Builder Component
|
||||
* Permite crear y editar schemas personalizados desde el frontend
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Trash2, Plus, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import type { CustomSchema, SchemaField, FieldType, FieldTypeOption } from '@/types/schema'
|
||||
import { FIELD_TYPE_OPTIONS } from '@/types/schema'
|
||||
|
||||
interface SchemaBuilderProps {
|
||||
initialSchema?: CustomSchema
|
||||
tema?: string
|
||||
onSave: (schema: CustomSchema) => Promise<void>
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function SchemaBuilder({ initialSchema, tema, onSave, onCancel }: SchemaBuilderProps) {
|
||||
const [schema, setSchema] = useState<CustomSchema>(
|
||||
initialSchema || {
|
||||
schema_name: '',
|
||||
description: '',
|
||||
fields: [],
|
||||
tema: tema,
|
||||
is_global: false
|
||||
}
|
||||
)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const addField = () => {
|
||||
setSchema({
|
||||
...schema,
|
||||
fields: [
|
||||
...schema.fields,
|
||||
{
|
||||
name: '',
|
||||
type: 'string',
|
||||
description: '',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const updateField = (index: number, updates: Partial<SchemaField>) => {
|
||||
const newFields = [...schema.fields]
|
||||
newFields[index] = { ...newFields[index], ...updates }
|
||||
setSchema({ ...schema, fields: newFields })
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
setSchema({
|
||||
...schema,
|
||||
fields: schema.fields.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null)
|
||||
|
||||
// Validaciones básicas
|
||||
if (!schema.schema_name.trim()) {
|
||||
setError('El nombre del schema es requerido')
|
||||
return
|
||||
}
|
||||
|
||||
if (!schema.description.trim()) {
|
||||
setError('La descripción es requerida')
|
||||
return
|
||||
}
|
||||
|
||||
if (schema.fields.length === 0) {
|
||||
setError('Debe agregar al menos un campo')
|
||||
return
|
||||
}
|
||||
|
||||
// Validar campos
|
||||
for (let i = 0; i < schema.fields.length; i++) {
|
||||
const field = schema.fields[i]
|
||||
if (!field.name.trim()) {
|
||||
setError(`El campo ${i + 1} necesita un nombre`)
|
||||
return
|
||||
}
|
||||
if (!field.description.trim()) {
|
||||
setError(`El campo "${field.name}" necesita una descripción`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(schema)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error guardando schema')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{initialSchema ? 'Editar Schema' : 'Crear Nuevo Schema'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Define los campos que quieres extraer de los documentos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4 p-6 bg-white rounded-lg border">
|
||||
<div>
|
||||
<Label htmlFor="schema_name">Nombre del Schema *</Label>
|
||||
<Input
|
||||
id="schema_name"
|
||||
value={schema.schema_name}
|
||||
onChange={(e) => setSchema({ ...schema, schema_name: e.target.value })}
|
||||
placeholder="Ej: Contrato Legal, Factura Comercial"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Descripción *</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={schema.description}
|
||||
onChange={(e) => setSchema({ ...schema, description: e.target.value })}
|
||||
placeholder="¿Qué información extrae este schema?"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_global"
|
||||
checked={schema.is_global}
|
||||
onChange={(e) => setSchema({ ...schema, is_global: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="is_global" className="cursor-pointer">
|
||||
Disponible para todos los temas (global)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!schema.is_global && (
|
||||
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||
Este schema solo estará disponible para el tema: <strong>{tema || 'actual'}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Campos a Extraer</h3>
|
||||
<p className="text-sm text-gray-600">Define qué datos quieres que la IA extraiga</p>
|
||||
</div>
|
||||
<Button onClick={addField} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Agregar Campo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{schema.fields.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<p className="text-gray-600 mb-3">No hay campos definidos</p>
|
||||
<Button onClick={addField} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Agregar Primer Campo
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{schema.fields.map((field, index) => (
|
||||
<SchemaFieldRow
|
||||
key={index}
|
||||
field={field}
|
||||
index={index}
|
||||
onUpdate={(updates) => updateField(index, updates)}
|
||||
onRemove={() => removeField(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end pt-4 border-t">
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>Guardando...</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Guardar Schema
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchemaFieldRow({
|
||||
field,
|
||||
index,
|
||||
onUpdate,
|
||||
onRemove
|
||||
}: {
|
||||
field: SchemaField
|
||||
index: number
|
||||
onUpdate: (updates: Partial<SchemaField>) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const selectedTypeOption = FIELD_TYPE_OPTIONS.find(opt => opt.value === field.type)
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg border hover:border-blue-300 transition-colors">
|
||||
<div className="grid grid-cols-12 gap-3 items-start">
|
||||
{/* Field Number */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-semibold flex items-center justify-center text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Field Name */}
|
||||
<div className="col-span-3">
|
||||
<Label className="text-xs text-gray-600">Nombre del campo *</Label>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) => onUpdate({ name: e.target.value.toLowerCase().replace(/\s+/g, '_') })}
|
||||
placeholder="nombre_campo"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Type */}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-gray-600">Tipo *</Label>
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(e) => onUpdate({ type: e.target.value as FieldType })}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Label className="text-xs text-gray-600">Descripción para IA *</Label>
|
||||
<Input
|
||||
value={field.description}
|
||||
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||
placeholder="¿Qué debe extraer la IA?"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required Checkbox */}
|
||||
<div className="col-span-1 flex items-end pb-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => onUpdate({ required: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Requerido</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div className="col-span-1 flex items-end justify-center pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Min/Max Values (for numeric types) */}
|
||||
{selectedTypeOption?.supportsMinMax && (
|
||||
<div className="mt-3 pt-3 border-t grid grid-cols-2 gap-3 ml-12">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">Valor Mínimo (opcional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.min_value ?? ''}
|
||||
onChange={(e) => onUpdate({ min_value: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
placeholder="Sin límite"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">Valor Máximo (opcional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.max_value ?? ''}
|
||||
onChange={(e) => onUpdate({ max_value: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
placeholder="Sin límite"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
frontend/src/components/schemas/SchemaList.tsx
Normal file
151
frontend/src/components/schemas/SchemaList.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Schema List Component
|
||||
* Lista y gestiona schemas existentes
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Trash2, Edit, Globe, FolderClosed } from 'lucide-react'
|
||||
import type { CustomSchema } from '@/types/schema'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
interface SchemaListProps {
|
||||
schemas: CustomSchema[]
|
||||
onEdit: (schema: CustomSchema) => void
|
||||
onDelete: (schemaId: string) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function SchemaList({ schemas, onEdit, onDelete, onRefresh }: SchemaListProps) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (schema: CustomSchema) => {
|
||||
if (!schema.schema_id) return
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`¿Estás seguro de eliminar el schema "${schema.schema_name}"?\n\nEsta acción no se puede deshacer.`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setDeletingId(schema.schema_id)
|
||||
try {
|
||||
await api.deleteSchema(schema.schema_id)
|
||||
onDelete(schema.schema_id)
|
||||
} catch (error: any) {
|
||||
alert(`Error eliminando schema: ${error.message}`)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (schemas.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<p className="text-gray-600 mb-2">No hay schemas creados</p>
|
||||
<p className="text-sm text-gray-500">Crea tu primer schema para empezar</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{schemas.map((schema) => (
|
||||
<SchemaCard
|
||||
key={schema.schema_id}
|
||||
schema={schema}
|
||||
onEdit={() => onEdit(schema)}
|
||||
onDelete={() => handleDelete(schema)}
|
||||
isDeleting={deletingId === schema.schema_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchemaCard({
|
||||
schema,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting
|
||||
}: {
|
||||
schema: CustomSchema
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg border hover:border-blue-300 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-semibold">{schema.schema_name}</h3>
|
||||
{schema.is_global ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">
|
||||
<Globe className="h-3 w-3" />
|
||||
Global
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded-full">
|
||||
<FolderClosed className="h-3 w-3" />
|
||||
{schema.tema}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600 mb-3">{schema.description}</p>
|
||||
|
||||
{/* Fields Summary */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{schema.fields.slice(0, 5).map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center px-2 py-1 bg-gray-50 text-gray-700 text-xs rounded border"
|
||||
>
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="text-gray-500 ml-1">({field.type})</span>
|
||||
{field.required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{schema.fields.length > 5 && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-gray-500 text-xs">
|
||||
+{schema.fields.length - 5} más
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{schema.created_at && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Creado: {new Date(schema.created_at).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
frontend/src/pages/SchemaManagement.tsx
Normal file
127
frontend/src/pages/SchemaManagement.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Schema Management Page
|
||||
* Página principal para gestionar schemas personalizados
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowLeft } from 'lucide-react'
|
||||
import { SchemaBuilder } from '@/components/schemas/SchemaBuilder'
|
||||
import { SchemaList } from '@/components/schemas/SchemaList'
|
||||
import { useFileStore } from '@/stores/fileStore'
|
||||
import { api } from '@/services/api'
|
||||
import type { CustomSchema } from '@/types/schema'
|
||||
|
||||
type View = 'list' | 'create' | 'edit'
|
||||
|
||||
export function SchemaManagement() {
|
||||
const { selectedTema } = useFileStore()
|
||||
const [view, setView] = useState<View>('list')
|
||||
const [schemas, setSchemas] = useState<CustomSchema[]>([])
|
||||
const [selectedSchema, setSelectedSchema] = useState<CustomSchema | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadSchemas()
|
||||
}, [selectedTema])
|
||||
|
||||
const loadSchemas = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.listSchemas(selectedTema || undefined)
|
||||
setSchemas(data)
|
||||
} catch (error: any) {
|
||||
console.error('Error loading schemas:', error)
|
||||
alert('Error cargando schemas: ' + error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (schema: CustomSchema) => {
|
||||
try {
|
||||
if (selectedSchema?.schema_id) {
|
||||
// Update existing
|
||||
await api.updateSchema(selectedSchema.schema_id, schema)
|
||||
} else {
|
||||
// Create new
|
||||
await api.createSchema(schema)
|
||||
}
|
||||
|
||||
await loadSchemas()
|
||||
setView('list')
|
||||
setSelectedSchema(undefined)
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (schema: CustomSchema) => {
|
||||
setSelectedSchema(schema)
|
||||
setView('edit')
|
||||
}
|
||||
|
||||
const handleDelete = async (schemaId: string) => {
|
||||
await loadSchemas()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setView('list')
|
||||
setSelectedSchema(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
{view === 'list' ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Schemas Personalizados</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{selectedTema
|
||||
? `Schemas para el tema: ${selectedTema}`
|
||||
: 'Todos los schemas disponibles'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setView('create')}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Crear Nuevo Schema
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" onClick={handleCancel} className="mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Volver a la lista
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
{loading && view === 'list' ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Cargando schemas...</p>
|
||||
</div>
|
||||
) : view === 'list' ? (
|
||||
<SchemaList
|
||||
schemas={schemas}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRefresh={loadSchemas}
|
||||
/>
|
||||
) : (
|
||||
<SchemaBuilder
|
||||
initialSchema={selectedSchema}
|
||||
tema={selectedTema || undefined}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -420,4 +420,96 @@ export const api = {
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Schemas API
|
||||
// ============================================================================
|
||||
|
||||
// Crear schema
|
||||
createSchema: async (schema: any): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schema)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail?.message || 'Error creando schema')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Listar schemas
|
||||
listSchemas: async (tema?: string): Promise<any[]> => {
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/schemas/?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/schemas/`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Error listando schemas')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Obtener schema por ID
|
||||
getSchema: async (schema_id: string): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`)
|
||||
if (!response.ok) throw new Error('Error obteniendo schema')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Actualizar schema
|
||||
updateSchema: async (schema_id: string, schema: any): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schema)
|
||||
})
|
||||
if (!response.ok) throw new Error('Error actualizando schema')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Eliminar schema
|
||||
deleteSchema: async (schema_id: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (!response.ok) throw new Error('Error eliminando schema')
|
||||
},
|
||||
|
||||
// Validar schema
|
||||
validateSchema: async (schema: any): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schema)
|
||||
})
|
||||
if (!response.ok) throw new Error('Error validando schema')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// LandingAI Processing
|
||||
// ============================================================================
|
||||
|
||||
// Procesar con LandingAI
|
||||
processWithLandingAI: async (config: {
|
||||
file_name: string
|
||||
tema: string
|
||||
collection_name: string
|
||||
mode: 'quick' | 'extract'
|
||||
schema_id?: string
|
||||
include_chunk_types?: string[]
|
||||
max_tokens_per_chunk?: number
|
||||
merge_small_chunks?: boolean
|
||||
}): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/chunking-landingai/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 procesando con LandingAI')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
}
|
||||
60
frontend/src/types/schema.ts
Normal file
60
frontend/src/types/schema.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tipos TypeScript para schemas personalizables
|
||||
*/
|
||||
|
||||
export type FieldType =
|
||||
| 'string'
|
||||
| 'integer'
|
||||
| 'float'
|
||||
| 'boolean'
|
||||
| 'array_string'
|
||||
| 'array_integer'
|
||||
| 'array_float'
|
||||
| 'date'
|
||||
|
||||
export interface SchemaField {
|
||||
name: string
|
||||
type: FieldType
|
||||
description: string
|
||||
required: boolean
|
||||
min_value?: number
|
||||
max_value?: number
|
||||
pattern?: string
|
||||
}
|
||||
|
||||
export interface CustomSchema {
|
||||
schema_id?: string
|
||||
schema_name: string
|
||||
description: string
|
||||
fields: SchemaField[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
tema?: string
|
||||
is_global: boolean
|
||||
}
|
||||
|
||||
export interface SchemaValidationResponse {
|
||||
valid: boolean
|
||||
message: string
|
||||
json_schema?: any
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
// Opciones para el selector de tipo
|
||||
export interface FieldTypeOption {
|
||||
value: FieldType
|
||||
label: string
|
||||
description: string
|
||||
supportsMinMax: boolean
|
||||
}
|
||||
|
||||
export const FIELD_TYPE_OPTIONS: FieldTypeOption[] = [
|
||||
{ value: 'string', label: 'Texto', description: 'Texto simple', supportsMinMax: false },
|
||||
{ value: 'integer', label: 'Número Entero', description: 'Número sin decimales', supportsMinMax: true },
|
||||
{ value: 'float', label: 'Número Decimal', description: 'Número con decimales', supportsMinMax: true },
|
||||
{ value: 'boolean', label: 'Verdadero/Falso', description: 'Sí o No', supportsMinMax: false },
|
||||
{ value: 'array_string', label: 'Lista de Textos', description: 'Múltiples textos', supportsMinMax: false },
|
||||
{ value: 'array_integer', label: 'Lista de Números', description: 'Múltiples números enteros', supportsMinMax: true },
|
||||
{ value: 'array_float', label: 'Lista de Decimales', description: 'Múltiples números decimales', supportsMinMax: true },
|
||||
{ value: 'date', label: 'Fecha', description: 'Fecha en formato ISO', supportsMinMax: false },
|
||||
]
|
||||
Reference in New Issue
Block a user