/** * 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} */ _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 = `
Tệp PyTorch (.pt) không được hỗ trợ trực tiếp

Tệp "${this._escapeHtml(fileName)}" là định dạng PyTorch. Vui lòng convert sang .onnx hoặc .safetensors trước khi upload.


Convert PyTorch → ONNX:

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"}}
)

Convert PyTorch → SafeTensors:

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")

Cài đặt: pip install torch onnx safetensors

`; 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 = ` ${this._escapeHtml(message)} `; 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 = ` ${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;