Replace entire Barclays colour palette (navy #1A2142, lime #C3FB5A, violet #7A0FF9) with Oliver brand tokens: black #1A1A1A, gold #FFCB05, orange #FF5C00, azure #0487B6, sky #5DF5EA, grey #EFEFEF, green #09821F. - Switch font from Inter/Barclays Effra to Arial (system font) - Add new Oliver logo asset (BAR-ModComms-logo-v4.png) - Sidebar: black background, new logo, azure active state - Hero: orange "Intelligent Review" text, hide AI-Powered tagline - Hide ChecksOverview on Home page per Oliver design - Toast notification: orange background with black text - All tables: sky headers, alternating white/grey rows - Campaign badges: gold "In Progress", green "Completed" - Analytics: grey KPI cards, sky accent on Key Insight, oliver trend colours - All buttons: azure fill, pill-shaped (rounded-full) - All tabs/toggles/dropdowns: azure accent colour - Update HTML title to "Mod Comms - Intelligent Review" - Default border radius set to 10px Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
672 lines
34 KiB
TypeScript
672 lines
34 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',
|
|
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<string, string> = {
|
|
partial: 'partial parse',
|
|
};
|
|
return (
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || 'bg-oliver-grey text-oliver-black/60'}`}>
|
|
{labels[status] || 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;
|
|
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 (
|
|
<div className="p-8 max-w-6xl mx-auto w-full">
|
|
<button onClick={handleBackFromDiff} className="flex items-center gap-2 text-oliver-azure 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-oliver-black">{selectedKb.display_name} - Diff</h1>
|
|
<p className="text-oliver-black/60 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-oliver-black/60 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-oliver-black/60 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-oliver-azure 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-oliver-black">
|
|
{selectedKb?.display_name} - Version {viewingSpec.version_number}
|
|
</h1>
|
|
<p className="text-oliver-black/60 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-oliver-black 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-oliver-azure 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-oliver-black">{selectedKb.display_name}</h1>
|
|
{selectedKb.description && <p className="text-oliver-black/60 mt-1">{selectedKb.description}</p>}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mb-6 bg-oliver-grey 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-oliver-black shadow-sm'
|
|
: 'text-oliver-black/60 hover:text-oliver-black'
|
|
}`}
|
|
>
|
|
{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-oliver-azure bg-oliver-azure/5' : 'border-grey-300 hover:border-oliver-azure/50'
|
|
}`}
|
|
>
|
|
<UploadIcon className="h-10 w-10 mx-auto text-oliver-black/60 mb-3" />
|
|
<p className="text-oliver-black/60 mb-2">Drag & drop files here, or</p>
|
|
<label className="inline-flex items-center gap-2 bg-oliver-azure text-white font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure/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-oliver-black/60 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-oliver-azure text-white font-semibold py-2.5 px-8 rounded-full hover:bg-oliver-azure/90 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
{isJobRunning ? 'Processing...' : 'Process Documents'}
|
|
</button>
|
|
{selectedKb.active_spec_version && (
|
|
<span className="text-sm text-oliver-black/60">
|
|
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-oliver-azure animate-spin" />
|
|
<span className="font-medium text-oliver-black">
|
|
{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-oliver-azure 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-oliver-black/60 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-oliver-grey border-grey-300 text-oliver-black/60'
|
|
}`}>
|
|
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-oliver-grey text-left text-oliver-black/60">
|
|
<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 => (
|
|
<React.Fragment key={doc.id}>
|
|
<tr className="hover:bg-grey-50 transition-colors">
|
|
<td className="px-4 py-3 font-medium text-oliver-black">{doc.filename}</td>
|
|
<td className="px-4 py-3 text-oliver-black/60">{formatBytes(doc.file_size_bytes)}</td>
|
|
<td className="px-4 py-3 text-oliver-black/60">{formatDate(doc.created_at)}</td>
|
|
<td className="px-4 py-3 text-oliver-black/60">{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-oliver-black/60 hover:text-error transition-colors"
|
|
title="Remove document"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{doc.parse_error && (doc.parse_status === 'partial' || doc.parse_status === 'error') && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 pb-3 pt-0">
|
|
<div className={`text-xs px-3 py-2 rounded-lg ${doc.parse_status === 'partial' ? 'bg-orange-50 text-orange-700' : 'bg-red-50 text-red-700'}`}>
|
|
{doc.parse_error}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-oliver-black/60 bg-oliver-grey 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-oliver-azure text-white font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure/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-oliver-grey text-left text-oliver-black/60">
|
|
<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-oliver-azure focus:ring-oliver-azure"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-oliver-black">v{v.version_number}</td>
|
|
<td className="px-4 py-3 text-oliver-black/60">{formatDate(v.created_at)}</td>
|
|
<td className="px-4 py-3 text-oliver-black/60">{v.generated_by_name || '-'}</td>
|
|
<td className="px-4 py-3 text-oliver-black/60">{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-oliver-black/60 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-oliver-azure 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-oliver-black/60 bg-oliver-grey 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-oliver-azure 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-oliver-black">Knowledge Base</h1>
|
|
<p className="text-oliver-black/60 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-oliver-azure hover:shadow-md transition-all duration-200 group`}
|
|
>
|
|
<h3 className="text-lg font-semibold text-oliver-black group-hover:text-oliver-azure transition-colors">
|
|
{kb.display_name}
|
|
</h3>
|
|
{kb.description && (
|
|
<p className="text-sm text-oliver-black/60 mt-1 line-clamp-2">{kb.description}</p>
|
|
)}
|
|
<div className="mt-4 space-y-1 text-sm text-oliver-black/60">
|
|
<div className="flex justify-between">
|
|
<span>Source Documents</span>
|
|
<span className="font-medium text-oliver-black">{kb.source_document_count}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Active Spec</span>
|
|
<span className="font-medium text-oliver-black">
|
|
{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-oliver-black/60 text-right">
|
|
{formatDate(kb.latest_job_completed_at)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|