import React, { useState, useEffect, useCallback, useRef } from 'react'; import apiService from '../services/apiService'; import type { KnowledgeBaseListItem, KnowledgeBaseDetail, SourceDocument, ProcessingJob, SpecVersionListItem, SpecVersionDetail, DiffResult, } from '../types'; import { ArrowLeftIcon } from './icons/ArrowLeftIcon'; import { TrashIcon } from './icons/TrashIcon'; import { UploadIcon } from './icons/UploadIcon'; import { SpinnerIcon } from './icons/SpinnerIcon'; // --- Helper Components --- const StatusBadge: React.FC<{ status: string }> = ({ status }) => { const colors: Record = { pending: 'bg-yellow-100 text-yellow-800', parsing: 'bg-blue-100 text-blue-800', parsed: 'bg-green-100 text-green-800', partial: 'bg-orange-100 text-orange-800', error: 'bg-red-100 text-red-800', parsing_documents: 'bg-blue-100 text-blue-800', distilling: 'bg-purple-100 text-purple-800', completed: 'bg-green-100 text-green-800', failed: 'bg-red-100 text-red-800', }; const labels: Record = { partial: 'partial parse', }; return ( {labels[status] || status} ); }; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatDate(dateStr: string | null): string { if (!dateStr) return '-'; return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } // --- Main Component --- export const KnowledgeBase: React.FC = () => { // Level 1: List view const [knowledgeBases, setKnowledgeBases] = useState([]); const [loading, setLoading] = useState(true); // Level 2: Detail view const [selectedKb, setSelectedKb] = useState(null); const [activeTab, setActiveTab] = useState<'documents' | 'versions'>('documents'); const [versions, setVersions] = useState([]); const [uploading, setUploading] = useState(false); // Processing job polling const [activeJob, setActiveJob] = useState(null); const pollRef = useRef | null>(null); // Level 3: Diff view const [diffResult, setDiffResult] = useState(null); const [selectedForDiff, setSelectedForDiff] = useState([]); // Spec content modal const [viewingSpec, setViewingSpec] = useState(null); // --- Data Loading --- const loadKnowledgeBases = useCallback(async () => { try { setLoading(true); const data = await apiService.getKnowledgeBases(); setKnowledgeBases(data); } catch (err) { console.error('Failed to load knowledge bases:', err); } finally { setLoading(false); } }, []); useEffect(() => { loadKnowledgeBases(); }, [loadKnowledgeBases]); const loadKbDetail = useCallback(async (kbId: string) => { try { const [detail, vers] = await Promise.all([ apiService.getKnowledgeBase(kbId), apiService.getSpecVersions(kbId), ]); setSelectedKb(detail); setVersions(vers); // Check for active job - only treat as running if actually in progress // A "pending" job older than 2 minutes is considered stale/stuck if (detail.latest_job && ['parsing_documents', 'distilling'].includes(detail.latest_job.status)) { setActiveJob(detail.latest_job); } else if (detail.latest_job && detail.latest_job.status === 'pending' && detail.latest_job.started_at) { const ageMs = Date.now() - new Date(detail.latest_job.started_at).getTime(); if (ageMs < 2 * 60 * 1000) { setActiveJob(detail.latest_job); } else { setActiveJob(null); } } else { setActiveJob(null); } } catch (err) { console.error('Failed to load KB detail:', err); } }, []); // Poll active job useEffect(() => { if (activeJob && selectedKb) { pollRef.current = setInterval(async () => { try { const job = await apiService.getProcessingJob(selectedKb.id, activeJob.id); setActiveJob(job); if (['completed', 'failed'].includes(job.status)) { if (pollRef.current) clearInterval(pollRef.current); pollRef.current = null; // Refresh data loadKbDetail(selectedKb.id); loadKnowledgeBases(); } } catch (err) { console.error('Failed to poll job:', err); // Stop polling on error (e.g. 404 job not found) setActiveJob(null); if (pollRef.current) clearInterval(pollRef.current); pollRef.current = null; loadKbDetail(selectedKb.id); } }, 3000); } return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; }, [activeJob, selectedKb, loadKbDetail, loadKnowledgeBases]); // --- Handlers --- const handleSelectKb = (kb: KnowledgeBaseListItem) => { loadKbDetail(kb.id); setActiveTab('documents'); setDiffResult(null); setSelectedForDiff([]); }; const handleBack = () => { setSelectedKb(null); setDiffResult(null); setSelectedForDiff([]); setViewingSpec(null); loadKnowledgeBases(); }; const handleBackFromDiff = () => { setDiffResult(null); setSelectedForDiff([]); }; const handleFileUpload = async (files: FileList | null) => { if (!files || !selectedKb) return; setUploading(true); try { for (let i = 0; i < files.length; i++) { await apiService.uploadSourceDocument(selectedKb.id, files[i]); } await loadKbDetail(selectedKb.id); loadKnowledgeBases(); } catch (err) { console.error('Failed to upload file:', err); } finally { setUploading(false); } }; const handleRemoveDoc = async (docId: string) => { if (!selectedKb) return; if (!confirm('Are you sure you want to delete this document? This action cannot be undone.')) return; try { await apiService.removeSourceDocument(selectedKb.id, docId); await loadKbDetail(selectedKb.id); loadKnowledgeBases(); } catch (err) { console.error('Failed to remove document:', err); } }; const handleProcess = async () => { if (!selectedKb) return; try { const job = await apiService.triggerProcessing(selectedKb.id); setActiveJob(job); } catch (err: any) { console.error('Failed to trigger processing:', err); alert(err.message || 'Failed to trigger processing.'); } }; const handleViewSpec = async (versionId: string) => { if (!selectedKb) return; try { const spec = await apiService.getSpecVersion(selectedKb.id, versionId); setViewingSpec(spec); } catch (err) { console.error('Failed to load spec:', err); } }; const handleDiffToggle = (versionId: string) => { setSelectedForDiff(prev => { if (prev.includes(versionId)) { return prev.filter(id => id !== versionId); } if (prev.length >= 2) { return [prev[1], versionId]; } return [...prev, versionId]; }); }; const handleCompare = async () => { if (selectedForDiff.length !== 2 || !selectedKb) return; try { const diff = await apiService.getSpecDiff(selectedKb.id, selectedForDiff[0], selectedForDiff[1]); setDiffResult(diff); } catch (err) { console.error('Failed to compute diff:', err); } }; const handleActivateVersion = async (versionId: string) => { if (!selectedKb) return; if (!confirm('Are you sure you want to activate this version? This will change the spec used by the AI agent.')) return; try { await apiService.activateSpecVersion(selectedKb.id, versionId); await loadKbDetail(selectedKb.id); } catch (err) { console.error('Failed to activate version:', err); } }; // --- Drag & Drop --- const [isDragging, setIsDragging] = useState(false); const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = () => setIsDragging(false); const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); handleFileUpload(e.dataTransfer.files); }; // ===================== RENDER ===================== // Level 3: Diff view if (diffResult && selectedKb) { return (

