When the poll request fails (e.g. job not found 404), clear the activeJob state and stop the interval instead of endlessly retrying. Also refresh the KB detail to get the current state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
656 lines
33 KiB
TypeScript
656 lines
33 KiB
TypeScript
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<string, string> = {
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
parsing: 'bg-blue-100 text-blue-800',
|
|
parsed: 'bg-green-100 text-green-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',
|
|
};
|
|
return (
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || 'bg-grey-100 text-grey-700'}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
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<KnowledgeBaseListItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Level 2: Detail view
|
|
const [selectedKb, setSelectedKb] = useState<KnowledgeBaseDetail | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'documents' | 'versions'>('documents');
|
|
const [versions, setVersions] = useState<SpecVersionListItem[]>([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
// Processing job polling
|
|
const [activeJob, setActiveJob] = useState<ProcessingJob | null>(null);
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// Level 3: Diff view
|
|
const [diffResult, setDiffResult] = useState<DiffResult | null>(null);
|
|
const [selectedForDiff, setSelectedForDiff] = useState<string[]>([]);
|
|
|
|
// Spec content modal
|
|
const [viewingSpec, setViewingSpec] = useState<SpecVersionDetail | null>(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;
|
|
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 (
|
|
<div className="p-8 max-w-6xl mx-auto w-full">
|
|
<button onClick={handleBackFromDiff} className="flex items-center gap-2 text-active-blue hover:underline mb-6 text-sm font-medium">
|
|
<ArrowLeftIcon className="h-4 w-4" /> Back to Version History
|
|
</button>
|
|
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-primary-blue">{selectedKb.display_name} - Diff</h1>
|
|
<p className="text-grey-700 mt-1">
|
|
Version {diffResult.version_a} vs Version {diffResult.version_b}
|
|
<span className="ml-4 text-green-600 font-medium">+{diffResult.additions}</span>
|
|
<span className="ml-2 text-red-600 font-medium">-{diffResult.deletions}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl border border-grey-300 overflow-hidden">
|
|
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto font-mono text-sm">
|
|
{diffResult.lines.map((line, i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex ${
|
|
line.type === 'add' ? 'bg-green-50' :
|
|
line.type === 'remove' ? 'bg-red-50' :
|
|
''
|
|
}`}
|
|
>
|
|
<span className="w-12 text-right pr-2 text-grey-700 select-none border-r border-grey-200 flex-shrink-0 py-0.5">
|
|
{line.line_number_old || ''}
|
|
</span>
|
|
<span className="w-12 text-right pr-2 text-grey-700 select-none border-r border-grey-200 flex-shrink-0 py-0.5">
|
|
{line.line_number_new || ''}
|
|
</span>
|
|
<span className={`w-6 text-center flex-shrink-0 py-0.5 ${
|
|
line.type === 'add' ? 'text-green-600' :
|
|
line.type === 'remove' ? 'text-red-600' :
|
|
'text-grey-400'
|
|
}`}>
|
|
{line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '}
|
|
</span>
|
|
<pre className="flex-1 py-0.5 pr-4 whitespace-pre-wrap break-words">{line.content}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Spec content modal
|
|
if (viewingSpec) {
|
|
return (
|
|
<div className="p-8 max-w-6xl mx-auto w-full">
|
|
<button onClick={() => setViewingSpec(null)} className="flex items-center gap-2 text-active-blue hover:underline mb-6 text-sm font-medium">
|
|
<ArrowLeftIcon className="h-4 w-4" /> Back to Version History
|
|
</button>
|
|
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-primary-blue">
|
|
{selectedKb?.display_name} - Version {viewingSpec.version_number}
|
|
</h1>
|
|
<p className="text-grey-700 mt-1">
|
|
{formatDate(viewingSpec.created_at)} | {viewingSpec.char_count.toLocaleString()} chars
|
|
{viewingSpec.generated_by_name && <span> | Generated by {viewingSpec.generated_by_name}</span>}
|
|
{viewingSpec.is_active && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl border border-grey-300 p-6 overflow-x-auto max-h-[70vh] overflow-y-auto">
|
|
<pre className="whitespace-pre-wrap text-sm text-black-title font-mono leading-relaxed">{viewingSpec.content}</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Level 2: Agent detail
|
|
if (selectedKb) {
|
|
const isJobRunning = activeJob && ['pending', 'parsing_documents', 'distilling'].includes(activeJob.status);
|
|
|
|
return (
|
|
<div className="p-8 max-w-6xl mx-auto w-full">
|
|
<button onClick={handleBack} className="flex items-center gap-2 text-active-blue hover:underline mb-6 text-sm font-medium">
|
|
<ArrowLeftIcon className="h-4 w-4" /> Back to Knowledge Bases
|
|
</button>
|
|
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-primary-blue">{selectedKb.display_name}</h1>
|
|
{selectedKb.description && <p className="text-grey-700 mt-1">{selectedKb.description}</p>}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mb-6 bg-grey-100 p-1 rounded-xl w-fit">
|
|
{(['documents', 'versions'] as const).map(tab => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
|
activeTab === tab
|
|
? 'bg-white text-primary-blue shadow-sm'
|
|
: 'text-grey-700 hover:text-primary-blue'
|
|
}`}
|
|
>
|
|
{tab === 'documents' ? 'Source Documents' : 'Version History'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'documents' ? (
|
|
<div>
|
|
{/* Upload area */}
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={`border-2 border-dashed rounded-xl p-8 mb-6 text-center transition-colors ${
|
|
isDragging ? 'border-active-blue bg-active-blue/5' : 'border-grey-300 hover:border-active-blue/50'
|
|
}`}
|
|
>
|
|
<UploadIcon className="h-10 w-10 mx-auto text-grey-700 mb-3" />
|
|
<p className="text-grey-700 mb-2">Drag & drop files here, or</p>
|
|
<label className="inline-flex items-center gap-2 bg-active-blue text-white font-semibold py-2 px-6 rounded-full hover:bg-active-blue/90 transition-colors cursor-pointer">
|
|
{uploading ? <SpinnerIcon className="h-4 w-4 animate-spin" /> : null}
|
|
{uploading ? 'Uploading...' : 'Browse Files'}
|
|
<input
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => handleFileUpload(e.target.files)}
|
|
accept=".pdf,.docx,.pptx,.xlsx,.html,.txt,.md,.png,.jpg,.jpeg,.webp"
|
|
disabled={uploading}
|
|
/>
|
|
</label>
|
|
<p className="text-xs text-grey-700 mt-2">PDF, DOCX, PPTX, XLSX, HTML, TXT, MD, PNG, JPG, WebP</p>
|
|
</div>
|
|
|
|
{/* Process button + job status */}
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<button
|
|
onClick={handleProcess}
|
|
disabled={!!isJobRunning || selectedKb.source_documents.length === 0}
|
|
className="bg-active-blue text-white font-semibold py-2.5 px-8 rounded-full hover:bg-active-blue/90 transition-colors disabled:bg-grey-700 disabled:cursor-not-allowed"
|
|
>
|
|
{isJobRunning ? 'Processing...' : 'Process Documents'}
|
|
</button>
|
|
{selectedKb.active_spec_version && (
|
|
<span className="text-sm text-grey-700">
|
|
Active spec: v{selectedKb.active_spec_version} ({selectedKb.active_spec_char_count?.toLocaleString()} chars)
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Processing progress */}
|
|
{isJobRunning && activeJob && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<SpinnerIcon className="h-5 w-5 text-active-blue animate-spin" />
|
|
<span className="font-medium text-primary-blue">
|
|
{activeJob.status === 'parsing_documents' ? 'Parsing documents...' :
|
|
activeJob.status === 'distilling' ? 'Distilling spec with AI...' :
|
|
'Starting...'}
|
|
</span>
|
|
</div>
|
|
{activeJob.status === 'parsing_documents' && (
|
|
<div className="w-full bg-blue-100 rounded-full h-2">
|
|
<div
|
|
className="bg-active-blue h-2 rounded-full transition-all duration-500"
|
|
style={{ width: `${activeJob.total_documents > 0 ? (activeJob.parsed_documents / activeJob.total_documents) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<p className="text-sm text-grey-700 mt-1">
|
|
{activeJob.parsed_documents} / {activeJob.total_documents} documents parsed
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Last job result */}
|
|
{selectedKb.latest_job && !isJobRunning && (
|
|
<div className={`border rounded-xl p-3 mb-6 text-sm ${
|
|
selectedKb.latest_job.status === 'completed'
|
|
? 'bg-green-50 border-green-200 text-green-800'
|
|
: selectedKb.latest_job.status === 'failed'
|
|
? 'bg-red-50 border-red-200 text-red-800'
|
|
: 'bg-grey-100 border-grey-300 text-grey-700'
|
|
}`}>
|
|
Last processing: <StatusBadge status={selectedKb.latest_job.status} />
|
|
{selectedKb.latest_job.completed_at && <span className="ml-2">{formatDate(selectedKb.latest_job.completed_at)}</span>}
|
|
{selectedKb.latest_job.error_message && <p className="mt-1 text-sm">{selectedKb.latest_job.error_message}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{/* Documents table */}
|
|
{selectedKb.source_documents.length > 0 ? (
|
|
<div className="bg-white rounded-xl border border-grey-300 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-grey-100 text-left text-grey-700">
|
|
<th className="px-4 py-3 font-medium">Filename</th>
|
|
<th className="px-4 py-3 font-medium">Size</th>
|
|
<th className="px-4 py-3 font-medium">Uploaded</th>
|
|
<th className="px-4 py-3 font-medium">Uploaded By</th>
|
|
<th className="px-4 py-3 font-medium">Parse Status</th>
|
|
<th className="px-4 py-3 font-medium w-12"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-grey-200">
|
|
{selectedKb.source_documents.map(doc => (
|
|
<tr key={doc.id} className="hover:bg-grey-50 transition-colors">
|
|
<td className="px-4 py-3 font-medium text-primary-blue">{doc.filename}</td>
|
|
<td className="px-4 py-3 text-grey-700">{formatBytes(doc.file_size_bytes)}</td>
|
|
<td className="px-4 py-3 text-grey-700">{formatDate(doc.created_at)}</td>
|
|
<td className="px-4 py-3 text-grey-700">{doc.uploaded_by_name || '-'}</td>
|
|
<td className="px-4 py-3"><StatusBadge status={doc.parse_status} /></td>
|
|
<td className="px-4 py-3">
|
|
<button
|
|
onClick={() => handleRemoveDoc(doc.id)}
|
|
className="text-grey-700 hover:text-error transition-colors"
|
|
title="Remove document"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-grey-700 bg-grey-100 rounded-xl">
|
|
No source documents uploaded yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Versions tab */
|
|
<div>
|
|
{/* Compare button */}
|
|
{selectedForDiff.length === 2 && (
|
|
<div className="mb-4">
|
|
<button
|
|
onClick={handleCompare}
|
|
className="bg-active-blue text-white font-semibold py-2 px-6 rounded-full hover:bg-active-blue/90 transition-colors"
|
|
>
|
|
Compare Selected Versions
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{versions.length > 0 ? (
|
|
<div className="bg-white rounded-xl border border-grey-300 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-grey-100 text-left text-grey-700">
|
|
<th className="px-4 py-3 font-medium w-10">
|
|
<span className="sr-only">Compare</span>
|
|
</th>
|
|
<th className="px-4 py-3 font-medium">Version</th>
|
|
<th className="px-4 py-3 font-medium">Date</th>
|
|
<th className="px-4 py-3 font-medium">Generated By</th>
|
|
<th className="px-4 py-3 font-medium">Chars</th>
|
|
<th className="px-4 py-3 font-medium">Status</th>
|
|
<th className="px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-grey-200">
|
|
{versions.map(v => (
|
|
<tr key={v.id} className="hover:bg-grey-50 transition-colors">
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedForDiff.includes(v.id)}
|
|
onChange={() => handleDiffToggle(v.id)}
|
|
className="h-4 w-4 rounded border-grey-300 text-active-blue focus:ring-active-blue"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-primary-blue">v{v.version_number}</td>
|
|
<td className="px-4 py-3 text-grey-700">{formatDate(v.created_at)}</td>
|
|
<td className="px-4 py-3 text-grey-700">{v.generated_by_name || '-'}</td>
|
|
<td className="px-4 py-3 text-grey-700">{v.char_count.toLocaleString()}</td>
|
|
<td className="px-4 py-3">
|
|
{v.is_active ? (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
Active
|
|
</span>
|
|
) : (
|
|
<span className="text-grey-700 text-xs">Inactive</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleViewSpec(v.id)}
|
|
className="text-active-blue hover:underline text-xs font-medium"
|
|
>
|
|
View
|
|
</button>
|
|
{!v.is_active && (
|
|
<button
|
|
onClick={() => handleActivateVersion(v.id)}
|
|
className="text-amber-600 hover:underline text-xs font-medium"
|
|
>
|
|
Activate
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-grey-700 bg-grey-100 rounded-xl">
|
|
No spec versions generated yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Level 1: Agent list
|
|
if (loading) {
|
|
return (
|
|
<div className="p-8 flex items-center justify-center min-h-[400px]">
|
|
<SpinnerIcon className="h-8 w-8 text-active-blue animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 max-w-6xl mx-auto w-full">
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-primary-blue">Knowledge Base</h1>
|
|
<p className="text-grey-700 mt-1">Manage the AI agent knowledge bases. Upload source documents, process them, and version the resulting specs.</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{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 (
|
|
<button
|
|
key={kb.id}
|
|
onClick={() => handleSelectKb(kb)}
|
|
className={`text-left p-6 rounded-xl border-2 ${statusColor} hover:border-active-blue hover:shadow-md transition-all duration-200 group`}
|
|
>
|
|
<h3 className="text-lg font-semibold text-primary-blue group-hover:text-active-blue transition-colors">
|
|
{kb.display_name}
|
|
</h3>
|
|
{kb.description && (
|
|
<p className="text-sm text-grey-700 mt-1 line-clamp-2">{kb.description}</p>
|
|
)}
|
|
<div className="mt-4 space-y-1 text-sm text-grey-700">
|
|
<div className="flex justify-between">
|
|
<span>Source Documents</span>
|
|
<span className="font-medium text-primary-blue">{kb.source_document_count}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Active Spec</span>
|
|
<span className="font-medium text-primary-blue">
|
|
{kb.active_spec_version ? `v${kb.active_spec_version}` : 'None'}
|
|
</span>
|
|
</div>
|
|
{kb.latest_job_status && (
|
|
<div className="flex justify-between items-center">
|
|
<span>Last Job</span>
|
|
<StatusBadge status={kb.latest_job_status} />
|
|
</div>
|
|
)}
|
|
{kb.latest_job_completed_at && (
|
|
<div className="text-xs text-grey-700 text-right">
|
|
{formatDate(kb.latest_job_completed_at)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|