import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import FileUploader from '~/components/file-uploader' import StyledButton from '~/components/StyledButton' import StyledSectionHeader from '~/components/StyledSectionHeader' import StyledTable from '~/components/StyledTable' import { useNotifications } from '~/context/NotificationContext' import api from '~/lib/api' import { groupAndSortKbFiles, type KbFileGroup, } from '~/lib/kb_file_grouping' import { IconX } from '@tabler/icons-react' import { useModals } from '~/context/ModalContext' import StyledModal from '../StyledModal' import ActiveEmbedJobs from '~/components/ActiveEmbedJobs' import { SERVICE_NAMES } from '../../../constants/service_names' interface KnowledgeBaseModalProps { aiAssistantName?: string onClose: () => void } export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) { const { addNotification } = useNotifications() const [files, setFiles] = useState([]) const [isUploading, setIsUploading] = useState(false) const [confirmDeleteSource, setConfirmDeleteSource] = useState(null) const [bulkMode, setBulkMode] = useState(null) const [resetTyped, setResetTyped] = useState('') const fileUploaderRef = useRef>(null) const { openModal, closeModal } = useModals() const queryClient = useQueryClient() const [isStartingQdrant, setIsStartingQdrant] = useState(false) const { data: healthStatus } = useQuery({ queryKey: ['qdrantHealth'], queryFn: () => api.checkRAGHealth(), refetchInterval: isStartingQdrant ? 3_000 : 30_000, }) const qdrantOffline = healthStatus?.online === false useEffect(() => { if (!qdrantOffline) setIsStartingQdrant(false) }, [qdrantOffline]) const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({ queryKey: ['storedFiles'], queryFn: () => api.getStoredRAGFiles(), select: (data) => data || [], }) // Per-file conditional warnings (RFC #883 §6). `ok: false` means the // computation itself failed (Qdrant/DB/FS) — distinct from `ok: true` with // an empty map, which means everything is healthy. We surface the failure // explicitly so a silent backend failure doesn't masquerade as health. const { data: warningsResult } = useQuery({ queryKey: ['kbFileWarnings'], queryFn: () => api.getKbFileWarnings(), refetchInterval: 30_000, }) const fileWarnings = warningsResult?.warnings ?? {} const warningsUnavailable = warningsResult !== undefined && warningsResult.ok === false // Global auto-index policy. KVStore returns `null` for an unset key, which // we treat as 'Always' for backward compatibility with installs that predate // this UI. The user can opt into Manual mode from the toggle below. const { data: ingestPolicySetting } = useQuery({ queryKey: ['ingestPolicy'], queryFn: () => api.getSetting('rag.defaultIngestPolicy'), }) const ingestPolicy: 'Always' | 'Manual' = ingestPolicySetting?.value === 'Manual' ? 'Manual' : 'Always' const updateIngestPolicyMutation = useMutation({ mutationFn: (policy: 'Always' | 'Manual') => api.updateSetting('rag.defaultIngestPolicy', policy), onSuccess: (_data, policy) => { queryClient.invalidateQueries({ queryKey: ['ingestPolicy'] }) addNotification({ type: 'success', message: policy === 'Always' ? 'New content will be auto-indexed for AI.' : 'New content will wait for you to opt in.', }) }, onError: (error: any) => { addNotification({ type: 'error', message: error?.message || 'Failed to update indexing policy.', }) }, }) const uploadMutation = useMutation({ mutationFn: (file: File) => api.uploadDocument(file), }) const deleteMutation = useMutation({ mutationFn: (source: string) => api.deleteRAGFile(source), onSuccess: () => { addNotification({ type: 'success', message: 'File removed from knowledge base.' }) setConfirmDeleteSource(null) queryClient.invalidateQueries({ queryKey: ['storedFiles'] }) }, onError: (error: any) => { addNotification({ type: 'error', message: error?.message || 'Failed to delete file.' }) setConfirmDeleteSource(null) }, }) const cleanupFailedMutation = useMutation({ mutationFn: () => api.cleanupFailedEmbedJobs(), onSuccess: (data) => { addNotification({ type: 'success', message: data?.message || 'Failed jobs cleaned up.' }) queryClient.invalidateQueries({ queryKey: ['failedEmbedJobs'] }) }, onError: (error: any) => { addNotification({ type: 'error', message: error?.message || 'Failed to clean up jobs.' }) }, }) const startQdrantMutation = useMutation({ mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'), onSuccess: () => { setIsStartingQdrant(true) queryClient.invalidateQueries({ queryKey: ['qdrantHealth'] }) }, onError: (error: any) => { addNotification({ type: 'error', message: error?.message || 'Failed to start Qdrant.' }) }, }) const syncMutation = useMutation({ mutationFn: () => api.syncRAGStorage(), onSuccess: (data) => { addNotification({ type: 'success', message: data?.message || 'Storage synced successfully. If new files were found, they have been queued for processing.', }) }, onError: (error: any) => { addNotification({ type: 'error', message: error?.message || 'Failed to sync storage', }) }, }) const reembedMutation = useMutation({ mutationFn: () => api.reembedAllRAG(), onSuccess: (data) => { addNotification({ type: data?.success ? 'success' : 'error', message: data?.message || 'Re-embed completed.', }) queryClient.invalidateQueries({ queryKey: ['storedFiles'] }) queryClient.invalidateQueries({ queryKey: ['embed-jobs'] }) setBulkMode(null) setResetTyped('') }, onError: () => { addNotification({ type: 'error', message: 'Failed to re-embed knowledge base.' }) setBulkMode(null) }, }) const resetMutation = useMutation({ mutationFn: () => api.resetAndRebuildRAG(), onSuccess: (data) => { addNotification({ type: data?.success ? 'success' : 'error', message: data?.message || 'Reset complete.', }) queryClient.invalidateQueries({ queryKey: ['storedFiles'] }) queryClient.invalidateQueries({ queryKey: ['embed-jobs'] }) setBulkMode(null) setResetTyped('') }, onError: () => { addNotification({ type: 'error', message: 'Failed to reset knowledge base.' }) setBulkMode(null) }, }) const bulkBusy = reembedMutation.isPending || resetMutation.isPending const handleUpload = async () => { if (files.length === 0) return setIsUploading(true) let successCount = 0 const failedNames: string[] = [] for (const file of files) { try { await uploadMutation.mutateAsync(file) successCount++ } catch (error: any) { failedNames.push(file.name) } } setIsUploading(false) setFiles([]) fileUploaderRef.current?.clear() queryClient.invalidateQueries({ queryKey: ['embed-jobs'] }) if (successCount > 0) { addNotification({ type: 'success', message: `${successCount} file${successCount > 1 ? 's' : ''} queued for processing.`, }) } for (const name of failedNames) { addNotification({ type: 'error', message: `Failed to upload: ${name}` }) } } const handleConfirmSync = () => { openModal( { syncMutation.mutate() closeModal( "confirm-sync-modal" ) }} onCancel={() => closeModal("confirm-sync-modal")} open={true} confirmText='Confirm Sync' cancelText='Cancel' confirmVariant='primary' >

This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date. This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?

, "confirm-sync-modal" ) } return (

Knowledge Base

{qdrantOffline && (
Knowledge Base unavailable: The Qdrant vector database is offline. startQdrantMutation.mutate()} loading={startQdrantMutation.isPending || isStartingQdrant} disabled={startQdrantMutation.isPending || isStartingQdrant} > {isStartingQdrant ? 'Starting…' : 'Start Qdrant'}
)}
{ setFiles(Array.from(uploadedFiles)) }} />
Upload

