import React, { useCallback, useEffect, useRef, useState } from 'react'; import { uploadBatch, getBatchStatus, getBatchPreview, pushToRag, listDocuments, deleteDocument, createTextEntry, } from '../service/knowledge-base/knowledge-base.service'; import { TEXT_ENTRY_BODY_MAX_LENGTH, TEXT_ENTRY_TITLE_MAX_LENGTH, } from '../service/knowledge-base/knowledge-base.routes'; import type { DocumentFile, BatchStatus, BatchPreviewFile, } from '../service/knowledge-base/knowledge-base.interface'; import { useAppStateContext } from '../context/user.data.context'; import { WORDPRESS_STEP_KEYS } from '../service/setup-progress/setup-progress.constants'; import { setSetupProgressStep } from '../service/setup-progress/setup-progress.service'; import Button from '../components/widgets/Button'; import { Spinner } from '../components/ui/Spinner'; import ErrorWrapper from '../components/alert/ErrorWrapper'; const ALLOWED_TYPES = [ 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/msword', 'text/csv', ]; const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc', '.csv']; const MAX_FILES = 5; const MAX_SIZE = 20 * 1024 * 1024; const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; const StatusBadge = ({ status }: { status: string }) => { switch (status.toLowerCase()) { case 'pushed': return ( In Knowledge Base ); case 'summarized': case 'ready': return ( Ready to Push ); case 'processing': return ( Processing ); case 'failed': return ( Failed ); default: return ( {status} ); } }; const KnowledgeBase = (): JSX.Element => { const { token, clientId } = useAppStateContext(); // Visiting Knowledge Base auto-completes the step - no documents needed. useEffect(() => { if (token) { setSetupProgressStep(WORDPRESS_STEP_KEYS.KNOWLEDGE_BASE).catch(() => {}); } }, [token]); const fileInputRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [batchId, setBatchId] = useState(null); const [jobId, setJobId] = useState(null); const [batchStatus, setBatchStatus] = useState(null); const [previewFiles, setPreviewFiles] = useState([]); const [expandedPreviews, setExpandedPreviews] = useState>( new Set() ); const [pushing, setPushing] = useState(false); const [pushJobId, setPushJobId] = useState(null); // Set once a push completes; resets only when a new upload begins. // Keeps Review Summaries hidden after dismissal so a lingering // preview cache can't repopulate the section. const [pushedThisSession, setPushedThisSession] = useState(false); const [documents, setDocuments] = useState([]); const [loadingDocs, setLoadingDocs] = useState(true); const [deleteTarget, setDeleteTarget] = useState(null); // Auto-imported docs get the ``Exclude`` framing so the merchant // understands deleting one also blocks future re-import. const deleteTargetIsAuto = deleteTarget !== null && documents.find(d => d.file_name === deleteTarget)?.source === 'auto'; const [deleting, setDeleting] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [textEntryTitle, setTextEntryTitle] = useState(''); const [textEntryDescription, setTextEntryDescription] = useState(''); const [textEntrySubmitting, setTextEntrySubmitting] = useState(false); const [textEntryError, setTextEntryError] = useState(null); const [textEntrySuccess, setTextEntrySuccess] = useState(null); const pollingRef = useRef(null); const fetchDocuments = useCallback(async () => { try { const result = await listDocuments(); setDocuments(result.data?.documents || []); } catch (err: any) { console.error('Failed to load documents:', err); } finally { setLoadingDocs(false); } }, []); // The Reco copilot dispatches ``copilot:data-changed`` after it generates, // adds, or pushes a knowledge-base document, so re-fetch to reflect the new // doc / its pushed state without a manual reload. useEffect(() => { const onCopilotDataChanged = (): void => { void fetchDocuments(); }; window.addEventListener('copilot:data-changed', onCopilotDataChanged); return () => window.removeEventListener('copilot:data-changed', onCopilotDataChanged); }, [fetchDocuments]); const fetchPreview = useCallback(async (bid: string) => { try { const result = await getBatchPreview(bid); setPreviewFiles(result.data?.files || []); } catch (err: any) { console.error('Failed to load preview:', err); } }, []); const clearPolling = useCallback(() => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } }, []); useEffect(() => { if (clientId && token) { fetchDocuments(); } }, [clientId, token, fetchDocuments]); useEffect(() => { if (!jobId || !clientId || !token) return; const pollStatus = async () => { try { const result = await getBatchStatus(jobId); const status = result.data; setBatchStatus(status); if (status.state === 'SUCCESS') { clearPolling(); if (batchId) { fetchPreview(batchId); } fetchDocuments(); } else if (status.state === 'FAILURE') { clearPolling(); setUploadError(status.error || 'Processing failed'); } } catch (err: any) { console.error('Poll error:', err); } }; pollStatus(); pollingRef.current = setInterval(pollStatus, 3000); return () => clearPolling(); }, [ jobId, batchId, clientId, token, clearPolling, fetchDocuments, fetchPreview, ]); useEffect(() => { if (!pushJobId || !clientId || !token) return; const pollPush = async () => { try { const result = await getBatchStatus(pushJobId); const status = result.data; setBatchStatus(status); if (status.state === 'SUCCESS' || status.state === 'FAILURE') { clearPolling(); setPushing(false); setPreviewFiles([]); setBatchId(null); setJobId(null); if (status.state === 'SUCCESS') { setPushedThisSession(true); } fetchDocuments(); if (status.state === 'FAILURE') { setPushJobId(null); setBatchStatus(null); return; } // Keep the green success card visible briefly so the merchant // sees confirmation before the card auto-dismisses. setTimeout(() => { setPushJobId(null); setBatchStatus(null); }, 3000); } } catch (err: any) { console.error('Push poll error:', err); } }; pollPush(); pollingRef.current = setInterval(pollPush, 3000); return () => clearPolling(); }, [pushJobId, clientId, token, clearPolling, fetchDocuments]); const addFiles = useCallback((files: File[]) => { const valid = files.filter( f => ALLOWED_TYPES.includes(f.type) && f.size <= MAX_SIZE ); setSelectedFiles(prev => { const combined = [...prev, ...valid]; return combined.slice(0, MAX_FILES); }); setUploadError(null); }, []); const handleFileInputChange = (e: React.ChangeEvent) => { if (e.target.files) { addFiles(Array.from(e.target.files)); e.target.value = ''; } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (e.dataTransfer.files) { addFiles(Array.from(e.dataTransfer.files)); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }; const removeFile = (index: number) => { setSelectedFiles(prev => prev.filter((_, i) => i !== index)); }; const handleUpload = async () => { if (!clientId || !token || selectedFiles.length === 0) return; setUploading(true); setUploadError(null); setBatchStatus(null); setPreviewFiles([]); setPushedThisSession(false); try { const formData = new FormData(); selectedFiles.forEach(file => formData.append('files', file)); formData.append('language', 'en'); const result = await uploadBatch(formData); const data = result.data; setBatchId(data.batch_id); setJobId(data.job_id); setSelectedFiles([]); } catch (err: any) { setUploadError(err?.message || 'Upload failed'); } finally { setUploading(false); } }; const handlePushToRag = async () => { if (!clientId || !token || !batchId) return; setPushing(true); try { const result = await pushToRag(batchId); const newPushJobId = result.data?.job_id; if (newPushJobId) { setBatchStatus({ batch_id: '', job_id: newPushJobId, state: 'PENDING', progress: 0, files_processed: 0, files_total: 0, } as BatchStatus); } setPushJobId(newPushJobId); } catch (err: any) { setUploadError(err?.message || 'Push failed'); setPushing(false); } }; const handleTextEntrySubmit = async () => { if (!clientId || !token) return; const trimmedTitle = textEntryTitle.trim(); const trimmedDescription = textEntryDescription.trim(); if (!trimmedTitle || !trimmedDescription) { setTextEntryError('Title and description are both required.'); return; } if (trimmedDescription.length > TEXT_ENTRY_BODY_MAX_LENGTH) { setTextEntryError( `Description must be ${TEXT_ENTRY_BODY_MAX_LENGTH} characters or fewer.` ); return; } setTextEntrySubmitting(true); setTextEntryError(null); setTextEntrySuccess(null); try { const result = await createTextEntry({ title: trimmedTitle, description: trimmedDescription, }); setTextEntrySuccess( `Added "${result.data?.title || trimmedTitle}" to the knowledge base.` ); setTextEntryTitle(''); setTextEntryDescription(''); fetchDocuments(); } catch (err: any) { setTextEntryError( err?.response?.data?.detail || err?.message || 'Failed to save the text entry.' ); } finally { setTextEntrySubmitting(false); } }; const handleDelete = async () => { if (!clientId || !token || !deleteTarget) return; setDeleting(true); try { await deleteDocument(deleteTarget); setDeleteTarget(null); fetchDocuments(); } catch (err: any) { console.error('Delete failed:', err); } finally { setDeleting(false); } }; const togglePreview = (index: number) => { setExpandedPreviews(prev => { const next = new Set(prev); if (next.has(index)) { next.delete(index); } else { next.add(index); } return next; }); }; const isProcessing = batchStatus && (batchStatus.state === 'PENDING' || batchStatus.state === 'PROGRESS'); return (

Knowledge Base

Upload store policies, FAQs, and guides. Give your AI agent the context it needs to answer customers accurately.

{/* Knowledge Base Index — placed at the top so merchants see what the AI already knows about their shop before uploading more. */}

Knowledge Base Index

Items with the{' '} Generated by system badge were auto-imported from your site (crawled pages, brand profile, shop metadata) so you can see what the AI knows about your shop. Use Download to grab any document, edit it locally, and re-upload it below. Your product catalog is also part of the AI's knowledge base but is not listed here.
{loadingDocs ? (
) : documents.length === 0 ? (
No documents indexed yet.
) : (
{documents.map(doc => ( ))}
Name Type Size Date Status Actions
{doc.file_name} {doc.source === 'auto' && ( Generated by system )}
{doc.file_type.replace('.', '').toUpperCase()} {formatFileSize(doc.file_size_bytes)} {doc.processed_at ? new Date(doc.processed_at).toLocaleDateString() : '-'}
{doc.download_url && ( )}
)}

Upload Documents

{ if (!uploading && !isProcessing) fileInputRef.current?.click(); }} >

Drop files here or click to browse

Accepts PDF, DOCX, DOC, CSV (max {MAX_SIZE / (1024 * 1024)} MB each)

{selectedFiles.length > 0 && (

Selected Files{' '} ({selectedFiles.length}/{MAX_FILES})

    {selectedFiles.map((file, i) => (
  • {file.name} ({formatFileSize(file.size)})
  • ))}
)} {uploadError && (
)}
Or write text directly

Add Text Entry

Skip the document upload — paste a short note (FAQ, policy snippet, store hours) and we'll push it to the knowledge base immediately.

setTextEntryTitle(e.target.value)} disabled={textEntrySubmitting} />

{textEntryTitle.length}/{TEXT_ENTRY_TITLE_MAX_LENGTH}