import React, { useState, useEffect } from 'react'; import { X, FolderInput, ArrowRight, Info, Layers, Clock, Upload, Calendar } from 'lucide-react'; import { isExtensionAllowed } from '../constants/fileSupport'; import { useLanguage } from '../contexts/LanguageContext'; import { ModelConfig, ModelType, IndexingConfig, KnowledgeGroup } from '../types'; import { modelConfigService } from '../services/modelConfigService'; import { knowledgeGroupService } from '../services/knowledgeGroupService'; import { apiClient } from '../services/apiClient'; import { useToast } from '../contexts/ToastContext'; import IndexingModalWithMode from './IndexingModalWithMode'; interface ImportFolderDrawerProps { isOpen: boolean; onClose: () => void; authToken: string; initialGroupId?: string; initialGroupName?: string; onImportSuccess?: () => void; } interface FileWithPath { file: File; relativePath: string; } type ImportMode = 'immediate' | 'scheduled'; export const ImportFolderDrawer: React.FC = ({ isOpen, onClose, authToken, initialGroupId, initialGroupName, onImportSuccess, }) => { const { t } = useLanguage(); const { showError, showSuccess } = useToast(); // Tab const [importMode, setImportMode] = useState('immediate'); // Immediate mode state const [localFiles, setLocalFiles] = useState([]); const [folderName, setFolderName] = useState(''); const [targetName, setTargetName] = useState(''); const [useHierarchy, setUseHierarchy] = useState(false); const fileInputRef = React.useRef(null); const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false); const [models, setModels] = useState([]); // Scheduled mode state const [serverPath, setServerPath] = useState(''); const [scheduledTime, setScheduledTime] = useState(() => { // Default to 30 min from now const d = new Date(); d.setMinutes(d.getMinutes() + 30); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm" }); const [schedTargetName, setSchedTargetName] = useState(''); const [schedUseHierarchy, setSchedUseHierarchy] = useState(false); const [isLoading, setIsLoading] = useState(false); const [allGroups, setAllGroups] = useState([]); const [parentGroupId, setParentGroupId] = useState(''); const [schedParentGroupId, setSchedParentGroupId] = useState(''); useEffect(() => { if (isOpen) { setLocalFiles([]); setFolderName(''); setTargetName(initialGroupName || ''); setUseHierarchy(false); setIsIndexingConfigOpen(false); setImportMode('immediate'); setServerPath(''); setSchedTargetName(initialGroupName || ''); setSchedUseHierarchy(false); setParentGroupId(''); setSchedParentGroupId(''); // Default scheduled time = 30min from now const d = new Date(); d.setMinutes(d.getMinutes() + 30); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); setScheduledTime(d.toISOString().slice(0, 16)); modelConfigService.getAll(authToken).then(res => { setModels(res.filter(m => m.type === ModelType.EMBEDDING)); }); knowledgeGroupService.getGroups().then(groups => { const flat: any[] = []; function walk(items: any[], depth = 0) { for (const g of items) { flat.push({ ...g, d: depth }); if (g.children?.length) walk(g.children, depth + 1); } } walk(groups); setAllGroups(flat); }); } }, [isOpen, authToken, initialGroupName]); // ---- Immediate mode handlers ---- const handleLocalFolderChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { const allFiles = Array.from(e.target.files); const files: FileWithPath[] = allFiles .filter(file => { const ext = file.name.split('.').pop() || ''; return isExtensionAllowed(ext, 'group'); }) .map(file => ({ file, relativePath: file.webkitRelativePath || file.name, })); if (files.length === 0 && allFiles.length > 0) { showError(t('noFilesFound')); return; } setLocalFiles(files); const firstPath = allFiles[0].webkitRelativePath; if (firstPath) { const parts = firstPath.split('/'); if (parts.length > 0) { const name = parts[0]; setFolderName(name); if (!initialGroupId && !targetName) setTargetName(name); } } } }; const handleImmediateNext = () => { if (localFiles.length === 0) { showError(t('clickToSelectFolder')); return; } if (!initialGroupId && !targetName) { showError(t('fillTargetName')); return; } setIsIndexingConfigOpen(true); }; const handleConfirmConfig = async (config: IndexingConfig) => { setIsLoading(true); try { const { uploadService } = await import('../services/uploadService'); if (useHierarchy) { // Step 1: Determine root group let rootGroupId = initialGroupId ?? null; if (!rootGroupId) { const newGroup = await knowledgeGroupService.createGroup({ name: targetName, description: t('importedFromLocalFolder').replace('$1', folderName), parentId: parentGroupId || null, }); rootGroupId = newGroup.id; } // Step 2: Collect all unique directory paths const dirSet = new Set(); for (const { relativePath } of localFiles) { const parts = relativePath.split('/'); const dirParts = initialGroupId ? parts.slice(1, parts.length - 1) : parts.slice(0, parts.length - 1); for (let i = 1; i <= dirParts.length; i++) { dirSet.add(dirParts.slice(0, i).join('/')); } } // Step 3: Sort by depth, create groups sequentially const sortedDirs = Array.from(dirSet).sort((a, b) => a.split('/').length - b.split('/').length ); const dirToGroupId = new Map(); dirToGroupId.set(initialGroupId ? '' : folderName, rootGroupId); for (const dirPath of sortedDirs) { if (!dirPath || dirToGroupId.has(dirPath)) continue; const segments = dirPath.split('/'); const segName = segments[segments.length - 1]; const parentPath = segments.slice(0, segments.length - 1).join('/'); const parentId = dirToGroupId.get(parentPath) ?? rootGroupId; const newGroup = await knowledgeGroupService.createGroup({ name: segName, parentId }); dirToGroupId.set(dirPath, newGroup.id); } // Step 4: Upload files in parallel batches const BATCH_SIZE = 3; for (let i = 0; i < localFiles.length; i += BATCH_SIZE) { const batch = localFiles.slice(i, i + BATCH_SIZE); await Promise.all(batch.map(async ({ file, relativePath }) => { try { const parts = relativePath.split('/'); const dirParts = initialGroupId ? parts.slice(1, parts.length - 1) : parts.slice(0, parts.length - 1); const fileDirPath = dirParts.join('/'); const targetGroupId = dirToGroupId.get(fileDirPath) ?? rootGroupId!; const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken); await knowledgeGroupService.addFileToGroups(uploadedKb.id, [targetGroupId]); } catch (err) { console.error(`Failed to upload ${file.name}:`, err); } })); } } else { // Single-group mode let groupId = initialGroupId ?? null; if (!groupId) { const newGroup = await knowledgeGroupService.createGroup({ name: targetName, description: t('importedFromLocalFolder').replace('$1', folderName), }); groupId = newGroup.id; } const BATCH_SIZE = 3; for (let i = 0; i < localFiles.length; i += BATCH_SIZE) { const batch = localFiles.slice(i, i + BATCH_SIZE); await Promise.all(batch.map(async ({ file }) => { try { const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken); if (groupId) await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]); } catch (err) { console.error(`Failed to upload ${file.name}:`, err); } })); } } showSuccess(t('importComplete')); onImportSuccess?.(); onClose(); } catch (error: any) { showError(t('submitFailed', error.message)); } finally { setIsLoading(false); setIsIndexingConfigOpen(false); } }; // ---- Scheduled mode handler ---- const handleScheduledSubmit = async () => { if (!serverPath.trim()) { showError(t('fillServerPath')); return; } if (!initialGroupId && !schedTargetName.trim()) { showError(t('fillTargetName')); return; } const scheduledAt = new Date(scheduledTime); if (isNaN(scheduledAt.getTime())) { showError(t('invalidDateTime' as any)); return; } setIsLoading(true); try { let finalGroupId = initialGroupId || undefined; if (!finalGroupId && schedTargetName.trim()) { const newGroup = await knowledgeGroupService.createGroup({ name: schedTargetName.trim(), description: t('importedFromLocalFolder').replace('$1', schedTargetName.trim()), parentId: schedParentGroupId || null, }); finalGroupId = newGroup.id; } const defaultModel = models[0]; await apiClient.post('/import-tasks', { sourcePath: serverPath.trim(), targetGroupId: finalGroupId, targetGroupName: undefined, embeddingModelId: defaultModel?.id, scheduledAt: scheduledAt.toISOString(), chunkSize: 500, chunkOverlap: 50, mode: 'fast', useHierarchy: schedUseHierarchy, }); showSuccess(t('scheduleTaskCreated')); onImportSuccess?.(); onClose(); } catch (error: any) { showError(t('submitFailed', error.message)); } finally { setIsLoading(false); } }; if (!isOpen) return null; return ( <>
{/* Header */}

{t('importFolderTitle')}

{/* Mode Tabs */}
{/* Body */}
{importMode === 'immediate' ? ( <> {/* Immediate: folder picker */}
fileInputRef.current?.click()} className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group" >

{localFiles.length > 0 ? t('selectedFilesCount').replace('$1', localFiles.length.toString()) : t('clickToSelectFolder')}

{localFiles.length > 0 ? folderName : t('selectFolderTip')}

{/* Target group */}
setTargetName(e.target.value)} disabled={!!initialGroupId} placeholder={t('placeholderNewGroup')} className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`} /> {initialGroupId &&

{t('importToCurrentGroup')}

}
{!initialGroupId && (
)} {/* Hierarchy toggle */} ) : ( <> {/* Scheduled: server path */}

{t('scheduledImportTip')}

setServerPath(e.target.value)} placeholder={t('placeholderServerPath')} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none font-mono" />
{/* Target group */}
setSchedTargetName(e.target.value)} disabled={!!initialGroupId} placeholder={t('placeholderNewGroup')} className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`} /> {initialGroupId &&

{t('importToCurrentGroup')}

}
{!initialGroupId && (
)} {/* Scheduled datetime */}
setScheduledTime(e.target.value)} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none" />

{t('scheduledTimeHint')}

{/* Hierarchy toggle */} )}
{/* Footer */}
{importMode === 'immediate' ? ( ) : ( )}
{/* Indexing Config Modal (immediate mode only) */} setIsIndexingConfigOpen(false)} files={[]} embeddingModels={models} defaultEmbeddingId={models.length > 0 ? models[0].id : ''} onConfirm={handleConfirmConfig} isReconfiguring={false} /> ); }; /** Reusable hierarchy toggle */ const HierarchyToggle: React.FC<{ value: boolean; onChange: (v: boolean) => void; t: (key: string) => string; }> = ({ value, onChange, t }) => (