30. Listado de fuentes de conocimientos
30: Listado de Fuentes de Conocimiento
En este tutorial, aprenderemos a crear un listado de fuentes de conocimiento para un chatbot específico. Implementaremos un componente reutilizable para mostrar cada fuente y organizaremos la interfaz de usuario para visualizar todas las fuentes asociadas a un chatbot.
1. Cargar las Fuentes de Conocimiento desde el Controlador
1.1. Modificar el ChatbotController
Necesitamos cargar la relación knowledgeSources antes de pasar el chatbot a la vista.
app/Http/Controllers/ChatbotController.php
<?php namespace App\Http\Controllers; use App\Models\Chatbot; use Illuminate\Http\Request; use Inertia\Inertia; class ChatbotController extends Controller { /** * Muestra los detalles de un chatbot específico. */ public function show(Chatbot $chatbot) { $this->authorize('view', $chatbot); // ✅ Cargar las fuentes de conocimiento del chatbot $chatbot->load('knowledgeSources'); return Inertia::render('Chatbots/Show', [ 'chatbot' => $chatbot, ]); } // ... otros métodos }
1.2. Verificar la Estructura de Datos
Si hacemos un dd() o inspeccionamos los datos en el frontend, veremos:
// Datos disponibles en la vista { chatbot: { id: 'uuid', name: 'Mi Chatbot', description: 'Descripción del chatbot', // ... otros campos knowledge_sources: [ { id: 'uuid-1', name: 'Manual de Usuario', type: 'pdf', path: 'knowledge-sources/.../manual.pdf', status: 'processed', created_at: '2024-01-01 12:00:00', }, { id: 'uuid-2', name: 'Documentación API', type: 'website', path: 'https://api.ejemplo.com/docs', status: 'pending', created_at: '2024-01-02 12:00:00', }, // ... más fuentes ] } }
2. Actualizar la Vista Show
2.1. Estructura del Listado
resources/js/Pages/Chatbots/Show.vue
<template> <AppLayout> <template #header> <div class="flex items-center justify-between"> <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight"> {{ chatbot.name }} </h2> <button @click="openModal" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition" > <BookIcon class="w-5 h-5 mr-2" /> Gestionar Conocimiento </button> </div> </template> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <!-- Información del chatbot --> <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg mb-6"> <div class="p-6 text-gray-900 dark:text-gray-100"> <div class="space-y-4"> <div> <h3 class="text-lg font-medium">Descripción</h3> <p class="text-gray-600 dark:text-gray-400"> {{ chatbot.description || 'Sin descripción' }} </p> </div> <div> <h3 class="text-lg font-medium">Modelo</h3> <p class="text-gray-600 dark:text-gray-400"> {{ chatbot.model }} </p> </div> <div> <h3 class="text-lg font-medium">System Prompt</h3> <pre class="bg-gray-100 dark:bg-gray-700 p-4 rounded-md text-sm whitespace-pre-wrap"> {{ chatbot.system_prompt || 'No definido' }} </pre> </div> </div> </div> </div> <!-- ✅ Listado de fuentes de conocimiento --> <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6"> <div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100"> Fuentes de Conocimiento </h3> <span class="text-sm text-gray-500 dark:text-gray-400"> {{ chatbot.knowledge_sources?.length || 0 }} fuentes </span> </div> <!-- Listado --> <div v-if="chatbot.knowledge_sources && chatbot.knowledge_sources.length > 0"> <div class="space-y-2"> <KnowledgeSourceListItem v-for="source in chatbot.knowledge_sources" :key="source.id" :knowledge-source="source" /> </div> </div> <!-- Estado vacío --> <div v-else class="text-center py-8"> <div class="text-gray-400 dark:text-gray-500"> <BookIcon class="w-12 h-12 mx-auto mb-3 opacity-50" /> <p class="text-sm">No hay fuentes de conocimiento</p> <p class="text-xs mt-1">Agrega una fuente para mejorar las respuestas del chatbot</p> </div> </div> </div> </div> </div> </div> <!-- Modal para crear fuentes --> <KnowledgeSourceCreateModal :show="showModal" @close="closeModal" :chatbot-id="chatbot.id" /> </AppLayout> </template> <script setup> import AppLayout from '@/Layouts/AppLayout.vue'; import { ref } from 'vue'; import BookIcon from '@/Components/Icons/BookIcon.vue'; import KnowledgeSourceCreateModal from '@/Components/KnowledgeSources/KnowledgeSourceCreateModal.vue'; import KnowledgeSourceListItem from '@/Components/KnowledgeSources/KnowledgeSourceListItem.vue'; const props = defineProps({ chatbot: Object, }); const showModal = ref(false); const openModal = () => { showModal.value = true; }; const closeModal = () => { showModal.value = false; }; </script>
3. Crear el Componente KnowledgeSourceListItem
3.1. Estructura del Componente
resources/js/Components/KnowledgeSources/KnowledgeSourceListItem.vue
<template> <div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-500 transition-colors"> <!-- Información de la fuente --> <div class="flex items-center space-x-3 min-w-0"> <!-- Icono según el tipo --> <div :class="getTypeIconClass(knowledgeSource.type)" class="flex-shrink-0 p-2 rounded-lg"> <component :is="getTypeIcon(knowledgeSource.type)" class="w-5 h-5" /> </div> <div class="min-w-0"> <!-- Nombre --> <div class="flex items-center gap-2"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> {{ knowledgeSource.name }} </span> <!-- Badge de estado --> <span :class="getStatusBadgeClass(knowledgeSource.status)" class="flex-shrink-0 px-2 py-0.5 text-xs rounded-full"> {{ getStatusLabel(knowledgeSource.status) }} </span> </div> <!-- Metadatos --> <div class="flex items-center gap-3 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <span>{{ knowledgeSource.type.toUpperCase() }}</span> <span>•</span> <span>{{ formatDate(knowledgeSource.created_at) }}</span> <span v-if="knowledgeSource.metadata" class="flex items-center gap-1"> • <span v-if="knowledgeSource.metadata.pages"> {{ knowledgeSource.metadata.pages }} páginas </span> <span v-else-if="knowledgeSource.metadata.rows"> {{ knowledgeSource.metadata.rows }} registros </span> </span> </div> </div> </div> <!-- Acciones --> <div class="flex items-center gap-2 flex-shrink-0"> <!-- Botón de reprocesar (solo si no está procesado) --> <button v-if="knowledgeSource.status !== 'processed'" @click="reprocessSource" class="p-1.5 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors" title="Reprocesar fuente" > <ArrowPathIcon class="w-4 h-4" /> </button> <!-- Botón de ver detalles --> <button @click="viewSource" class="p-1.5 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors" title="Ver detalles" > <EyeIcon class="w-4 h-4" /> </button> <!-- Botón de eliminar --> <button @click="deleteSource" class="p-1.5 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" title="Eliminar fuente" > <TrashIcon class="w-4 h-4" /> </button> </div> </div> </template> <script setup> import { computed } from 'vue'; import { router } from '@inertiajs/vue3'; import { DocumentIcon, GlobeIcon, DocumentTextIcon, TableCellsIcon, DocumentChartBarIcon, CodeBracketIcon, } from '@heroicons/vue/24/outline'; import { ArrowPathIcon, EyeIcon, TrashIcon, } from '@heroicons/vue/24/outline'; const props = defineProps({ knowledgeSource: { type: Object, required: true, }, }); // Obtener el icono según el tipo const getTypeIcon = (type) => { const icons = { pdf: DocumentIcon, website: GlobeIcon, text: DocumentTextIcon, csv: TableCellsIcon, excel: DocumentChartBarIcon, json: CodeBracketIcon, }; return icons[type] || DocumentIcon; }; // Obtener clase CSS para el icono según el tipo const getTypeIconClass = (type) => { const classes = { pdf: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400', website: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', text: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400', csv: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400', excel: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400', json: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400', }; return classes[type] || 'bg-gray-100 text-gray-600'; }; // Obtener clase CSS para el badge de estado const getStatusBadgeClass = (status) => { const classes = { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', processed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', }; return classes[status] || 'bg-gray-100 text-gray-800'; }; // Obtener etiqueta del estado const getStatusLabel = (status) => { const labels = { pending: 'Pendiente', processing: 'Procesando', processed: 'Procesado', failed: 'Falló', }; return labels[status] || status; }; // Formatear fecha const formatDate = (date) => { if (!date) return ''; return new Date(date).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric', }); }; // Acciones const viewSource = () => { router.get(route('chatbots.knowledge-sources.show', { chatbot: props.knowledgeSource.chatbot_id, knowledgeSource: props.knowledgeSource.id, })); }; const reprocessSource = () => { if (confirm(`¿Quieres reprocesar "${props.knowledgeSource.name}"?`)) { router.post(route('chatbots.knowledge-sources.reprocess', { chatbot: props.knowledgeSource.chatbot_id, knowledgeSource: props.knowledgeSource.id, })); } }; const deleteSource = () => { if (confirm(`¿Estás seguro de que quieres eliminar "${props.knowledgeSource.name}"?`)) { router.delete(route('chatbots.knowledge-sources.destroy', { chatbot: props.knowledgeSource.chatbot_id, knowledgeSource: props.knowledgeSource.id, })); } }; </script>
4. Mejoras Adicionales
4.1. Filtrado y Búsqueda
Podemos agregar un filtro para buscar fuentes de conocimiento:
<template> <!-- Barra de búsqueda --> <div class="mb-4"> <input v-model="searchQuery" type="text" placeholder="Buscar fuentes..." class="w-full sm:w-64 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white" /> </div> <!-- Listado filtrado --> <div v-if="filteredSources.length > 0"> <KnowledgeSourceListItem v-for="source in filteredSources" :key="source.id" :knowledge-source="source" /> </div> </template> <script setup> import { computed, ref } from 'vue'; const searchQuery = ref(''); const filteredSources = computed(() => { if (!searchQuery.value) return props.chatbot.knowledge_sources; const query = searchQuery.value.toLowerCase(); return props.chatbot.knowledge_sources.filter(source => source.name.toLowerCase().includes(query) || source.type.toLowerCase().includes(query) ); }); </script>
4.2. Paginación
Para muchos elementos, podemos agregar paginación:
<template> <!-- Listado paginado --> <div class="space-y-2"> <KnowledgeSourceListItem v-for="source in paginatedSources" :key="source.id" :knowledge-source="source" /> </div> <!-- Paginación --> <div class="mt-4 flex items-center justify-between"> <span class="text-sm text-gray-500 dark:text-gray-400"> Mostrando {{ (currentPage - 1) * itemsPerPage + 1 }} - {{ Math.min(currentPage * itemsPerPage, chatbot.knowledge_sources.length) }} de {{ chatbot.knowledge_sources.length }} fuentes </span> <div class="flex gap-2"> <button @click="currentPage--" :disabled="currentPage === 1" class="px-3 py-1 border rounded-md disabled:opacity-50" > Anterior </button> <button @click="currentPage++" :disabled="currentPage === totalPages" class="px-3 py-1 border rounded-md disabled:opacity-50" > Siguiente </button> </div> </div> </template> <script setup> import { ref, computed } from 'vue'; const currentPage = ref(1); const itemsPerPage = 5; const paginatedSources = computed(() => { const start = (currentPage.value - 1) * itemsPerPage; const end = start + itemsPerPage; return props.chatbot.knowledge_sources.slice(start, end); }); const totalPages = computed(() => { return Math.ceil(props.chatbot.knowledge_sources.length / itemsPerPage); }); </script>
5. Estilos del Listado
5.1. CSS para el Listado
/* resources/css/components/knowledge-sources.css */ .knowledge-sources-list { @apply space-y-2; } .knowledge-sources-list .list-item { @apply flex items-center justify-between p-4; @apply bg-gray-50 dark:bg-gray-700/50; @apply rounded-lg border border-gray-200 dark:border-gray-600; @apply hover:border-indigo-300 dark:hover:border-indigo-500; @apply transition-colors; } .knowledge-sources-list .list-item .type-icon { @apply flex-shrink-0 p-2 rounded-lg; } .knowledge-sources-list .list-item .type-icon.pdf { @apply bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400; } .knowledge-sources-list .list-item .type-icon.website { @apply bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400; } .knowledge-sources-list .list-item .type-icon.text { @apply bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400; } .knowledge-sources-list .list-item .status-badge { @apply px-2 py-0.5 text-xs rounded-full; } .knowledge-sources-list .list-item .status-badge.pending { @apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400; } .knowledge-sources-list .list-item .status-badge.processing { @apply bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400; } .knowledge-sources-list .list-item .status-badge.processed { @apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400; } .knowledge-sources-list .list-item .status-badge.failed { @apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400; }
6. Actualización en Tiempo Real
6.1. Refrescar el Listado después de Crear
<script setup> // En KnowledgeSourceCreateModal.vue const handleSubmit = () => { form.post(route('chatbots.knowledge-sources.store', props.chatbotId), { data: formData, forceFormData: true, preserveScroll: true, onSuccess: () => { handleClose(); form.reset(); // ✅ Emitir evento de actualización emit('created'); }, }); }; </script> <!-- En Show.vue --> <KnowledgeSourceCreateModal :show="showModal" @close="closeModal" @created="refreshSources" :chatbot-id="chatbot.id" />
6.2. Función para Refrescar
// En Show.vue const refreshSources = () => { // Recargar la página para obtener los datos actualizados router.reload({ only: ['chatbot'], }); };
7. Resumen del Listado
7.1. Características Implementadas
✅ Carga de fuentes desde el controlador con load()
✅ Listado visual con diseño consistente
✅ Iconos por tipo (PDF, Website, etc.)
✅ Badges de estado (Pendiente, Procesando, Procesado, Falló)
✅ Acciones (Ver, Reprocesar, Eliminar)
✅ Estado vacío cuando no hay fuentes
✅ Fecha de creación formateada
7.2. Estructura de Datos
// KnowledgeSource { id: 'uuid', chatbot_id: 'uuid', name: 'Manual de Usuario', type: 'pdf', // pdf, website, text, csv, excel, json path: 'knowledge-sources/.../manual.pdf', status: 'processed', // pending, processing, processed, failed extracted_content: '...', metadata: { pages: 10, size: 2048, }, created_at: '2024-01-01 12:00:00', updated_at: '2024-01-01 12:00:00', }
8. Código Completo del Componente ListItem
<!-- resources/js/Components/KnowledgeSources/KnowledgeSourceListItem.vue --> <template> <div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-500 transition-colors"> <div class="flex items-center space-x-3 min-w-0"> <div :class="getTypeIconClass(knowledgeSource.type)" class="flex-shrink-0 p-2 rounded-lg"> <component :is="getTypeIcon(knowledgeSource.type)" class="w-5 h-5" /> </div> <div class="min-w-0"> <div class="flex items-center gap-2"> <span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> {{ knowledgeSource.name }} </span> <span :class="getStatusBadgeClass(knowledgeSource.status)" class="flex-shrink-0 px-2 py-0.5 text-xs rounded-full"> {{ getStatusLabel(knowledgeSource.status) }} </span> </div> <div class="flex items-center gap-3 mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <span>{{ knowledgeSource.type.toUpperCase() }}</span> <span>•</span> <span>{{ formatDate(knowledgeSource.created_at) }}</span> </div> </div> </div> <div class="flex items-center gap-2 flex-shrink-0"> <button v-if="knowledgeSource.status !== 'processed'" @click="reprocessSource" class="p-1.5 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors" title="Reprocesar fuente" > <ArrowPathIcon class="w-4 h-4" /> </button> <button @click="viewSource" class="p-1.5 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors" title="Ver detalles" > <EyeIcon class="w-4 h-4" /> </button> <button @click="deleteSource" class="p-1.5 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" title="Eliminar fuente" > <TrashIcon class="w-4 h-4" /> </button> </div> </div> </template> <script setup> import { router } from '@inertiajs/vue3'; import { DocumentIcon, GlobeIcon, DocumentTextIcon, TableCellsIcon, DocumentChartBarIcon, CodeBracketIcon, } from '@heroicons/vue/24/outline'; import { ArrowPathIcon, EyeIcon, TrashIcon, } from '@heroicons/vue/24/outline'; const props = defineProps({ knowledgeSource: { type: Object, required: true, }, }); const getTypeIcon = (type) => { const icons = { pdf: DocumentIcon, website: GlobeIcon, text: DocumentTextIcon, csv: TableCellsIcon, excel: DocumentChartBarIcon, json: CodeBracketIcon, }; return icons[type] || DocumentIcon; }; const getTypeIconClass = (type) => { const classes = { pdf: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400', website: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', text: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400', csv: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400', excel: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400', json: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400', }; return classes[type] || 'bg-gray-100 text-gray-600'; }; const getStatusBadgeClass = (status) => { const classes = { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', processed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', }; return classes[status] || 'bg-gray-100 text-gray-800'; }; const getStatusLabel = (status) => { const labels = { pending: 'Pendiente', processing: 'Procesando', processed: 'Procesado', failed: 'Falló', }; return labels[status] || status; }; const formatDate = (date) => { if (!date) return ''; return new Date(date).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric', }); }; const viewSource = () => { router.get(route('chatbots.knowledge-sources.show', { chatbot: props.knowledgeSource.chatbot_id, knowledgeSource: props.knowledgeSource.id, })); }; const reprocessSource = () => { if (confirm(`¿Quieres reprocesar "${props.knowledgeSource.name}"?`)) { router.post(route('chatbots.knowledge-sources.reprocess', { chatbot: props.knowledgeSource.chatbot_id, knowledgeSource: props.knowledgeSource.id, })); } }; const deleteSource = () => { if (confirm(`¿Estás seguro de que quieres eliminar "${props.knowledgeSource.name}"?`)) { router.delete(route('chatbots.knowledge-sources.destroy', { chatbot: props.knowledgeSource.chatbot_id, knowledgeSource: props.knowledgeSource.id, })); } }; </script>
9. Resumen
En este tutorial hemos aprendido a:
✅ Cargar relaciones con load() en el controlador
✅ Crear un listado de fuentes de conocimiento
✅ Diseñar un componente reutilizable KnowledgeSourceListItem
✅ Mostrar información con iconos, badges y metadatos
✅ Implementar acciones (ver, reprocesar, eliminar)
✅ Manejar estados (vacío, con datos)
✅ Formatear fechas para mejor visualización
¡Excelente trabajo! Ahora tienes un listado completo y funcional de fuentes de conocimiento para tus chatbots
Comentarios
Publicar un comentario