Landing AI integrado

This commit is contained in:
Sebastian
2025-11-06 13:29:43 +00:00
parent 7c6e8c4858
commit c03d0e27c4
32 changed files with 3908 additions and 728 deletions

File diff suppressed because it is too large Load Diff

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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()
},
}

View 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 },
]