ImportFolderDrawer.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import React, { useState, useEffect } from 'react';
  2. import { X, FolderInput, ArrowRight, Info } from 'lucide-react';
  3. import { GROUP_ALLOWED_EXTENSIONS, isExtensionAllowed, getSupportedFormatsLabel } from '../constants/fileSupport';
  4. import { useLanguage } from '../contexts/LanguageContext';
  5. import { ModelConfig, ModelType, IndexingConfig } from '../types';
  6. import { modelConfigService } from '../services/modelConfigService';
  7. import { knowledgeGroupService } from '../services/knowledgeGroupService';
  8. import { useToast } from '../contexts/ToastContext';
  9. import IndexingModalWithMode from './IndexingModalWithMode';
  10. interface ImportFolderDrawerProps {
  11. isOpen: boolean;
  12. onClose: () => void;
  13. authToken: string;
  14. initialGroupId?: string; // If provided, locks target to this group
  15. initialGroupName?: string;
  16. onImportSuccess?: () => void;
  17. }
  18. export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
  19. isOpen,
  20. onClose,
  21. authToken,
  22. initialGroupId,
  23. initialGroupName,
  24. onImportSuccess
  25. }) => {
  26. const { t } = useLanguage();
  27. const { showError, showSuccess } = useToast();
  28. // Form State
  29. const [localFiles, setLocalFiles] = useState<File[]>([]);
  30. const [folderName, setFolderName] = useState('');
  31. const [targetName, setTargetName] = useState('');
  32. const fileInputRef = React.useRef<HTMLInputElement>(null);
  33. // Indexing Config State
  34. const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false);
  35. // Data State
  36. const [models, setModels] = useState<ModelConfig[]>([]);
  37. const [isLoading, setIsLoading] = useState(false);
  38. useEffect(() => {
  39. if (isOpen) {
  40. // Reset form
  41. setLocalFiles([]);
  42. setFolderName('');
  43. setTargetName(initialGroupName || '');
  44. setIsIndexingConfigOpen(false);
  45. // Fetch models
  46. modelConfigService.getAll(authToken).then(res => {
  47. setModels(res.filter(m => m.type === ModelType.EMBEDDING));
  48. });
  49. }
  50. }, [isOpen, authToken, initialGroupName]);
  51. const handleLocalFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  52. if (e.target.files && e.target.files.length > 0) {
  53. const allFiles = Array.from(e.target.files);
  54. // Filter files by allowed extensions
  55. const files = allFiles.filter(file => {
  56. const ext = file.name.split('.').pop() || '';
  57. return isExtensionAllowed(ext, 'group');
  58. });
  59. if (files.length === 0 && allFiles.length > 0) {
  60. showError(t('noFilesFound'));
  61. return;
  62. }
  63. setLocalFiles(files);
  64. // Get root folder name from webkitRelativePath
  65. const firstPath = allFiles[0].webkitRelativePath; // Use allFiles to get path even if filtered
  66. if (firstPath) {
  67. const parts = firstPath.split('/');
  68. if (parts.length > 0) {
  69. const name = parts[0];
  70. setFolderName(name);
  71. if (!initialGroupId && !targetName) {
  72. setTargetName(name);
  73. }
  74. }
  75. }
  76. }
  77. };
  78. const handleNext = async () => {
  79. if (localFiles.length === 0) {
  80. showError(t('clickToSelectFolder'));
  81. return;
  82. }
  83. if (!initialGroupId && !targetName) {
  84. showError(t('fillTargetName'));
  85. return;
  86. }
  87. // Open indexing config modal
  88. setIsIndexingConfigOpen(true);
  89. };
  90. const handleConfirmConfig = async (config: IndexingConfig) => {
  91. setIsLoading(true);
  92. try {
  93. // 1. Ensure target group exists or create it
  94. let groupId = initialGroupId;
  95. if (!groupId) {
  96. const newGroup = await knowledgeGroupService.createGroup({
  97. name: targetName,
  98. description: t('importedFromLocalFolder').replace('$1', folderName)
  99. });
  100. groupId = newGroup.id;
  101. }
  102. // 2. Upload files in batches
  103. const { uploadService } = await import('../services/uploadService');
  104. const { readFile } = await import('../utils/fileUtils');
  105. // Limit concurrent uploads for large folders
  106. const BATCH_SIZE = 3;
  107. for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
  108. const batch = localFiles.slice(i, i + BATCH_SIZE);
  109. await Promise.all(batch.map(async (file) => {
  110. try {
  111. await readFile(file); // Optional: verify readability
  112. const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
  113. if (groupId) {
  114. await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
  115. }
  116. } catch (err) {
  117. console.error(`Failed to upload ${file.name}:`, err);
  118. }
  119. }));
  120. }
  121. showSuccess(t('importComplete'));
  122. onImportSuccess?.();
  123. onClose();
  124. } catch (error: any) {
  125. showError(t('submitFailed', error.message));
  126. } finally {
  127. setIsLoading(false);
  128. setIsIndexingConfigOpen(false);
  129. }
  130. };
  131. if (!isOpen) return null;
  132. return (
  133. <>
  134. <div className="fixed inset-0 z-50 flex justify-end">
  135. <div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={onClose} />
  136. <div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full animate-in slide-in-from-right duration-300">
  137. {/* Header */}
  138. <div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
  139. <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
  140. <FolderInput className="w-5 h-5 text-blue-600" />
  141. {t('importFolderTitle')}
  142. </h2>
  143. <button onClick={onClose} className="p-2 -mr-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors">
  144. <X size={20} />
  145. </button>
  146. </div>
  147. {/* Body */}
  148. <div className="flex-1 overflow-y-auto p-6 space-y-6">
  149. <div className="bg-blue-50 border border-blue-100 rounded-lg p-3 text-sm text-blue-800 flex items-start gap-2">
  150. <Info className="w-4 h-4 mt-0.5 shrink-0" />
  151. <div>
  152. <p className="font-medium underline decoration-blue-200 underline-offset-2 mb-1">
  153. {t('supportedFormatsInfo')}
  154. </p>
  155. <p className="opacity-80 text-xs">
  156. {t('importFolderTip')}
  157. </p>
  158. </div>
  159. </div>
  160. {/* Local Folder Selection */}
  161. <div className="space-y-4 animate-in fade-in slide-in-from-top-2">
  162. <div
  163. onClick={() => fileInputRef.current?.click()}
  164. 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"
  165. >
  166. <div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
  167. <FolderInput size={24} />
  168. </div>
  169. <div className="text-center">
  170. <p className="text-sm font-medium text-slate-700">
  171. {localFiles.length > 0
  172. ? t('selectedFilesCount').replace('$1', localFiles.length.toString())
  173. : t('clickToSelectFolder')}
  174. </p>
  175. <p className="text-xs text-slate-400 mt-1">
  176. {localFiles.length > 0 ? folderName : t('selectFolderTip')}
  177. </p>
  178. </div>
  179. <input
  180. type="file"
  181. ref={fileInputRef}
  182. onChange={handleLocalFolderChange}
  183. className="hidden"
  184. multiple
  185. // @ts-ignore
  186. webkitdirectory=""
  187. directory=""
  188. />
  189. </div>
  190. </div>
  191. {/* Target Group */}
  192. <div className="space-y-2">
  193. <label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
  194. <input
  195. type="text"
  196. value={targetName}
  197. onChange={e => setTargetName(e.target.value)}
  198. disabled={!!initialGroupId} // Readonly if locking to group
  199. placeholder={t('placeholderNewGroup')}
  200. 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' : ''}`}
  201. />
  202. {initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
  203. </div>
  204. </div>
  205. {/* Footer */}
  206. <div className="p-6 border-t border-slate-100 shrink-0 flex gap-3 bg-slate-50">
  207. <button
  208. onClick={onClose}
  209. className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
  210. >
  211. {t('cancel')}
  212. </button>
  213. <button
  214. onClick={handleNext}
  215. className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
  216. disabled={isLoading}
  217. >
  218. <span>{isLoading ? t('uploading') : t('nextStep')}</span>
  219. <ArrowRight size={16} />
  220. </button>
  221. </div>
  222. </div>
  223. </div>
  224. {/* Indexing Config Modal */}
  225. <IndexingModalWithMode
  226. isOpen={isIndexingConfigOpen}
  227. onClose={() => setIsIndexingConfigOpen(false)}
  228. files={[]} // Empty array for folder import mode
  229. embeddingModels={models}
  230. defaultEmbeddingId={models.length > 0 ? models[0].id : ''}
  231. onConfirm={handleConfirmConfig}
  232. isReconfiguring={false}
  233. />
  234. </>
  235. );
  236. };