baveshraam's picture
FIX: SurrealDB 2.0 migration syntax and Frontend/CORS link
f871fed
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { LoaderIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { WizardContainer, WizardStep } from '@/components/ui/wizard-container'
import { SourceTypeStep, parseAndValidateUrls } from './steps/SourceTypeStep'
import { NotebooksStep } from './steps/NotebooksStep'
import { ProcessingStep } from './steps/ProcessingStep'
import { useNotebooks } from '@/lib/hooks/use-notebooks'
import { useTransformations } from '@/lib/hooks/use-transformations'
import { useCreateSource } from '@/lib/hooks/use-sources'
import { useSettings } from '@/lib/hooks/use-settings'
import { CreateSourceRequest } from '@/lib/types/api'
const MAX_BATCH_SIZE = 50
const createSourceSchema = z.object({
type: z.enum(['link', 'upload', 'text']),
title: z.string().optional(),
url: z.string().optional(),
content: z.string().optional(),
file: z.any().optional(),
notebooks: z.array(z.string()).optional(),
transformations: z.array(z.string()).optional(),
embed: z.boolean(),
async_processing: z.boolean(),
}).refine((data) => {
if (data.type === 'link') {
return !!data.url && data.url.trim() !== ''
}
if (data.type === 'text') {
return !!data.content && data.content.trim() !== ''
}
if (data.type === 'upload') {
if (data.file instanceof FileList) {
return data.file.length > 0
}
return !!data.file
}
return true
}, {
message: 'Please provide the required content for the selected source type',
path: ['type'],
}).refine((data) => {
// Make title mandatory for text sources
if (data.type === 'text') {
return !!data.title && data.title.trim() !== ''
}
return true
}, {
message: 'Title is required for text sources',
path: ['title'],
})
type CreateSourceFormData = z.infer<typeof createSourceSchema>
interface AddSourceDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
defaultNotebookId?: string
}
const WIZARD_STEPS: readonly WizardStep[] = [
{ number: 1, title: 'Source & Content', description: 'Choose type and add content' },
{ number: 2, title: 'Organization', description: 'Select notebooks' },
{ number: 3, title: 'Processing', description: 'Choose transformations and options' },
]
interface ProcessingState {
message: string
progress?: number
}
interface BatchProgress {
total: number
completed: number
failed: number
currentItem?: string
}
export function AddSourceDialog({
open,
onOpenChange,
defaultNotebookId
}: AddSourceDialogProps) {
// Simplified state management
const [currentStep, setCurrentStep] = useState(1)
const [processing, setProcessing] = useState(false)
const [processingStatus, setProcessingStatus] = useState<ProcessingState | null>(null)
const [selectedNotebooks, setSelectedNotebooks] = useState<string[]>(
defaultNotebookId ? [defaultNotebookId] : []
)
const [selectedTransformations, setSelectedTransformations] = useState<string[]>([])
// Batch-specific state
const [urlValidationErrors, setUrlValidationErrors] = useState<{ url: string; line: number }[]>([])
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)
// Cleanup timeouts to prevent memory leaks
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// API hooks
const createSource = useCreateSource()
const { data: notebooks = [], isLoading: notebooksLoading } = useNotebooks()
const { data: transformations = [], isLoading: transformationsLoading } = useTransformations()
const { data: settings } = useSettings()
// Form setup
const {
register,
handleSubmit,
control,
watch,
formState: { errors },
reset,
} = useForm<CreateSourceFormData>({
resolver: zodResolver(createSourceSchema),
defaultValues: {
notebooks: defaultNotebookId ? [defaultNotebookId] : [],
embed: settings?.default_embedding_option === 'always' || settings?.default_embedding_option === 'ask',
async_processing: true,
transformations: [],
},
})
// Initialize form values when settings and transformations are loaded
useEffect(() => {
if (settings && transformations.length > 0) {
const defaultTransformations = transformations
.filter(t => t.apply_default)
.map(t => t.id)
setSelectedTransformations(defaultTransformations)
// Reset form with proper embed value based on settings
const embedValue = settings.default_embedding_option === 'always' ||
(settings.default_embedding_option === 'ask')
reset({
notebooks: defaultNotebookId ? [defaultNotebookId] : [],
embed: embedValue,
async_processing: true,
transformations: [],
})
}
}, [settings, transformations, defaultNotebookId, reset])
// Cleanup effect
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const selectedType = watch('type')
const watchedUrl = watch('url')
const watchedContent = watch('content')
const watchedFile = watch('file')
const watchedTitle = watch('title')
// Batch mode detection
const { isBatchMode, itemCount, parsedUrls, parsedFiles } = useMemo(() => {
let urlCount = 0
let fileCount = 0
let parsedUrls: string[] = []
let parsedFiles: File[] = []
if (selectedType === 'link' && watchedUrl) {
const { valid } = parseAndValidateUrls(watchedUrl)
parsedUrls = valid
urlCount = valid.length
}
if (selectedType === 'upload' && watchedFile) {
const fileList = watchedFile as FileList
if (fileList?.length) {
parsedFiles = Array.from(fileList)
fileCount = parsedFiles.length
}
}
const isBatchMode = urlCount > 1 || fileCount > 1
const itemCount = selectedType === 'link' ? urlCount : fileCount
return { isBatchMode, itemCount, parsedUrls, parsedFiles }
}, [selectedType, watchedUrl, watchedFile])
// Check for batch size limit
const isOverLimit = itemCount > MAX_BATCH_SIZE
// Step validation - now reactive with watched values
const isStepValid = (step: number): boolean => {
switch (step) {
case 1:
if (!selectedType) return false
// Check batch size limit
if (isOverLimit) return false
// Check for URL validation errors
if (urlValidationErrors.length > 0) return false
if (selectedType === 'link') {
// In batch mode, check that we have at least one valid URL
if (isBatchMode) {
return parsedUrls.length > 0
}
return !!watchedUrl && watchedUrl.trim() !== ''
}
if (selectedType === 'text') {
return !!watchedContent && watchedContent.trim() !== '' &&
!!watchedTitle && watchedTitle.trim() !== ''
}
if (selectedType === 'upload') {
if (watchedFile instanceof FileList) {
return watchedFile.length > 0 && watchedFile.length <= MAX_BATCH_SIZE
}
return !!watchedFile
}
return true
case 2:
case 3:
return true
default:
return false
}
}
// Navigation
const handleNextStep = (e?: React.MouseEvent) => {
e?.preventDefault()
e?.stopPropagation()
// Validate URLs when leaving step 1 in link mode
if (currentStep === 1 && selectedType === 'link' && watchedUrl) {
const { invalid } = parseAndValidateUrls(watchedUrl)
if (invalid.length > 0) {
setUrlValidationErrors(invalid)
return
}
setUrlValidationErrors([])
}
if (currentStep < 3 && isStepValid(currentStep)) {
setCurrentStep(currentStep + 1)
}
}
// Clear URL validation errors when user edits
const handleClearUrlErrors = () => {
setUrlValidationErrors([])
}
const handlePrevStep = (e?: React.MouseEvent) => {
e?.preventDefault()
e?.stopPropagation()
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const handleStepClick = (step: number) => {
if (step <= currentStep || (step === currentStep + 1 && isStepValid(currentStep))) {
setCurrentStep(step)
}
}
// Selection handlers
const handleNotebookToggle = (notebookId: string) => {
const updated = selectedNotebooks.includes(notebookId)
? selectedNotebooks.filter(id => id !== notebookId)
: [...selectedNotebooks, notebookId]
setSelectedNotebooks(updated)
}
const handleTransformationToggle = (transformationId: string) => {
const updated = selectedTransformations.includes(transformationId)
? selectedTransformations.filter(id => id !== transformationId)
: [...selectedTransformations, transformationId]
setSelectedTransformations(updated)
}
// Single source submission
const submitSingleSource = async (data: CreateSourceFormData): Promise<void> => {
const createRequest: CreateSourceRequest = {
type: data.type,
notebooks: selectedNotebooks,
url: data.type === 'link' ? data.url : undefined,
content: data.type === 'text' ? data.content : undefined,
title: data.title,
transformations: selectedTransformations,
embed: data.embed,
delete_source: false,
async_processing: true,
}
if (data.type === 'upload' && data.file) {
const file = data.file instanceof FileList ? data.file[0] : data.file
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
requestWithFile.file = file
}
await createSource.mutateAsync(createRequest)
}
// Batch submission
const submitBatch = async (data: CreateSourceFormData): Promise<{ success: number; failed: number }> => {
const results = { success: 0, failed: 0 }
const items: { type: 'url' | 'file'; value: string | File }[] = []
// Collect items to process
if (data.type === 'link' && parsedUrls.length > 0) {
parsedUrls.forEach(url => items.push({ type: 'url', value: url }))
} else if (data.type === 'upload' && parsedFiles.length > 0) {
parsedFiles.forEach(file => items.push({ type: 'file', value: file }))
}
setBatchProgress({
total: items.length,
completed: 0,
failed: 0,
})
// Process each item sequentially
for (let i = 0; i < items.length; i++) {
const item = items[i]
const itemLabel = item.type === 'url'
? (item.value as string).substring(0, 50) + '...'
: (item.value as File).name
setBatchProgress(prev => prev ? {
...prev,
currentItem: itemLabel,
} : null)
try {
const createRequest: CreateSourceRequest = {
type: item.type === 'url' ? 'link' : 'upload',
notebooks: selectedNotebooks,
url: item.type === 'url' ? item.value as string : undefined,
transformations: selectedTransformations,
embed: data.embed,
delete_source: false,
async_processing: true,
}
if (item.type === 'file') {
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
requestWithFile.file = item.value as File
}
await createSource.mutateAsync(createRequest)
results.success++
} catch (error) {
console.error(`Error creating source for ${itemLabel}:`, error)
results.failed++
}
setBatchProgress(prev => prev ? {
...prev,
completed: results.success,
failed: results.failed,
} : null)
}
return results
}
// Form submission
const onSubmit = async (data: CreateSourceFormData) => {
try {
setProcessing(true)
if (isBatchMode) {
// Batch submission
setProcessingStatus({ message: `Processing ${itemCount} sources...` })
const results = await submitBatch(data)
// Show summary toast
if (results.failed === 0) {
toast.success(`${results.success} source${results.success !== 1 ? 's' : ''} created successfully`)
} else if (results.success === 0) {
toast.error(`Failed to create all ${results.failed} sources`)
} else {
toast.warning(`${results.success} succeeded, ${results.failed} failed`)
}
handleClose()
} else {
// Single source submission
setProcessingStatus({ message: 'Submitting source for processing...' })
await submitSingleSource(data)
handleClose()
}
} catch (error) {
console.error('Error creating source:', error)
setProcessingStatus({
message: 'Error creating source. Please try again.',
})
timeoutRef.current = setTimeout(() => {
setProcessing(false)
setProcessingStatus(null)
setBatchProgress(null)
}, 3000)
}
}
// Dialog management
const handleClose = () => {
// Clear any pending timeouts
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
reset()
setCurrentStep(1)
setProcessing(false)
setProcessingStatus(null)
setSelectedNotebooks(defaultNotebookId ? [defaultNotebookId] : [])
setUrlValidationErrors([])
setBatchProgress(null)
// Reset to default transformations
if (transformations.length > 0) {
const defaultTransformations = transformations
.filter(t => t.apply_default)
.map(t => t.id)
setSelectedTransformations(defaultTransformations)
} else {
setSelectedTransformations([])
}
onOpenChange(false)
}
// Processing view
if (processing) {
const progressPercent = batchProgress
? Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)
: undefined
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]" showCloseButton={true}>
<DialogHeader>
<DialogTitle>
{batchProgress ? 'Processing Batch' : 'Processing Source'}
</DialogTitle>
<DialogDescription>
{batchProgress
? `Processing ${batchProgress.total} sources. This may take a few moments.`
: 'Your source is being processed. This may take a few moments.'
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3">
<LoaderIcon className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
{processingStatus?.message || 'Processing...'}
</span>
</div>
{/* Batch progress */}
{batchProgress && (
<>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5 text-green-600">
<CheckCircleIcon className="h-4 w-4" />
{batchProgress.completed} completed
</span>
{batchProgress.failed > 0 && (
<span className="flex items-center gap-1.5 text-destructive">
<XCircleIcon className="h-4 w-4" />
{batchProgress.failed} failed
</span>
)}
</div>
<span className="text-muted-foreground">
{batchProgress.completed + batchProgress.failed} / {batchProgress.total}
</span>
</div>
{batchProgress.currentItem && (
<p className="text-xs text-muted-foreground truncate">
Current: {batchProgress.currentItem}
</p>
)}
</>
)}
{/* Single source progress */}
{!batchProgress && processingStatus?.progress && (
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${processingStatus.progress}%` }}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
const currentStepValid = isStepValid(currentStep)
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[700px] p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>Add New Source</DialogTitle>
<DialogDescription>
Add content from links, uploads, or text to your notebooks.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<WizardContainer
currentStep={currentStep}
steps={WIZARD_STEPS}
onStepClick={handleStepClick}
className="border-0"
>
{currentStep === 1 && (
<SourceTypeStep
// @ts-expect-error - Type inference issue with zod schema
control={control}
register={register}
// @ts-expect-error - Type inference issue with zod schema
errors={errors}
urlValidationErrors={urlValidationErrors}
onClearUrlErrors={handleClearUrlErrors}
/>
)}
{currentStep === 2 && (
<NotebooksStep
notebooks={notebooks}
selectedNotebooks={selectedNotebooks}
onToggleNotebook={handleNotebookToggle}
loading={notebooksLoading}
/>
)}
{currentStep === 3 && (
<ProcessingStep
// @ts-expect-error - Type inference issue with zod schema
control={control}
transformations={transformations}
selectedTransformations={selectedTransformations}
onToggleTransformation={handleTransformationToggle}
loading={transformationsLoading}
settings={settings}
/>
)}
</WizardContainer>
{/* Navigation */}
<div className="flex justify-between items-center px-6 py-4 border-t border-border bg-muted">
<Button
type="button"
variant="outline"
onClick={handleClose}
>
Cancel
</Button>
<div className="flex gap-2">
{currentStep > 1 && (
<Button
type="button"
variant="outline"
onClick={handlePrevStep}
>
Back
</Button>
)}
{/* Show Next button on steps 1 and 2, styled as outline/secondary */}
{currentStep < 3 && (
<Button
type="button"
variant="outline"
onClick={(e) => handleNextStep(e)}
disabled={!currentStepValid}
>
Next
</Button>
)}
{/* Show Done button on all steps, styled as primary */}
<Button
type="submit"
disabled={!currentStepValid || createSource.isPending}
className="min-w-[120px]"
>
{createSource.isPending ? 'Creating...' : 'Done'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}