487 lines
20 KiB
TypeScript
487 lines
20 KiB
TypeScript
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<File[]>([])
|
||
const [isUploading, setIsUploading] = useState(false)
|
||
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
|
||
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(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 || [],
|
||
})
|
||
|
||
// 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.',
|
||
})
|
||
},
|
||
})
|
||
|
||
// Per-file conditional warnings (RFC #883 §6). Only sources with at least
|
||
// one triggered warning are returned, so an empty map means everything is
|
||
// healthy. Polled at the same idle cadence as health for low overhead.
|
||
const { data: fileWarnings = {} } = useQuery({
|
||
queryKey: ['kbFileWarnings'],
|
||
queryFn: () => api.getKbFileWarnings(),
|
||
select: (data) => data ?? {},
|
||
refetchInterval: 30_000,
|
||
})
|
||
|
||
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 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(
|
||
<StyledModal
|
||
title='Confirm Sync?'
|
||
onConfirm={() => {
|
||
syncMutation.mutate()
|
||
closeModal(
|
||
"confirm-sync-modal"
|
||
)
|
||
}}
|
||
onCancel={() => closeModal("confirm-sync-modal")}
|
||
open={true}
|
||
confirmText='Confirm Sync'
|
||
cancelText='Cancel'
|
||
confirmVariant='primary'
|
||
>
|
||
<p className='text-text-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?
|
||
</p>
|
||
</StyledModal>,
|
||
"confirm-sync-modal"
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
|
||
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
|
||
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
|
||
>
|
||
<IconX className="h-6 w-6 text-text-muted" />
|
||
</button>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1 p-6">
|
||
{qdrantOffline && (
|
||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm dark:bg-red-950 dark:border-red-800 dark:text-red-300 flex items-center justify-between gap-4">
|
||
<span>
|
||
<strong>Knowledge Base unavailable:</strong> The Qdrant vector database is offline.
|
||
</span>
|
||
<StyledButton
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => startQdrantMutation.mutate()}
|
||
loading={startQdrantMutation.isPending || isStartingQdrant}
|
||
disabled={startQdrantMutation.isPending || isStartingQdrant}
|
||
>
|
||
{isStartingQdrant ? 'Starting…' : 'Start Qdrant'}
|
||
</StyledButton>
|
||
</div>
|
||
)}
|
||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
|
||
<div className="p-6">
|
||
<FileUploader
|
||
ref={fileUploaderRef}
|
||
minFiles={1}
|
||
maxFiles={5}
|
||
onUpload={(uploadedFiles) => {
|
||
setFiles(Array.from(uploadedFiles))
|
||
}}
|
||
/>
|
||
<div className="flex justify-center gap-4 my-6">
|
||
<StyledButton
|
||
variant="primary"
|
||
size="lg"
|
||
icon="IconUpload"
|
||
onClick={handleUpload}
|
||
disabled={files.length === 0 || isUploading || qdrantOffline}
|
||
loading={isUploading}
|
||
>
|
||
Upload
|
||
</StyledButton>
|
||
</div>
|
||
</div>
|
||
<div className="border-t bg-surface-primary p-6">
|
||
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
||
Why upload documents to your Knowledge Base?
|
||
</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
|
||
1
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-desert-stone-dark">
|
||
{aiAssistantName} Knowledge Base Integration
|
||
</p>
|
||
<p className="text-sm text-desert-stone">
|
||
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.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
|
||
2
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-desert-stone-dark">
|
||
Enhanced Document Processing with OCR
|
||
</p>
|
||
<p className="text-sm text-desert-stone">
|
||
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.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
|
||
3
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-desert-stone-dark">
|
||
Information Library Integration
|
||
</p>
|
||
<p className="text-sm text-desert-stone">
|
||
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.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="my-8 p-4 rounded-lg border border-border-subtle bg-surface-secondary">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex-1 min-w-[14rem]">
|
||
<p className="text-sm font-medium text-text-primary">
|
||
Auto-index new content for AI?
|
||
</p>
|
||
<p className="text-xs text-text-muted mt-1">
|
||
Indexed content typically uses 5–10× the original file size on disk.
|
||
Changes apply to new content added after this setting changes.
|
||
</p>
|
||
</div>
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Ingest policy"
|
||
className="inline-flex rounded-md overflow-hidden border border-border-subtle"
|
||
>
|
||
{(['Always', 'Manual'] as const).map((option) => {
|
||
const isActive = ingestPolicy === option
|
||
return (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={isActive}
|
||
onClick={() =>
|
||
!isActive && updateIngestPolicyMutation.mutate(option)
|
||
}
|
||
disabled={updateIngestPolicyMutation.isPending}
|
||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||
isActive
|
||
? 'bg-desert-green text-white'
|
||
: 'bg-surface-primary text-text-secondary hover:bg-surface-tertiary'
|
||
} ${updateIngestPolicyMutation.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||
>
|
||
{option}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="my-8">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<StyledSectionHeader title="Processing Queue" className="!mb-0" />
|
||
<StyledButton
|
||
variant="danger"
|
||
size="md"
|
||
icon="IconTrash"
|
||
onClick={() => cleanupFailedMutation.mutate()}
|
||
loading={cleanupFailedMutation.isPending}
|
||
disabled={cleanupFailedMutation.isPending || qdrantOffline}
|
||
>
|
||
Clean Up Failed
|
||
</StyledButton>
|
||
</div>
|
||
<ActiveEmbedJobs withHeader={false} />
|
||
</div>
|
||
|
||
<div className="my-12">
|
||
<div className='flex items-center justify-between mb-6'>
|
||
<StyledSectionHeader title="Stored Knowledge Base Files" className='!mb-0' />
|
||
<StyledButton
|
||
variant="secondary"
|
||
size="md"
|
||
icon='IconRefresh'
|
||
onClick={handleConfirmSync}
|
||
disabled={syncMutation.isPending || isUploading || qdrantOffline}
|
||
loading={syncMutation.isPending || isUploading}
|
||
>
|
||
Sync Storage
|
||
</StyledButton>
|
||
</div>
|
||
<StyledTable<KbFileGroup>
|
||
className="font-semibold"
|
||
rowLines={true}
|
||
columns={[
|
||
{
|
||
accessor: 'source',
|
||
title: 'File Name',
|
||
render(record) {
|
||
const warnings = fileWarnings[record.source] ?? []
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-text-primary">{record.displayName}</span>
|
||
{warnings.map((w, i) => (
|
||
<span
|
||
key={i}
|
||
className="inline-flex items-center gap-1.5 self-start text-xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-2 py-0.5"
|
||
>
|
||
<span aria-hidden="true">⚠</span>
|
||
{w.kind === 'zero_chunks' && (
|
||
<span>
|
||
Embedded 0 chunks — this file has no text content.
|
||
AI Assistant cannot reference it.
|
||
</span>
|
||
)}
|
||
{w.kind === 'partial_stall' && (
|
||
<span>
|
||
Only {w.chunksEmbedded.toLocaleString()} of est.{' '}
|
||
{w.chunksExpected.toLocaleString()} chunks embedded —
|
||
ingestion may have stalled.
|
||
</span>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
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 (
|
||
<div className="flex justify-end">
|
||
<span className="text-sm text-text-muted italic">
|
||
Managed by NOMAD
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const isConfirming = confirmDeleteSource === record.source
|
||
const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source
|
||
if (isConfirming) {
|
||
return (
|
||
<div className="flex items-center gap-2 justify-end">
|
||
<span className="text-sm text-text-secondary">Remove from knowledge base?</span>
|
||
<StyledButton
|
||
variant='danger'
|
||
size='sm'
|
||
onClick={() => deleteMutation.mutate(record.source)}
|
||
disabled={isDeleting}
|
||
>
|
||
{isDeleting ? 'Deleting…' : 'Confirm'}
|
||
</StyledButton>
|
||
<StyledButton
|
||
variant='ghost'
|
||
size='sm'
|
||
onClick={() => setConfirmDeleteSource(null)}
|
||
disabled={isDeleting}
|
||
>
|
||
Cancel
|
||
</StyledButton>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="flex justify-end">
|
||
<StyledButton
|
||
variant="danger"
|
||
size="sm"
|
||
icon="IconTrash"
|
||
onClick={() => setConfirmDeleteSource(record.source)}
|
||
disabled={deleteMutation.isPending}
|
||
loading={deleteMutation.isPending && confirmDeleteSource === record.source}
|
||
>Delete</StyledButton>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
]}
|
||
data={groupAndSortKbFiles(storedFiles)}
|
||
loading={isLoadingFiles}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|