{selectedKb.display_name} - Diff

Version {diffResult.version_a} vs Version {diffResult.version_b} +{diffResult.additions} -{diffResult.deletions}

{diffResult.lines.map((line, i) => (
{line.line_number_old || ''} {line.line_number_new || ''} {line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '}
{line.content}
))}
); } // Spec content modal if (viewingSpec) { return (

{selectedKb?.display_name} - Version {viewingSpec.version_number}

{formatDate(viewingSpec.created_at)} | {viewingSpec.char_count.toLocaleString()} chars {viewingSpec.generated_by_name && | Generated by {viewingSpec.generated_by_name}} {viewingSpec.is_active && Active}

{viewingSpec.content}
); } // Level 2: Agent detail if (selectedKb) { const isJobRunning = activeJob && ['pending', 'parsing_documents', 'distilling'].includes(activeJob.status); return (

{selectedKb.display_name}

{selectedKb.description &&

{selectedKb.description}

}
{/* Tabs */}
{(['documents', 'versions'] as const).map(tab => ( ))}
{activeTab === 'documents' ? (
{/* Upload area */}

Drag & drop files here, or

PDF, DOCX, PPTX, XLSX, HTML, TXT, MD, PNG, JPG, WebP

{/* Process button + job status */}
{selectedKb.active_spec_version && ( Active spec: v{selectedKb.active_spec_version} ({selectedKb.active_spec_char_count?.toLocaleString()} chars) )}
{/* Processing progress */} {isJobRunning && activeJob && (
{activeJob.status === 'parsing_documents' ? 'Parsing documents...' : activeJob.status === 'distilling' ? 'Distilling spec with AI...' : 'Starting...'}
{activeJob.status === 'parsing_documents' && (
0 ? (activeJob.parsed_documents / activeJob.total_documents) * 100 : 0}%` }} />
)}

{activeJob.parsed_documents} / {activeJob.total_documents} documents parsed

)} {/* Last job result */} {selectedKb.latest_job && !isJobRunning && (
Last processing: {selectedKb.latest_job.completed_at && {formatDate(selectedKb.latest_job.completed_at)}} {selectedKb.latest_job.error_message &&

{selectedKb.latest_job.error_message}

}
)} {/* Documents table */} {selectedKb.source_documents.length > 0 ? (
{selectedKb.source_documents.map(doc => ( {doc.parse_error && (doc.parse_status === 'partial' || doc.parse_status === 'error') && ( )} ))}
Filename Size Uploaded Uploaded By Parse Status
{doc.filename} {formatBytes(doc.file_size_bytes)} {formatDate(doc.created_at)} {doc.uploaded_by_name || '-'}
{doc.parse_error}
) : (
No source documents uploaded yet.
)}
) : ( /* Versions tab */
{/* Compare button */} {selectedForDiff.length === 2 && (
)} {versions.length > 0 ? (
{versions.map(v => ( ))}
Compare Version Date Generated By Chars Status Actions
handleDiffToggle(v.id)} className="h-4 w-4 rounded border-grey-300 text-oliver-azure focus:ring-oliver-azure" /> v{v.version_number} {formatDate(v.created_at)} {v.generated_by_name || '-'} {v.char_count.toLocaleString()} {v.is_active ? ( Active ) : ( Inactive )}
{!v.is_active && ( )}
) : (
No spec versions generated yet.
)}
)}
); } // Level 1: Agent list if (loading) { return (
); } return (

Knowledge Base

Manage the AI agent knowledge bases. Upload source documents, process them, and version the resulting specs.

{knowledgeBases.map(kb => { const statusColor = kb.latest_job_status === 'failed' ? 'border-red-300 bg-red-50' : kb.latest_job_status === 'completed' ? 'border-grey-300 bg-white' : 'border-grey-300 bg-white'; return ( ); })}
); };