Spaces:
Running
Running
| // GenerationManager β Shared module for text-to-image and html-to-image tools | |
| // Implements: #3 (deduplication), #18 (CSS classes), #1 (SSE progress), #6 (cancel), #9 (token counter) | |
| class GenerationManager { | |
| /** | |
| * @param {Object} config | |
| * @param {string} config.mode - 'text' or 'html' | |
| * @param {string} config.inputId - ID of the textarea input | |
| * @param {string} config.loadingId - ID of the loading container | |
| * @param {string} config.errorId - ID of the error alert | |
| * @param {string} config.resultId - ID of the result section | |
| * @param {string} config.buttonContainerId - ID of the button container | |
| * @param {string} config.generateEndpoint - API endpoint for generation | |
| * @param {string} config.inputField - JSON field name for the input ('text' or 'html') | |
| * @param {boolean} config.useSSE - Whether to use SSE endpoint for generation | |
| * @param {boolean} config.hasPreview - Whether this mode has a preview feature | |
| */ | |
| constructor(config) { | |
| this.mode = config.mode; | |
| this.inputId = config.inputId; | |
| this.loadingId = config.loadingId; | |
| this.errorId = config.errorId; | |
| this.resultId = config.resultId; | |
| this.buttonContainerId = config.buttonContainerId; | |
| this.generateEndpoint = config.generateEndpoint; | |
| this.inputField = config.inputField; | |
| this.useSSE = config.useSSE || false; | |
| this.hasPreview = config.hasPreview || false; | |
| // State | |
| this.lastGeneratedHtmlFile = null; | |
| this.lastGeneratedSettings = null; | |
| this.currentVersionScreenshots = null; | |
| this.previousVersionScreenshots = null; | |
| this.abortController = null; | |
| this.currentOperationId = null; | |
| this.etaIntervalId = null; | |
| this.etaSecondsRemaining = 0; | |
| // Initialize | |
| this._initSettingsWatcher(); | |
| this._initTokenCounter(); | |
| } | |
| // βββ Token Counter (#9) βββ | |
| _initTokenCounter() { | |
| const input = document.getElementById(this.inputId); | |
| if (!input) return; | |
| // Create counter element if it doesn't exist | |
| let counter = document.getElementById(`${this.mode}TokenCounter`); | |
| if (!counter) { | |
| counter = document.createElement('div'); | |
| counter.id = `${this.mode}TokenCounter`; | |
| counter.className = 'token-counter'; | |
| input.parentNode.appendChild(counter); | |
| } | |
| const updateCounter = () => { | |
| const text = input.value; | |
| const chars = text.length; | |
| const words = text.trim() ? text.trim().split(/\s+/).length : 0; | |
| const estimatedTokens = Math.ceil(chars / 4); | |
| const maxTokens = 100000; | |
| const pct = (estimatedTokens / maxTokens) * 100; | |
| counter.textContent = `${chars.toLocaleString()} chars Β· ${words.toLocaleString()} words Β· ~${estimatedTokens.toLocaleString()} tokens`; | |
| counter.classList.remove('token-counter--warning', 'token-counter--danger'); | |
| if (pct > 90) { | |
| counter.classList.add('token-counter--danger'); | |
| } else if (pct > 70) { | |
| counter.classList.add('token-counter--warning'); | |
| } | |
| }; | |
| input.addEventListener('input', updateCounter); | |
| updateCounter(); | |
| } | |
| _getSettings() { | |
| // Find the optional verification toggle (only exists in text mode currently) | |
| const verificationToggle = document.getElementById(`${this.mode}EnableVerification`); | |
| // Find the optional model selector (only exists in text mode currently) | |
| const modelSelector = document.getElementById(`${this.mode}ModelChoice`); | |
| return { | |
| zoom: parseFloat(document.getElementById(`${this.mode}Zoom`)?.value) || 2.1, | |
| overlap: parseInt(document.getElementById(`${this.mode}Overlap`)?.value) || 20, | |
| viewport_width: parseInt(document.getElementById(`${this.mode}ViewportWidth`)?.value) || 1920, | |
| viewport_height: parseInt(document.getElementById(`${this.mode}ViewportHeight`)?.value) || 1080, | |
| max_screenshots: parseInt(document.getElementById(`${this.mode}MaxScreenshots`)?.value) || 50, | |
| enable_verification: verificationToggle ? verificationToggle.checked : true, | |
| model_choice: modelSelector ? modelSelector.value : 'default' | |
| }; | |
| } | |
| _hasSettingsChanged() { | |
| if (!this.lastGeneratedSettings) return false; | |
| const current = this._getSettings(); | |
| return JSON.stringify(current) !== JSON.stringify(this.lastGeneratedSettings); | |
| } | |
| _initSettingsWatcher() { | |
| const baseInputs = [ | |
| 'Zoom', 'Overlap', | |
| 'ViewportWidth', 'ViewportHeight', 'MaxScreenshots' | |
| ]; | |
| baseInputs.forEach(baseId => { | |
| const id = `${this.mode}${baseId}`; | |
| const input = document.getElementById(id); | |
| if (input) { | |
| input.addEventListener('input', () => { | |
| const regenerateBtn = document.getElementById(`${this.mode}RegenerateBtn`); | |
| if (regenerateBtn && regenerateBtn.style.display !== 'none') { | |
| const changed = this._hasSettingsChanged(); | |
| regenerateBtn.disabled = !changed; | |
| regenerateBtn.classList.toggle('btn--disabled', !changed); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| // βββ Button State (#18 β CSS classes) βββ | |
| _setButtonDisabled(btn, disabled) { | |
| if (!btn) return; | |
| btn.disabled = disabled; | |
| btn.classList.toggle('btn--disabled', disabled); | |
| } | |
| _setButtonLoading(btn, loading) { | |
| if (!btn) return; | |
| btn.classList.toggle('btn--loading', loading); | |
| btn.disabled = loading; | |
| } | |
| updateButtonState(state) { | |
| const container = document.getElementById(this.buttonContainerId); | |
| if (!container) return; | |
| const generateBtn = container.querySelector('.btn-primary'); | |
| let regenerateBtn = document.getElementById(`${this.mode}RegenerateBtn`); | |
| if (!regenerateBtn) { | |
| regenerateBtn = document.createElement('button'); | |
| regenerateBtn.id = `${this.mode}RegenerateBtn`; | |
| regenerateBtn.className = 'btn btn-primary btn--disabled'; | |
| regenerateBtn.style.display = 'none'; | |
| regenerateBtn.disabled = true; | |
| regenerateBtn.innerHTML = ` | |
| <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> | |
| </svg> | |
| Regenerate with New Settings | |
| `; | |
| regenerateBtn.onclick = () => this.regenerate(); | |
| container.appendChild(regenerateBtn); | |
| } | |
| if (state === 'generated') { | |
| if (generateBtn) generateBtn.style.display = 'none'; | |
| regenerateBtn.style.display = 'inline-flex'; | |
| this._setButtonDisabled(regenerateBtn, true); | |
| } else if (state === 'initial') { | |
| if (generateBtn) generateBtn.style.display = 'inline-flex'; | |
| regenerateBtn.style.display = 'none'; | |
| } | |
| } | |
| // βββ Cancel (#6) βββ | |
| _showCancelButton() { | |
| const loading = document.getElementById(this.loadingId); | |
| if (!loading) return; | |
| let cancelBtn = document.getElementById(`${this.mode}CancelBtn`); | |
| if (!cancelBtn) { | |
| cancelBtn = document.createElement('button'); | |
| cancelBtn.id = `${this.mode}CancelBtn`; | |
| cancelBtn.className = 'btn btn-secondary'; | |
| cancelBtn.style.marginTop = '12px'; | |
| cancelBtn.innerHTML = ` | |
| <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| Cancel | |
| `; | |
| cancelBtn.onclick = () => this.cancelGeneration(); | |
| loading.appendChild(cancelBtn); | |
| } | |
| cancelBtn.style.display = 'inline-flex'; | |
| } | |
| _hideCancelButton() { | |
| const cancelBtn = document.getElementById(`${this.mode}CancelBtn`); | |
| if (cancelBtn) cancelBtn.style.display = 'none'; | |
| } | |
| async cancelGeneration() { | |
| // Abort the fetch request | |
| if (this.abortController) { | |
| this.abortController.abort(); | |
| this.abortController = null; | |
| } | |
| // Cancel server-side operation | |
| if (this.currentOperationId) { | |
| try { | |
| await fetch(`/cancel/${this.currentOperationId}`, { method: 'POST' }); | |
| } catch (e) { | |
| console.log('Cancel request failed (operation may have finished):', e); | |
| } | |
| this.currentOperationId = null; | |
| } | |
| this._hideCancelButton(); | |
| const loading = document.getElementById(this.loadingId); | |
| if (loading) loading.classList.remove('active'); | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.info('Cancelled', 'Generation was cancelled'); | |
| } | |
| // Re-enable generate button | |
| const container = document.getElementById(this.buttonContainerId); | |
| if (container) { | |
| const generateBtn = container.querySelector('.btn-primary'); | |
| this._setButtonDisabled(generateBtn, false); | |
| this._setButtonLoading(generateBtn, false); | |
| } | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.reset(); | |
| } | |
| } | |
| // βββ Progress (#1 β real SSE) βββ | |
| _updateProgress(message, progress) { | |
| const loadingMsg = document.getElementById('loadingMessage'); | |
| if (loadingMsg) loadingMsg.textContent = message; | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.updateProgress(progress); | |
| } | |
| } | |
| // βββ ETA Countdown βββ | |
| _startEtaCountdown(seconds) { | |
| this._stopEtaCountdown(); | |
| this.etaSecondsRemaining = seconds; | |
| const etaText = document.getElementById('etaText'); | |
| if (!etaText) return; | |
| etaText.style.display = 'block'; | |
| this._updateEtaDisplay(); | |
| this.etaIntervalId = setInterval(() => { | |
| if (this.etaSecondsRemaining > 0) { | |
| this.etaSecondsRemaining--; | |
| } | |
| this._updateEtaDisplay(); | |
| }, 1000); | |
| } | |
| _updateEtaDisplay() { | |
| const etaText = document.getElementById('etaText'); | |
| if (!etaText) return; | |
| if (this.etaSecondsRemaining <= 0) { | |
| etaText.textContent = "Almost done... applying finishing touches"; | |
| return; | |
| } | |
| const m = Math.floor(this.etaSecondsRemaining / 60); | |
| const s = Math.floor(this.etaSecondsRemaining % 60); | |
| if (m > 0) { | |
| etaText.textContent = `Estimated time: ${m}m ${s}s`; | |
| } else { | |
| etaText.textContent = `Estimated time: ${s}s`; | |
| } | |
| } | |
| _stopEtaCountdown() { | |
| if (this.etaIntervalId) { | |
| clearInterval(this.etaIntervalId); | |
| this.etaIntervalId = null; | |
| } | |
| const etaText = document.getElementById('etaText'); | |
| if (etaText) { | |
| etaText.textContent = ''; | |
| etaText.style.display = 'none'; | |
| } | |
| } | |
| // βββ Generate βββ | |
| async generate() { | |
| const input = document.getElementById(this.inputId); | |
| const inputValue = input?.value.trim() || ''; | |
| const loading = document.getElementById(this.loadingId); | |
| const error = document.getElementById(this.errorId); | |
| const result = document.getElementById(this.resultId); | |
| const container = document.getElementById(this.buttonContainerId); | |
| const generateBtn = container?.querySelector('.btn-primary'); | |
| // #4 β Use textContent for error display (prevents XSS) | |
| error.style.display = 'none'; | |
| result.classList.remove('active'); | |
| if (!inputValue) { | |
| error.textContent = this.mode === 'text' ? 'Please enter some text content' : 'Please provide HTML content'; | |
| error.style.display = 'block'; | |
| return; | |
| } | |
| // Build settings | |
| const baseSettings = { ...this._getSettings() }; | |
| const payload = { | |
| [this.inputField]: inputValue, | |
| ...baseSettings | |
| }; | |
| if (this.mode === 'text') { | |
| payload.use_cache = true; | |
| payload.beautify_html = true; | |
| } | |
| // Disable button (#18 β CSS classes) | |
| this._setButtonDisabled(generateBtn, true); | |
| this._setButtonLoading(generateBtn, true); | |
| loading.classList.add('active'); | |
| this._showCancelButton(); | |
| // Start progress | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.start([ | |
| this.mode === 'text' ? 'Sending request to AI...' : 'Processing HTML...', | |
| 'Creating screenshots...', | |
| 'Finalizing...' | |
| ]); | |
| } | |
| // #6 β AbortController for cancellation | |
| this.abortController = new AbortController(); | |
| try { | |
| if (this.useSSE && this.mode === 'text') { | |
| await this._generateWithSSE(payload); | |
| } else { | |
| await this._generateWithFetch(payload); | |
| } | |
| } catch (err) { | |
| if (err.name === 'AbortError') { | |
| // User cancelled β already handled | |
| return; | |
| } | |
| error.textContent = 'Error: ' + err.message; | |
| error.style.display = 'block'; | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.error('Error', err.message); | |
| } | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.reset(); | |
| } | |
| } finally { | |
| this._setButtonDisabled(generateBtn, false); | |
| this._setButtonLoading(generateBtn, false); | |
| loading.classList.remove('active'); | |
| this._hideCancelButton(); | |
| this._stopEtaCountdown(); | |
| this.abortController = null; | |
| setTimeout(() => { | |
| if (typeof progressTracker !== 'undefined') progressTracker.reset(); | |
| }, 2000); | |
| } | |
| } | |
| // #1 β Real SSE progress | |
| async _generateWithSSE(settings) { | |
| const error = document.getElementById(this.errorId); | |
| return new Promise((resolve, reject) => { | |
| // SSE requires GET, but we need to POST settings first | |
| // Use fetch with SSE-like reading via ReadableStream | |
| fetch('/generate-sse', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(settings), | |
| signal: this.abortController?.signal | |
| }).then(response => { | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| const processChunk = ({ done, value }) => { | |
| if (done) { | |
| resolve(); | |
| return; | |
| } | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| try { | |
| const event = JSON.parse(line.slice(6)); | |
| this._handleSSEEvent(event); | |
| if (event.type === 'complete') { | |
| resolve(); | |
| return; | |
| } | |
| if (event.type === 'error') { | |
| error.textContent = event.message; | |
| error.style.display = 'block'; | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.error('Generation Failed', event.message); | |
| } | |
| resolve(); | |
| return; | |
| } | |
| if (event.type === 'cancelled') { | |
| resolve(); | |
| return; | |
| } | |
| } catch (e) { | |
| console.error('SSE parse error:', e); | |
| } | |
| } | |
| } | |
| reader.read().then(processChunk).catch(reject); | |
| }; | |
| reader.read().then(processChunk).catch(reject); | |
| }).catch(reject); | |
| }); | |
| } | |
| _handleSSEEvent(event) { | |
| switch (event.type) { | |
| case 'started': | |
| this.currentOperationId = event.operation_id; | |
| this._updateProgress('Starting generation...', 5); | |
| if (event.estimated_total_seconds) { | |
| this._startEtaCountdown(event.estimated_total_seconds); | |
| } | |
| break; | |
| case 'progress': | |
| this._updateProgress(event.message, event.progress); | |
| break; | |
| case 'complete': | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.complete('Screenshots generated!'); | |
| } | |
| const data = event.data; | |
| if (data.html_filename) { | |
| this.lastGeneratedHtmlFile = data.html_filename; | |
| this.lastGeneratedSettings = this._getSettings(); | |
| this.currentVersionScreenshots = data.screenshot_files; | |
| this.updateButtonState('generated'); | |
| } | |
| if (data.html_content) { | |
| window.generatedHTML = data.html_content; | |
| } | |
| if (typeof displayResults !== 'undefined') { | |
| displayResults(this.mode, data); | |
| } | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.success( | |
| 'Generation Complete!', | |
| `Created ${data.screenshot_count} screenshot(s)` | |
| ); | |
| } | |
| break; | |
| } | |
| } | |
| async _generateWithFetch(settings) { | |
| const error = document.getElementById(this.errorId); | |
| const response = await fetch(this.generateEndpoint, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(settings), | |
| signal: this.abortController?.signal | |
| }); | |
| const data = await response.json(); | |
| if (data.error) { | |
| error.textContent = data.error; | |
| error.style.display = 'block'; | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.error('Generation Failed', data.error); | |
| } | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.reset(); | |
| } | |
| } else { | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.complete('Screenshots generated!'); | |
| } | |
| if (data.html_filename) { | |
| this.lastGeneratedHtmlFile = data.html_filename; | |
| this.lastGeneratedSettings = this._getSettings(); | |
| this.currentVersionScreenshots = data.screenshot_files; | |
| this.updateButtonState('generated'); | |
| } | |
| if (data.html_content) { | |
| window.generatedHTML = data.html_content; | |
| } | |
| if (typeof displayResults !== 'undefined') { | |
| displayResults(this.mode, data); | |
| } | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.success( | |
| 'Generation Complete!', | |
| `Created ${data.screenshot_count} screenshot(s)${data.performance ? ` in ${data.performance.total_time}` : ''}` | |
| ); | |
| } | |
| if (data.performance) { | |
| this._displayPerformanceMetrics(data.performance); | |
| } | |
| } | |
| } | |
| // βββ Regenerate βββ | |
| async regenerate() { | |
| if (!this.lastGeneratedHtmlFile) { | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.error('Error', 'No HTML file available for regeneration'); | |
| } | |
| return; | |
| } | |
| const loading = document.getElementById(this.loadingId); | |
| const error = document.getElementById(this.errorId); | |
| const result = document.getElementById(this.resultId); | |
| const regenerateBtn = document.getElementById(`${this.mode}RegenerateBtn`); | |
| error.style.display = 'none'; | |
| result.classList.remove('active'); | |
| const settings = this._getSettings(); | |
| // #18 β CSS classes | |
| this._setButtonDisabled(regenerateBtn, true); | |
| this._setButtonLoading(regenerateBtn, true); | |
| loading.classList.add('active'); | |
| this._showCancelButton(); | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.start([ | |
| 'Reading HTML file...', | |
| 'Creating screenshots...', | |
| 'Finalizing...' | |
| ]); | |
| } | |
| this.abortController = new AbortController(); | |
| try { | |
| const payload = { | |
| ...settings, | |
| html_filename: this.lastGeneratedHtmlFile | |
| }; | |
| const response = await fetch('/regenerate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| signal: this.abortController.signal | |
| }); | |
| const data = await response.json(); | |
| if (data.error) { | |
| error.textContent = data.error; | |
| error.style.display = 'block'; | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.error('Regeneration Failed', data.error); | |
| } | |
| if (typeof progressTracker !== 'undefined') progressTracker.reset(); | |
| } else { | |
| if (typeof progressTracker !== 'undefined') { | |
| progressTracker.complete('Screenshots regenerated!'); | |
| } | |
| this.previousVersionScreenshots = this.currentVersionScreenshots; | |
| this.currentVersionScreenshots = data.screenshot_files; | |
| this.lastGeneratedSettings = this._getSettings(); | |
| data.html_filename = this.lastGeneratedHtmlFile; | |
| if (typeof displayResults !== 'undefined') { | |
| displayResults(this.mode, data); | |
| } | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.success( | |
| 'Regeneration Complete!', | |
| `Replaced old version with ${data.screenshot_count} new screenshot(s).` | |
| ); | |
| } | |
| } | |
| } catch (err) { | |
| if (err.name === 'AbortError') return; | |
| error.textContent = 'Error: ' + err.message; | |
| error.style.display = 'block'; | |
| if (typeof notificationManager !== 'undefined') { | |
| notificationManager.error('Error', err.message); | |
| } | |
| if (typeof progressTracker !== 'undefined') progressTracker.reset(); | |
| } finally { | |
| this._setButtonDisabled(regenerateBtn, false); | |
| this._setButtonLoading(regenerateBtn, false); | |
| loading.classList.remove('active'); | |
| this._hideCancelButton(); | |
| this._stopEtaCountdown(); | |
| this.abortController = null; | |
| setTimeout(() => { | |
| if (typeof progressTracker !== 'undefined') progressTracker.reset(); | |
| }, 2000); | |
| } | |
| } | |
| // βββ Performance Metrics Display βββ | |
| _displayPerformanceMetrics(performance) { | |
| const resultSection = document.getElementById(this.resultId); | |
| if (!resultSection) return; | |
| const existingMetrics = resultSection.querySelector('.metrics-display'); | |
| if (existingMetrics) existingMetrics.remove(); | |
| const metricsDiv = document.createElement('div'); | |
| metricsDiv.className = 'metrics-display'; | |
| metricsDiv.innerHTML = ` | |
| <div class="metric-item"> | |
| <span class="metric-label">Total Time:</span> | |
| <span class="metric-value">${performance.total_time}</span> | |
| </div> | |
| <div class="metric-item"> | |
| <span class="metric-label">AI Processing:</span> | |
| <span class="metric-value">${performance.ai_time}</span> | |
| </div> | |
| <div class="metric-item"> | |
| <span class="metric-label">Screenshot Generation:</span> | |
| <span class="metric-value">${performance.screenshot_time}</span> | |
| </div> | |
| `; | |
| const successAlert = resultSection.querySelector('.alert-success'); | |
| if (successAlert) successAlert.after(metricsDiv); | |
| } | |
| } | |