Why upload documents to your Knowledge Base?

1

{aiAssistantName} Knowledge Base Integration

When you upload documents to your Knowledge Base, NOMAD processes and embeds the content, making it directly accessible to {aiAssistantName}. This allows{' '} {aiAssistantName} to reference your specific documents during conversations, providing more accurate and personalized responses based on your uploaded data.

2

Enhanced Document Processing with OCR

NOMAD includes built-in Optical Character Recognition (OCR) capabilities, allowing it to extract text from image-based documents such as scanned PDFs or photos. This means that even if your documents are not in a standard text format, NOMAD can still process and embed their content for AI access.

3

Information Library Integration

NOMAD will automatically discover and extract any content you save to your Information Library (if installed), making it instantly available to {aiAssistantName} without any extra steps.

Auto-index new content for AI?

Indexed content typically uses 5–10× the original file size on disk. Changes apply to new content added after this setting changes.

{(['Always', 'Manual'] as const).map((option) => { const isActive = ingestPolicy === option return ( ) })}
cleanupFailedMutation.mutate()} loading={cleanupFailedMutation.isPending} disabled={cleanupFailedMutation.isPending || qdrantOffline} > Clean Up Failed
{ setResetTyped(''); setBulkMode('reset') }} disabled={isUploading || qdrantOffline || bulkBusy} loading={resetMutation.isPending} > Reset & Rebuild setBulkMode('reembed')} disabled={isUploading || qdrantOffline || bulkBusy || storedFiles.length === 0} loading={reembedMutation.isPending} > Re-embed All Sync Storage
{warningsUnavailable && (
File warnings unavailable — couldn't read storage state. Retrying…
)} className="font-semibold" rowLines={true} columns={[ { accessor: 'source', title: 'File Name', render(record) { const warnings = fileWarnings[record.source] ?? [] return (
{sourceToDisplayName(record.source)} {warnings.map((w, i) => ( {w.kind === 'zero_chunks' && ( Embedded 0 chunks — this file has no text content. AI Assistant cannot reference it. )} {w.kind === 'partial_stall' && ( Only {w.chunksEmbedded.toLocaleString()} of est.{' '} {w.chunksExpected.toLocaleString()} chunks embedded — ingestion may have stalled. )} ))}
) }, }, { accessor: 'source', title: '', render(record) { // Admin docs are auto-discovered and managed by NOMAD itself — // deleting one would just be re-embedded on the next sync, so // we surface them as informational only and hide Delete. if (record.bucket === 'admin_docs') { return (
Managed by NOMAD
) } const isConfirming = confirmDeleteSource === record.source const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source if (isConfirming) { return (
Remove from knowledge base? deleteMutation.mutate(record.source)} disabled={isDeleting} > {isDeleting ? 'Deleting…' : 'Confirm'} setConfirmDeleteSource(null)} disabled={isDeleting} > Cancel
) } return (
setConfirmDeleteSource(record.source)} disabled={deleteMutation.isPending} loading={deleteMutation.isPending && confirmDeleteSource === record.source} >Delete
) }, }, ]} data={groupAndSortKbFiles(storedFiles)} loading={isLoadingFiles} />
{bulkMode === 'reembed' && ( reembedMutation.mutate()} onCancel={() => setBulkMode(null)} >

This will re-process every document currently in your knowledge base — about {storedFiles.length} file{storedFiles.length === 1 ? '' : 's'}. For each file, NOMAD will delete the existing embeddings from Qdrant and queue a fresh embedding job using the current chunking and embedding model.

What this is for

Use this when the embedding model or chunking logic has changed, or when you suspect stored vectors are stale. Files on disk are not deleted, and any orphan points whose source file is no longer present will be preserved untouched (see Reset & Rebuild if you want a fully clean slate).

Heads up

  • Embedding {storedFiles.length} file{storedFiles.length === 1 ? '' : 's'} may take a long time, especially for large PDFs or ZIM archives.
  • On systems without GPU acceleration, expect sustained high CPU usage for the duration.
  • Knowledge Base search results may be incomplete until every file finishes re-embedding.
  • If embed jobs are already in progress, this action will be refused — wait for the queue to drain first.
)} {bulkMode === 'reset' && ( { if (resetTyped === 'RESET') resetMutation.mutate() }} onCancel={() => { setBulkMode(null); setResetTyped('') }} >

This will permanently delete every point in the nomad_knowledge_base Qdrant collection and rebuild from the {storedFiles.length} file{storedFiles.length === 1 ? '' : 's'} currently on disk. The collection is dropped, recreated, and every file is re-queued for embedding.

How this differs from Re-embed All

  • Re-embed All replaces vectors file-by-file. Any orphan points (vectors whose source file was deleted from disk at some point) are preserved.
  • Reset & Rebuild drops the entire collection. Orphan points are gone forever. Only files currently on disk will exist in Qdrant afterwards.

This action is destructive and cannot be undone

  • Knowledge Base search will be empty until embedding finishes (potentially hours on CPU-only systems).
  • For a few seconds during the reset, the Qdrant collection does not exist — any chat-with-RAG queries in that window may return a "collection not found" error. Avoid using chat until the rebuild has begun.
  • If embed jobs are already in progress, this action will be refused — wait for the queue to drain first.
setResetTyped(e.target.value)} placeholder='RESET' autoFocus className='w-full rounded border border-border-subtle bg-surface-primary px-3 py-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-red-500' /> {resetTyped.length > 0 && resetTyped !== 'RESET' && (

Type RESET exactly (uppercase, no spaces) to enable the confirm button.

)}
)}
) }