model-explorer / js /ui /fileUpload.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* 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;