Spaces:
Running
Running
| /** | |
| * FileUploadHandler - Handles ONNX file uploads via button click and drag-and-drop | |
| * Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6 | |
| */ | |
| class FileUploadHandler { | |
| /** | |
| * @param {string} [uploadBtnId='uploadBtn'] - ID of the upload button | |
| * @param {string} [fileInputId='fileInput'] - ID of the hidden file input | |
| * @param {string} [dropZoneId='app'] - ID of the drag-and-drop zone | |
| */ | |
| constructor(uploadBtnId = 'uploadBtn', fileInputId = 'fileInput', dropZoneId = 'app') { | |
| this._uploadBtn = document.getElementById(uploadBtnId); | |
| this._fileInput = document.getElementById(fileInputId); | |
| this._dropZone = document.getElementById(dropZoneId); | |
| this._errorContainer = document.getElementById('errorContainer'); | |
| this._dragCounter = 0; // track nested dragenter/dragleave | |
| if (!this._uploadBtn) { | |
| console.warn(`[FileUploadHandler] Upload button #${uploadBtnId} not found`); | |
| } | |
| if (!this._fileInput) { | |
| console.warn(`[FileUploadHandler] File input #${fileInputId} not found`); | |
| } | |
| this._bindEvents(); | |
| } | |
| // ─── Private ────────────────────────────────────────────────────────────── | |
| _bindEvents() { | |
| // Button click → open file dialog | |
| if (this._uploadBtn) { | |
| this._uploadBtn.addEventListener('click', () => { | |
| if (this._fileInput) this._fileInput.click(); | |
| }); | |
| } | |
| // File input change | |
| if (this._fileInput) { | |
| this._fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files && e.target.files[0]; | |
| if (file) this._processFile(file); | |
| // Reset so the same file can be re-selected | |
| e.target.value = ''; | |
| }); | |
| } | |
| // Drag-and-drop on drop zone | |
| if (this._dropZone) { | |
| this._dropZone.addEventListener('dragenter', (e) => this._onDragEnter(e)); | |
| this._dropZone.addEventListener('dragleave', (e) => this._onDragLeave(e)); | |
| this._dropZone.addEventListener('dragover', (e) => this._onDragOver(e)); | |
| this._dropZone.addEventListener('drop', (e) => this._onDrop(e)); | |
| } | |
| } | |
| _onDragEnter(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this._dragCounter++; | |
| if (this._dropZone) this._dropZone.classList.add('drag-over'); | |
| } | |
| _onDragLeave(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this._dragCounter--; | |
| if (this._dragCounter <= 0) { | |
| this._dragCounter = 0; | |
| if (this._dropZone) this._dropZone.classList.remove('drag-over'); | |
| } | |
| } | |
| _onDragOver(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; | |
| } | |
| _onDrop(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this._dragCounter = 0; | |
| if (this._dropZone) this._dropZone.classList.remove('drag-over'); | |
| const files = e.dataTransfer && e.dataTransfer.files; | |
| if (files && files.length > 0) { | |
| this._processFile(files[0]); | |
| } | |
| } | |
| /** | |
| * Validate and process a File object. | |
| * @param {File} file | |
| */ | |
| async _processFile(file) { | |
| // Validate extension | |
| const name = file.name || ''; | |
| const hasValidExt = CONFIG.FILE.ALLOWED_EXTENSIONS.some(ext => | |
| name.toLowerCase().endsWith(ext) | |
| ); | |
| if (!hasValidExt) { | |
| // Detect PyTorch .pt/.pth files and show conversion guide | |
| if (name.toLowerCase().endsWith('.pt') || name.toLowerCase().endsWith('.pth')) { | |
| this._showPtConversionGuide(name); | |
| return; | |
| } | |
| const allowed = CONFIG.FILE.ALLOWED_EXTENSIONS.join(', '); | |
| this._showError( | |
| `Invalid file type "${name}". Only ${allowed} files are supported.` | |
| ); | |
| return; | |
| } | |
| // Check for conversion-guide formats (e.g. .h5, .keras, .pb, .mlmodel, etc.) | |
| if (window.ConversionGuideManager) { | |
| const cgm = new ConversionGuideManager(); | |
| if (cgm.isConversionFormat(name)) { | |
| cgm.showGuide(name); | |
| return; // Don't emit FILE_UPLOADED | |
| } | |
| } | |
| // Validate empty file | |
| if (file.size === 0) { | |
| this._showError('The file is empty.'); | |
| return; | |
| } | |
| // Show progress indicator | |
| this._showProgress(`Reading "${name}"…`); | |
| try { | |
| // Delegate reading + validation to ModelLoader | |
| const loader = window.ModelLoader ? new window.ModelLoader() : null; | |
| let result; | |
| if (loader) { | |
| result = await loader.handleFileUpload(file); | |
| } else { | |
| // Fallback: read directly | |
| const data = await this._readFileAsArrayBuffer(file); | |
| result = { success: true, data }; | |
| } | |
| if (!result.success) { | |
| this._showError(result.error || CONFIG.ERRORS.UPLOAD_ERROR); | |
| return; | |
| } | |
| // Clear progress | |
| this._clearMessages(); | |
| // Emit FILE_UPLOADED event | |
| if (window.EventBus) { | |
| window.EventBus.emit(CONFIG.EVENTS.FILE_UPLOADED, { | |
| file, | |
| data: result.data, | |
| fileName: name | |
| }); | |
| } | |
| // Update StateManager loading state if available | |
| if (window.StateManager) { | |
| window.StateManager.setLoading(false); | |
| } | |
| } catch (err) { | |
| console.error('[FileUploadHandler] Upload error:', err); | |
| this._showError(err.message || CONFIG.ERRORS.UPLOAD_ERROR); | |
| } | |
| } | |
| /** | |
| * Read a File as ArrayBuffer. | |
| * @param {File} file | |
| * @returns {Promise<ArrayBuffer>} | |
| */ | |
| _readFileAsArrayBuffer(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => resolve(e.target.result); | |
| reader.onerror = () => reject(new Error(reader.error ? reader.error.message : 'FileReader error')); | |
| reader.readAsArrayBuffer(file); | |
| }); | |
| } | |
| /** | |
| * Show a conversion guide when user uploads a PyTorch .pt/.pth file. | |
| * @param {string} fileName | |
| */ | |
| _showPtConversionGuide(fileName) { | |
| if (!this._errorContainer) return; | |
| this._errorContainer.innerHTML = ''; | |
| const div = document.createElement('div'); | |
| div.className = 'alert alert-warning alert-dismissible fade show'; | |
| div.setAttribute('role', 'alert'); | |
| div.innerHTML = ` | |
| <div class="d-flex align-items-start"> | |
| <i class="fas fa-exchange-alt me-3 mt-1 fs-4 text-warning"></i> | |
| <div class="flex-grow-1"> | |
| <h6 class="alert-heading mb-2"> | |
| <i class="fas fa-file-code me-1"></i> | |
| Tệp PyTorch (.pt) không được hỗ trợ trực tiếp | |
| </h6> | |
| <p class="mb-2"> | |
| Tệp <strong>"${this._escapeHtml(fileName)}"</strong> là định dạng PyTorch. | |
| Vui lòng convert sang <strong>.onnx</strong> hoặc <strong>.safetensors</strong> trước khi upload. | |
| </p> | |
| <hr class="my-2"> | |
| <p class="mb-1 fw-bold"><i class="fas fa-code me-1"></i> Convert PyTorch → ONNX:</p> | |
| <pre class="bg-dark text-light p-2 rounded small mb-2" style="white-space:pre-wrap;"><code>import torch | |
| model = torch.load("model.pt", map_location="cpu") | |
| model.eval() | |
| # Tạo dummy input phù hợp với model | |
| dummy_input = torch.randn(1, 3, 224, 224) | |
| torch.onnx.export( | |
| model, dummy_input, "model.onnx", | |
| input_names=["input"], | |
| output_names=["output"], | |
| dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} | |
| )</code></pre> | |
| <p class="mb-1 fw-bold"><i class="fas fa-code me-1"></i> Convert PyTorch → SafeTensors:</p> | |
| <pre class="bg-dark text-light p-2 rounded small mb-2" style="white-space:pre-wrap;"><code>from safetensors.torch import save_file | |
| import torch | |
| state_dict = torch.load("model.pt", map_location="cpu") | |
| # Nếu là model đầy đủ (không phải state_dict): | |
| # state_dict = model.state_dict() | |
| save_file(state_dict, "model.safetensors")</code></pre> | |
| <p class="mb-0 text-muted small"> | |
| <i class="fas fa-info-circle me-1"></i> | |
| Cài đặt: <code>pip install torch onnx safetensors</code> | |
| </p> | |
| </div> | |
| </div> | |
| <button type="button" class="btn-close" aria-label="Close" | |
| onclick="this.closest('.alert').remove()"></button> | |
| `; | |
| this._errorContainer.appendChild(div); | |
| } | |
| /** | |
| * Display an error message in #errorContainer. | |
| * @param {string} message | |
| */ | |
| _showError(message) { | |
| if (window.ErrorDisplay && window.ErrorDisplay.show) { | |
| window.ErrorDisplay.show(message, 'error'); | |
| return; | |
| } | |
| if (!this._errorContainer) return; | |
| const div = document.createElement('div'); | |
| div.className = 'alert alert-danger alert-dismissible fade show'; | |
| div.setAttribute('role', 'alert'); | |
| div.innerHTML = ` | |
| <i class="fas fa-exclamation-circle me-2"></i>${this._escapeHtml(message)} | |
| <button type="button" class="btn-close" aria-label="Close" | |
| onclick="this.closest('.alert').remove()"></button> | |
| `; | |
| this._errorContainer.innerHTML = ''; | |
| this._errorContainer.appendChild(div); | |
| // Auto-dismiss | |
| setTimeout(() => { | |
| if (div.parentElement) div.remove(); | |
| }, CONFIG.UI.ERROR_DISPLAY_DURATION); | |
| } | |
| /** | |
| * Display a progress/info message in #errorContainer. | |
| * @param {string} message | |
| */ | |
| _showProgress(message) { | |
| if (!this._errorContainer) return; | |
| const div = document.createElement('div'); | |
| div.className = 'alert alert-info d-flex align-items-center'; | |
| div.setAttribute('role', 'status'); | |
| div.id = 'fileUploadProgress'; | |
| div.innerHTML = ` | |
| <span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span> | |
| ${this._escapeHtml(message)} | |
| `; | |
| this._errorContainer.innerHTML = ''; | |
| this._errorContainer.appendChild(div); | |
| } | |
| /** | |
| * Clear any progress/error messages. | |
| */ | |
| _clearMessages() { | |
| if (this._errorContainer) this._errorContainer.innerHTML = ''; | |
| } | |
| /** | |
| * Escape HTML to prevent XSS. | |
| * @param {string} str | |
| * @returns {string} | |
| */ | |
| _escapeHtml(str) { | |
| const div = document.createElement('div'); | |
| div.appendChild(document.createTextNode(str)); | |
| return div.innerHTML; | |
| } | |
| // ─── Public API ─────────────────────────────────────────────────────────── | |
| /** | |
| * Programmatically trigger the file dialog. | |
| */ | |
| openFileDialog() { | |
| if (this._fileInput) this._fileInput.click(); | |
| } | |
| /** | |
| * Destroy the handler and remove event listeners. | |
| * (Useful for cleanup in SPA-style navigation.) | |
| */ | |
| destroy() { | |
| // Listeners are attached to DOM elements; removing the elements cleans them up. | |
| // For explicit cleanup, re-bind with AbortController in future refactors. | |
| } | |
| } | |
| // Export as global for browser usage | |
| window.FileUploadHandler = FileUploadHandler; | |