/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/knowledge/DocumentUploadModal.tsx
707 строк
25 KB
Alexander Efanov
Обновление репозитория
15 июл 2026, 12:19
15 июл 2026, 12:19
76704c6
Код
Авторство
О чём код?
// src/components/knowledge/DocumentUploadModal.tsx import { useState, useRef, useCallback, type DragEvent } from 'react'; import { Upload, X, AlertCircle, Loader2, CheckCircle, FileText, Image as ImageIcon, Code, Database, Sparkles, } from 'lucide-react'; import mammoth from 'mammoth'; import { useAppStore } from '../../lib/stores/app-store'; import { ingestDocument, mimeToDocType } from '../../lib/api'; import { extractTextFromPdf, isPdfFile } from '../../lib/utils/pdf-extract'; import { extractTextWithOCR } from '../../lib/utils/ocr-extract'; import { cn } from '../../lib/utils'; // ============================================================================ // Constants // ============================================================================ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB const MAX_TEXT_CHARS = 10_000_000; // 10M символов const ALLOWED_EXTENSIONS = [ 'txt', 'md', 'markdown', 'pdf', 'docx', 'html', 'htm', 'json', 'xml', 'csv', 'png', 'jpg', 'jpeg', 'webp', 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'php', 'swift', 'kt', 'yml', 'yaml', 'toml', 'ini', 'conf', 'env', 'sh', 'bash', 'zsh', 'ps1', 'bat', ]; const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp']); const CODE_EXTENSIONS = [ 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'php', 'swift', 'kt', ]; const DATA_EXTENSIONS = ['json', 'xml', 'csv']; const FILE_INPUT_ACCEPT = [ 'text/plain', 'text/markdown', 'text/html', 'text/csv', 'application/pdf', 'application/json', 'application/xml', 'application/javascript', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/png', 'image/jpeg', 'image/webp', ...ALLOWED_EXTENSIONS.map((ext) => `.${ext}`), ].join(','); // ============================================================================ // Types // ============================================================================ interface UploadStatus { fileName: string; status: 'uploading' | 'processing' | 'success' | 'error'; message?: string; progress?: number; // 0-100 } interface DocumentUploadModalProps { isOpen: boolean; onClose: () => void; workspace: string; onUploadComplete: () => void; } // ============================================================================ // Helpers (pure functions) // ============================================================================ function getFileExtension(filename: string): string { return filename.toLowerCase().split('.').pop() || ''; } function extractTextFromHtml(htmlContent: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); doc .querySelectorAll('script, style, noscript, iframe, svg, template, meta, link') .forEach((el) => el.remove()); const rawText = doc.body?.textContent || doc.documentElement?.textContent || ''; return rawText .split('\n') .map((line) => line.trim().replace(/\s+/g, ' ')) .filter((line) => line.length > 0) .join('\n'); } function updateFileStatus( prev: UploadStatus[], fileName: string, update: Partial<UploadStatus> ): UploadStatus[] { return prev.map((s) => s.fileName === fileName ? { ...s, ...update } : s ); } // ============================================================================ // Sub-components // ============================================================================ /** * Статус-иконка для элемента загрузки. * Объявлен как компонент вне render. */ function StatusIcon({ status }: { status: UploadStatus['status'] }) { if (status === 'uploading' || status === 'processing') { return ( <Loader2 className="w-5 h-5 text-accent-lime animate-spin shrink-0" aria-hidden="true" /> ); } if (status === 'success') { return ( <CheckCircle className="w-5 h-5 text-accent-lime shrink-0" aria-hidden="true" /> ); } return ( <AlertCircle className="w-5 h-5 text-status-error shrink-0" aria-hidden="true" /> ); } /** * Иконка файла по расширению. * Объявлен как компонент вне render для соответствия React rules. */ function FileIconByExt({ ext }: { ext: string }) { if (IMAGE_EXTENSIONS.has(ext)) { return <ImageIcon className="w-4 h-4 text-text-muted" aria-hidden="true" />; } if (CODE_EXTENSIONS.includes(ext)) { return <Code className="w-4 h-4 text-text-muted" aria-hidden="true" />; } if (DATA_EXTENSIONS.includes(ext)) { return <Database className="w-4 h-4 text-text-muted" aria-hidden="true" />; } return <FileText className="w-4 h-4 text-text-muted" aria-hidden="true" />; } /** * Элемент статуса загрузки файла. */ function UploadStatusItem({ status }: { status: UploadStatus }) { const ext = getFileExtension(status.fileName); return ( <div className={cn( 'flex items-start gap-3 p-3 rounded-lg border transition-all', status.status === 'success' && 'bg-accent-lime-soft border-border-accent', status.status === 'error' && 'bg-status-error-soft border-status-error/30', (status.status === 'uploading' || status.status === 'processing') && 'bg-(--glass-bg-default) border-border-subtle' )} > <div className="flex items-center gap-3 flex-1 min-w-0"> <StatusIcon status={status.status} /> <div className="flex items-center gap-2 flex-1 min-w-0"> <div className="w-8 h-8 rounded-lg bg-(--glass-bg-strong) flex items-center justify-center shrink-0"> <FileIconByExt ext={ext} /> </div> <div className="flex-1 min-w-0"> <p className="text-sm font-medium text-text-primary truncate"> {status.fileName} </p> {status.message && ( <p className="text-xs text-text-muted truncate mt-0.5"> {status.message} </p> )} {status.progress !== undefined && ( <div className="mt-1.5"> <div className="w-full h-1 bg-(--glass-bg-strong) rounded-full overflow-hidden"> <div className="h-full gradient-primary transition-all duration-300" style={{ width: `${status.progress}%` }} /> </div> </div> )} </div> </div> </div> </div> ); } // ============================================================================ // Main Component // ============================================================================ export function DocumentUploadModal({ isOpen, onClose, workspace, onUploadComplete, }: DocumentUploadModalProps) { const { toast } = useAppStore(); const [uploadStatuses, setUploadStatuses] = useState<UploadStatus[]>([]); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef<HTMLInputElement>(null); const dragCounter = useRef(0); // ========================================================================== // File processing logic // ========================================================================== const handleFileSelect = useCallback( async (files: FileList | null) => { if (!files || files.length === 0) return; const fileArray = Array.from(files); // Валидация файлов const validatedFiles: Array<{ file: File; error?: string }> = fileArray.map( (file) => { if (file.size > MAX_FILE_SIZE) { return { file, error: `Файл слишком большой (макс. ${MAX_FILE_SIZE / 1024 / 1024}MB)`, }; } const ext = getFileExtension(file.name); if (!ALLOWED_EXTENSIONS.includes(ext)) { return { file, error: `Неподдерживаемый формат: .${ext || 'unknown'}`, }; } if (ext === 'doc') { return { file, error: 'Формат DOC (Word 97-2003) не поддерживается. Сохраните как DOCX или PDF.', }; } return { file }; } ); // Создаём начальные статусы const newStatuses: UploadStatus[] = validatedFiles.map(({ file, error }) => ({ fileName: file.name, status: error ? 'error' : 'uploading', message: error, })); setUploadStatuses((prev) => [...prev, ...newStatuses]); // Обрабатываем только валидные файлы const validFiles = validatedFiles.filter((v) => !v.error).map((v) => v.file); let successCount = 0; let errorCount = validatedFiles.filter((v) => v.error).length; for (const file of validFiles) { try { const ext = getFileExtension(file.name); let content: string; let effectiveDocType: string; // PDF: извлекаем текст или используем OCR для сканов if (isPdfFile(file) || ext === 'pdf') { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'processing', message: 'Извлечение текста из PDF...', progress: 20, }) ); content = await extractTextFromPdf(file) ?? ''; if (!content.trim()) { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { message: 'PDF-скан: распознавание через AI...', progress: 40, }) ); content = await extractTextWithOCR(file, (msg) => { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { message: msg, progress: 60 }) ); }) ?? ''; } effectiveDocType = 'pdf'; } // Изображения: OCR через DeepSeek-OCR-2 else if (IMAGE_EXTENSIONS.has(ext) || file.type.startsWith('image/')) { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'processing', message: 'Распознавание текста через AI (10-30 сек)...', progress: 30, }) ); content = await extractTextWithOCR(file, (msg) => { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { message: msg, progress: 60 }) ); }) ?? ''; if (!content.trim()) { throw new Error('Изображение не содержит распознаваемого текста'); } effectiveDocType = 'markdown'; } // DOCX: через mammoth.js else if (ext === 'docx') { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'processing', message: 'Извлечение текста из DOCX...', progress: 40, }) ); const arrayBuffer = await file.arrayBuffer(); const result = await mammoth.extractRawText({ arrayBuffer }); content = result.value ?? ''; if (!content.trim()) { throw new Error( 'DOCX не содержит текста (возможно, только изображения или таблицы).' ); } effectiveDocType = 'docx'; if (result.messages && result.messages.length > 0) { console.warn( `[DOCX] Warnings for ${file.name}:`, result.messages.map((m) => m.message) ); } } // HTML: извлечение через DOMParser else if (ext === 'html' || ext === 'htm') { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'processing', message: 'Извлечение текста из HTML...', progress: 50, }) ); const htmlContent = await file.text(); content = extractTextFromHtml(htmlContent) ?? ''; if (!content.trim()) { throw new Error('HTML не содержит читаемого текста'); } effectiveDocType = 'html'; } // Остальные (txt, md, код, конфиги, json, xml, csv, скрипты) else { setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'processing', message: 'Чтение файла...', progress: 60, }) ); content = await file.text() ?? ''; effectiveDocType = mimeToDocType(file.type, file.name); if (!content.trim()) { throw new Error('Файл пустой'); } } // ✅ Проверка что content валидный перед использованием if (!content || typeof content !== 'string') { throw new Error('Не удалось извлечь текст из файла'); } // Проверка размера текста if (content.length > MAX_TEXT_CHARS) { throw new Error( `Документ слишком большой: ${(content.length / 1_000_000).toFixed(1)}M символов (макс. ${MAX_TEXT_CHARS / 1_000_000}M)` ); } const charCount = content.length; setUploadStatuses((prev) => updateFileStatus(prev, file.name, { message: `Загрузка (${effectiveDocType}, ${charCount.toLocaleString()} симв.)...`, progress: 80, }) ); await ingestDocument({ content, source: file.name, doc_type: effectiveDocType, workspace_id: workspace, title: file.name, }); setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'success', message: `${charCount.toLocaleString()} символов загружено`, progress: 100, }) ); successCount++; } catch (err) { const message = err instanceof Error ? err.message : 'Ошибка загрузки'; setUploadStatuses((prev) => updateFileStatus(prev, file.name, { status: 'error', message, progress: undefined, }) ); errorCount++; } } // Итоговое уведомление if (successCount > 0 && errorCount === 0) { toast(`Загружено ${successCount} файл(ов)`, 'success'); onUploadComplete(); } else if (successCount > 0 && errorCount > 0) { toast(`Загружено ${successCount}, ошибок: ${errorCount}`, 'warning'); onUploadComplete(); } else if (errorCount > 0) { toast('Не удалось загрузить файлы', 'error'); } if (fileInputRef.current) { fileInputRef.current.value = ''; } }, [workspace, toast, onUploadComplete] ); // ========================================================================== // Drag & Drop handlers // ========================================================================== const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragging(true); } }, []); const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); dragCounter.current--; if (dragCounter.current === 0) { setIsDragging(false); } }, []); const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = useCallback( (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounter.current = 0; if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { handleFileSelect(e.dataTransfer.files); } }, [handleFileSelect] ); const handleClearCompleted = useCallback(() => { setUploadStatuses((prev) => prev.filter((s) => s.status !== 'success')); }, []); const handleClearAll = useCallback(() => { setUploadStatuses([]); }, []); // ========================================================================== // Render // ========================================================================== if (!isOpen) return null; const completedCount = uploadStatuses.filter((s) => s.status === 'success').length; const hasCompleted = completedCount > 0; const hasAnyStatuses = uploadStatuses.length > 0; return ( <> {/* Backdrop */} <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 fadein" onClick={onClose} aria-hidden="true" /> {/* Modal */} <div className="fixed top-[15%] left-1/2 -translate-x-1/2 w-full max-w-2xl z-50 fadein"> <div className="glass-strong rounded-2xl shadow-xl border border-border-subtle max-h-[80vh] flex flex-col"> {/* Header */} <div className="flex items-center justify-between p-4 border-b border-border-subtle"> <div className="flex items-center gap-3"> <div className="w-10 h-10 rounded-lg gradient-primary flex items-center justify-center shadow-lime"> <Upload className="w-5 h-5 text-text-on-accent" aria-hidden="true" /> </div> <div> <h2 className="text-lg font-semibold text-text-primary"> Загрузка документов в RAG </h2> <p className="text-xs text-text-muted"> Поддерживаются документы, изображения и код </p> </div> </div> <button onClick={onClose} className="btn btn-ghost btn-icon btn-sm" aria-label="Закрыть" > <X className="w-5 h-5" /> </button> </div> {/* Content */} <div className="p-6 space-y-4 overflow-y-auto flex-1"> {/* Drop zone */} <div onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} className={cn( 'border-2 border-dashed rounded-xl p-8 text-center transition-all', isDragging ? 'border-accent-lime bg-accent-lime-soft/30' : 'border-border-default hover:border-accent-lime/50' )} > {isDragging ? ( <div className="space-y-3"> <div className="w-16 h-16 rounded-full gradient-primary flex items-center justify-center mx-auto shadow-lime"> <Upload className="w-8 h-8 text-text-on-accent" aria-hidden="true" /> </div> <p className="text-text-primary font-semibold"> Отпустите файлы для загрузки </p> </div> ) : ( <> <div className="w-16 h-16 rounded-full bg-(--glass-bg-default) flex items-center justify-center mx-auto mb-3"> <Upload className="w-8 h-8 text-text-muted" aria-hidden="true" /> </div> <p className="text-text-primary font-medium mb-2"> Перетащите файлы сюда </p> <p className="text-text-muted text-sm mb-4">или</p> <input ref={fileInputRef} type="file" multiple accept={FILE_INPUT_ACCEPT} onChange={(e) => handleFileSelect(e.target.files)} className="hidden" aria-hidden="true" /> <button onClick={() => fileInputRef.current?.click()} className="btn btn-primary" > Выбрать файлы </button> <div className="text-xs text-text-muted mt-4 space-y-1.5 leading-relaxed"> <div className="flex items-center justify-center gap-2"> <FileText className="w-3 h-3 text-accent-lime" aria-hidden="true" /> <span> <span className="font-medium text-text-secondary">Документы:</span>{' '} TXT, MD, PDF, DOCX </span> </div> <div className="flex items-center justify-center gap-2"> <ImageIcon className="w-3 h-3 text-accent-cyan" aria-hidden="true" /> <span> <span className="font-medium text-text-secondary">Изображения:</span>{' '} PNG, JPG, WEBP{' '} <span className="text-accent-lime">(OCR через AI)</span> </span> </div> <div className="flex items-center justify-center gap-2"> <Code className="w-3 h-3 text-accent-amber" aria-hidden="true" /> <span> <span className="font-medium text-text-secondary">Код:</span> TS, JS, PY, RS, GO, JAVA, C/C++ </span> </div> <div className="flex items-center justify-center gap-2"> <Database className="w-3 h-3 text-accent-lime" aria-hidden="true" /> <span> <span className="font-medium text-text-secondary">Данные:</span> JSON, XML, CSV </span> </div> <div className="pt-2 text-text-muted/70"> Максимальный размер: 50MB на файл </div> </div> </> )} </div> {/* Upload statuses */} {hasAnyStatuses && ( <div className="space-y-3"> <div className="flex items-center justify-between"> <h3 className="text-sm font-semibold text-text-primary flex items-center gap-2"> <Sparkles className="w-4 h-4 text-accent-lime" aria-hidden="true" /> Статус загрузки ({uploadStatuses.length}) {hasCompleted && ( <span className="text-xs font-normal text-accent-lime"> • {completedCount} успешно </span> )} </h3> <div className="flex items-center gap-2"> {hasCompleted && ( <button onClick={handleClearCompleted} className="btn btn-ghost btn-sm text-xs" > Очистить завершённые </button> )} <button onClick={handleClearAll} className="btn btn-ghost btn-sm text-xs text-status-error hover:bg-status-error-soft" > Очистить всё </button> </div> </div> <div className="space-y-2 max-h-64 overflow-y-auto"> {uploadStatuses.map((status, idx) => ( <UploadStatusItem key={`${status.fileName}-${idx}`} status={status} /> ))} </div> </div> )} </div> </div> </div> </> ); }