diff --git a/README.md b/README.md index 168632d..3aaf31a 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,13 @@ aPix Image Workspace là một giao diện Flask nhẹ giúp bạn tạo hình 1. Đặt biến môi trường `GOOGLE_API_KEY` với API key của Google GenAI hoặc nhập trực tiếp trong giao diện. 2. Mở trình duyệt tới `http://127.0.0.1:8888`, nhập prompt, chọn tùy chọn và nhấn Generate. 3. Hình ảnh: `static/generated` lưu nội dung mới nhất, còn `/gallery` trả về URL cho phần lịch sử. + +### Cú pháp đặc biệt +Ứng dụng hỗ trợ cú pháp placeholder để tạo nhiều biến thể ảnh hoặc thay thế nội dung linh hoạt: + +* **Placeholder:** Sử dụng `{text}` hoặc `[text]` trong prompt. Ví dụ: `A photo of a {animal} in the style of {style}`. +* **Trường Note:** Nội dung trong trường Note sẽ thay thế cho placeholder: + * **Thay thế đơn:** Nếu Note là `cat`, prompt sẽ thành `A photo of a cat...`. + * **Hàng đợi (Queue):** Nếu Note chứa ký tự `|` (ví dụ: `cat|dog|bird`), ứng dụng sẽ tự động tạo 3 ảnh lần lượt với `cat`, `dog`, và `bird`. + * **Nhiều dòng:** Nếu Note có nhiều dòng, mỗi dòng sẽ ứng với một lần tạo ảnh. + * **Mặc định:** Nếu Note để trống, placeholder sẽ giữ nguyên hoặc dùng giá trị mặc định nếu có (ví dụ `{cat|dog}` sẽ tạo 2 ảnh nếu Note trống). diff --git a/app.py b/app.py index 32c4ba9..7cee721 100644 --- a/app.py +++ b/app.py @@ -16,7 +16,7 @@ import logging app = Flask(__name__) log = logging.getLogger('werkzeug') -log.setLevel(logging.ERROR) +log.setLevel(logging.WARNING) app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 PREVIEW_MAX_DIMENSION = 1024 @@ -192,6 +192,132 @@ os.makedirs(GENERATED_DIR, exist_ok=True) UPLOADS_DIR = os.path.join(app.static_folder, 'uploads') os.makedirs(UPLOADS_DIR, exist_ok=True) +def process_prompt_with_placeholders(prompt, note): + """ + Process prompt with {text} or [text] placeholders. + + Logic: + 1. If prompt has placeholders: + - If note is empty: + - If placeholder contains pipes (e.g. {cat|dog} or [cat|dog]), generate multiple prompts + - If no pipes, keep placeholder as is + - If note has content: + - If note has pipes (|), split note and replace placeholders for each segment (queue) + - If note has newlines, split note and replace placeholders sequentially + - If single note, replace all placeholders with note content + 2. If no placeholders: + - Standard behavior: "{prompt}. {note}" + + Returns: + list: List of processed prompts + """ + import re + + # Regex to find placeholders: {text} or [text] + # Matches {content} or [content] + placeholder_pattern = r'\{([^{}]+)\}|\[([^\[\]]+)\]' + placeholders = re.findall(placeholder_pattern, prompt) + + # Flatten the list of tuples from findall and filter empty strings + # re.findall with groups returns list of tuples like [('content', ''), ('', 'content')] + placeholders = [p[0] or p[1] for p in placeholders if p[0] or p[1]] + + if not placeholders: + # Standard behavior + return [f"{prompt}. {note}" if note else prompt] + + # If note is empty, check for default values in placeholders + if not note: + # Check if any placeholder has pipe-separated values + # We only handle the FIRST placeholder with pipes for combinatorial generation to keep it simple + # or we could generate for all, but let's stick to the requirement: "creates multiple commands" + + # Find the first placeholder that has options + target_placeholder = None + options = [] + + for p in placeholders: + if '|' in p: + target_placeholder = p + options = p.split('|') + break + + if target_placeholder: + # Generate a prompt for each option + generated_prompts = [] + for option in options: + # Replace the target placeholder with the option + # We need to handle both {placeholder} and [placeholder] + # Construct regex that matches either {target} or [target] + escaped_target = re.escape(target_placeholder) + pattern = f'(\\{{{escaped_target}\\}}|\\[{escaped_target}\\])' + + # Replace only the first occurrence or all? + # Usually all occurrences of the same placeholder string + new_prompt = re.sub(pattern, option.strip(), prompt) + generated_prompts.append(new_prompt) + return generated_prompts + + # No pipes in placeholders, return prompt as is (placeholders remain) + return [prompt] + + # Note has content + if '|' in note: + # Split note by pipe and generate a prompt for each segment + note_segments = [s.strip() for s in note.split('|') if s.strip()] + generated_prompts = [] + + for segment in note_segments: + current_prompt = prompt + # Replace all placeholders with this segment + # We need to replace all found placeholders + for p in placeholders: + escaped_p = re.escape(p) + pattern = f'(\\{{{escaped_p}\\}}|\\[{escaped_p}\\])' + current_prompt = re.sub(pattern, segment, current_prompt) + generated_prompts.append(current_prompt) + + return generated_prompts + + elif '\n' in note: + # Split note by newline and replace placeholders sequentially + note_lines = [l.strip() for l in note.split('\n') if l.strip()] + current_prompt = prompt + + for i, p in enumerate(placeholders): + replacement = "" + if i < len(note_lines): + replacement = note_lines[i] + else: + # If fewer lines than placeholders, use default (content inside braces) + # If default has pipes, take the first one + if '|' in p: + replacement = p.split('|')[0] + else: + # Keep the placeholder text but remove braces? + # Or keep the original placeholder? + # Requirement says: "remaining placeholders use their default text" + replacement = p + + escaped_p = re.escape(p) + pattern = f'(\\{{{escaped_p}\\}}|\\[{escaped_p}\\])' + # Replace only the first occurrence of this specific placeholder to allow sequential mapping + # But if multiple placeholders have SAME text, this might be ambiguous. + # Assuming placeholders are unique or processed left-to-right. + # re.sub replaces all by default, count=1 replaces first + current_prompt = re.sub(pattern, replacement, current_prompt, count=1) + + return [current_prompt] + + else: + # Single note content, replace all placeholders + current_prompt = prompt + for p in placeholders: + escaped_p = re.escape(p) + pattern = f'(\\{{{escaped_p}\\}}|\\[{escaped_p}\\])' + current_prompt = re.sub(pattern, note, current_prompt) + return [current_prompt] + @app.route('/') def index(): return render_template('index.html') @@ -206,6 +332,7 @@ def generate_image(): note = form.get('note', '') aspect_ratio = form.get('aspect_ratio') resolution = form.get('resolution', '2K') + model = form.get('model', 'gemini-3-pro-image-preview') api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY') reference_files = request.files.getlist('reference_images') reference_paths_json = form.get('reference_image_paths') @@ -215,6 +342,7 @@ def generate_image(): note = data.get('note', '') aspect_ratio = data.get('aspect_ratio') resolution = data.get('resolution', '2K') + model = data.get('model', 'gemini-3-pro-image-preview') api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY') reference_files = [] reference_paths_json = data.get('reference_image_paths') @@ -229,9 +357,11 @@ def generate_image(): print("Đang gửi lệnh...", flush=True) client = genai.Client(api_key=api_key) - image_config_args = { - "image_size": resolution - } + image_config_args = {} + + # Only add resolution if NOT using flash model + if model != 'gemini-2.5-flash-image': + image_config_args["image_size"] = resolution if aspect_ratio and aspect_ratio != 'Auto': image_config_args["aspect_ratio"] = aspect_ratio @@ -239,8 +369,25 @@ def generate_image(): # Process reference paths and files final_reference_paths = [] - # Merge prompt with note for API call, but keep originals for metadata - api_prompt = f"{prompt}. {note}" if note else prompt + # Process prompt with placeholders - returns list of prompts + processed_prompts = process_prompt_with_placeholders(prompt, note) + + # If multiple prompts (queue scenario), return them to frontend for queue processing + if len(processed_prompts) > 1: + return jsonify({ + 'queue': True, + 'prompts': processed_prompts, + 'metadata': { + 'original_prompt': prompt, + 'original_note': note, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'model': model + } + }) + + # Single prompt - continue with normal generation + api_prompt = processed_prompts[0] contents = [api_prompt] # Parse reference paths from frontend @@ -323,8 +470,8 @@ def generate_image(): print(f"Error processing uploaded file: {e}") continue - model_name = "gemini-3-pro-image-preview" - print("Đang tạo...", flush=True) + model_name = model + print(f"Đang tạo với model {model_name}...", flush=True) response = client.models.generate_content( model=model_name, contents=contents, @@ -366,8 +513,8 @@ def generate_image(): image_url = url_for('static', filename=rel_path) metadata = { - 'prompt': prompt, - 'note': note, + 'prompt': api_prompt, # Store the processed prompt + 'note': '', # Note is already merged into prompt 'aspect_ratio': aspect_ratio or 'Auto', 'resolution': resolution, 'reference_images': final_reference_paths, @@ -942,5 +1089,10 @@ def refine_prompt(): except Exception as e: return jsonify({'error': str(e)}), 500 +port_sever = 8888 if __name__ == '__main__': - app.run(debug=True, port=8888) + # Use ANSI green text so the startup banner stands out in terminals + print("\033[32m" + "aPix Image Workspace running at:" + "\033[0m", flush=True) + print("\033[32m" + f"http://localhost:{port_sever}" + " " + "\033[0m", flush=True) + print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True) + app.run(debug=True, port=port_sever) diff --git a/static/modules/gallery.js b/static/modules/gallery.js index 28a4c45..6f6acb4 100644 --- a/static/modules/gallery.js +++ b/static/modules/gallery.js @@ -305,5 +305,43 @@ export function createGallery({ galleryGrid, onSelect }) { return showOnlyFavorites; } + function navigate(direction) { + const activeItem = galleryGrid.querySelector('.gallery-item.active'); + + if (!activeItem) { + // If nothing active, select the first item on any arrow key + const firstItem = galleryGrid.querySelector('.gallery-item'); + if (firstItem) { + firstItem.click(); + firstItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + return; + } + + let targetItem; + if (direction === 'prev') { + targetItem = activeItem.previousElementSibling; + } else if (direction === 'next') { + targetItem = activeItem.nextElementSibling; + } + + if (targetItem && targetItem.classList.contains('gallery-item')) { + targetItem.click(); + targetItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + + // Setup keyboard navigation + document.addEventListener('keydown', (e) => { + // Ignore if user is typing in an input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + if (e.key === 'ArrowLeft') { + navigate('prev'); + } else if (e.key === 'ArrowRight') { + navigate('next'); + } + }); + return { load, setFilter, getCurrentFilter, setSearch, getSearchQuery, toggleFavorites, isFavoritesActive }; } diff --git a/static/script.js b/static/script.js index 814e39e..dfccdde 100644 --- a/static/script.js +++ b/static/script.js @@ -48,6 +48,16 @@ const docsContent = { 'API key và prompt được lưu để lần sau không phải nhập lại', ], }, + { + heading: 'Cú pháp đặc biệt', + items: [ + 'Placeholder: Dùng {text} hoặc [text] trong prompt (VD: A photo of a {animal})', + 'Note đơn: Nội dung Note sẽ thay thế cho placeholder', + 'Note hàng đợi: Dùng dấu | để tạo nhiều ảnh (VD: cat|dog|bird)', + 'Note nhiều dòng: Mỗi dòng tương ứng một lần tạo ảnh', + 'Mặc định: Nếu Note trống, dùng giá trị trong ngoặc (VD: {cat|dog} tạo 2 ảnh)', + ], + }, ], }; @@ -98,6 +108,97 @@ document.addEventListener('DOMContentLoaded', () => { const refineInstructionInput = document.getElementById('refine-instruction'); const confirmRefineBtn = document.getElementById('confirm-refine-btn'); + // --- Helper Functions (Moved to top to avoid hoisting issues) --- + + // Model Selection Logic + const apiModelSelect = document.getElementById('api-model'); + const resolutionGroup = resolutionInput.closest('.input-group'); + + function toggleResolutionVisibility() { + if (apiModelSelect && apiModelSelect.value === 'gemini-2.5-flash-image') { + resolutionGroup.classList.add('hidden'); + } else { + resolutionGroup.classList.remove('hidden'); + } + } + + if (apiModelSelect) { + apiModelSelect.addEventListener('change', () => { + toggleResolutionVisibility(); + persistSettings(); + }); + } + + // Load Settings + function loadSettings() { + try { + const saved = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (saved) { + const settings = JSON.parse(saved); + if (settings.apiKey) apiKeyInput.value = settings.apiKey; + if (settings.prompt) promptInput.value = settings.prompt; + if (settings.note) promptNoteInput.value = settings.note; + if (settings.aspectRatio) aspectRatioInput.value = settings.aspectRatio; + if (settings.resolution) resolutionInput.value = settings.resolution; + if (settings.model && apiModelSelect) { + apiModelSelect.value = settings.model; + toggleResolutionVisibility(); + } + return settings; + } + } catch (e) { + console.warn('Failed to load settings', e); + } + return {}; + } + + function persistSettings() { + // Check if slotManager is initialized + const referenceImages = (typeof slotManager !== 'undefined') ? slotManager.getImages() : []; + + const settings = { + apiKey: apiKeyInput.value, + prompt: promptInput.value, + note: promptNoteInput.value, + aspectRatio: aspectRatioInput.value, + resolution: resolutionInput.value, + model: apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview', + referenceImages: referenceImages, + }; + try { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch (e) { + console.warn('Failed to save settings', e); + } + } + + // Helper to build form data for generation + function buildGenerateFormData({ prompt, note, aspect_ratio, resolution, api_key, model }) { + const formData = new FormData(); + formData.append('prompt', prompt); + formData.append('note', note); + formData.append('aspect_ratio', aspect_ratio); + formData.append('resolution', resolution); + formData.append('api_key', api_key); + const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); + formData.append('model', selectedModel); + + // Add reference images using correct slotManager methods + const referenceFiles = slotManager.getReferenceFiles(); + referenceFiles.forEach(file => { + formData.append('reference_images', file); + }); + + const referencePaths = slotManager.getReferencePaths(); + if (referencePaths && referencePaths.length > 0) { + formData.append('reference_image_paths', JSON.stringify(referencePaths)); + } + + return formData; + } + + // --- End Helper Functions --- + let zoomLevel = 1; let panOffset = { x: 0, y: 0 }; let isPanning = false; @@ -202,15 +303,31 @@ document.addEventListener('DOMContentLoaded', () => { let generationQueue = []; let isProcessingQueue = false; + let pendingRequests = 0; // Track requests waiting for backend response function updateQueueCounter() { - // Count includes current processing item + items in queue - const count = generationQueue.length + (isProcessingQueue ? 1 : 0); + // Count includes: + // 1. Items waiting in queue + // 2. Item currently being processed (isProcessingQueue) + // 3. Items waiting for backend response (pendingRequests) + const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests; + + console.log('Queue counter update:', { + queue: generationQueue.length, + processing: isProcessingQueue, + pending: pendingRequests, + total: count + }); + if (count > 0) { - queueCounter.classList.remove('hidden'); - queueCountText.textContent = count; + if (queueCounter) { + queueCounter.classList.remove('hidden'); + queueCountText.textContent = count; + } } else { - queueCounter.classList.add('hidden'); + if (queueCounter) { + queueCounter.classList.add('hidden'); + } } } @@ -224,35 +341,58 @@ document.addEventListener('DOMContentLoaded', () => { // Take task from queue FIRST, then update state const task = generationQueue.shift(); isProcessingQueue = true; - updateQueueCounter(); + updateQueueCounter(); // Show counter immediately try { setViewState('loading'); - const formData = buildGenerateFormData({ - prompt: task.prompt, - note: task.note || '', - aspect_ratio: task.aspectRatio, - resolution: task.resolution, - api_key: task.apiKey, - }); - - const response = await fetch('/generate', { - method: 'POST', - body: formData, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to generate image'); - } - - if (data.image) { - displayImage(data.image, data.image_data); + // Check if this task already has a result (immediate generation) + if (task.immediateResult) { + // Display the already-generated image + displayImage(task.immediateResult.image, task.immediateResult.image_data); gallery.load(); } else { - throw new Error('No image data received'); + // Need to generate the image + const formData = buildGenerateFormData({ + prompt: task.prompt, + note: task.note || '', + aspect_ratio: task.aspectRatio, + resolution: task.resolution, + api_key: task.apiKey, + model: task.model, + }); + + const response = await fetch('/generate', { + method: 'POST', + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to generate image'); + } + + if (data.image) { + displayImage(data.image, data.image_data); + gallery.load(); + } else if (data.queue && data.prompts && Array.isArray(data.prompts)) { + // Backend returned more items - add them to queue + console.log('Backend returned additional queue items:', data.prompts.length); + data.prompts.forEach(processedPrompt => { + generationQueue.push({ + prompt: processedPrompt, + note: '', + aspectRatio: task.aspectRatio, + resolution: task.resolution, + apiKey: task.apiKey, + model: task.model, + }); + }); + updateQueueCounter(); + } else { + throw new Error('No image data received'); + } } } catch (error) { showError(error.message); @@ -264,12 +404,13 @@ document.addEventListener('DOMContentLoaded', () => { } } - function addToQueue() { + async function addToQueue() { const prompt = promptInput.value.trim(); const note = promptNoteInput.value.trim(); const aspectRatio = aspectRatioInput.value; const resolution = resolutionInput.value; const apiKey = apiKeyInput.value.trim(); + const selectedModel = apiModelSelect?.value || 'gemini-3-pro-image-preview'; if (!apiKey) { openApiSettings(); @@ -281,19 +422,96 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Store original prompt and note separately - generationQueue.push({ - prompt: prompt, - note: note, - aspectRatio, - resolution, - apiKey - }); + // Show loading state if not already processing and this is the first request + if (!isProcessingQueue && pendingRequests === 0) { + setViewState('loading'); + } + // Increment pending requests and update counter immediately + pendingRequests++; updateQueueCounter(); - if (!isProcessingQueue) { - processNextInQueue(); + let fetchCompleted = false; + + try { + const formData = buildGenerateFormData({ + prompt: prompt, + note: note, + aspect_ratio: aspectRatio, + resolution: resolution, + api_key: apiKey, + model: selectedModel, + }); + + const response = await fetch('/generate', { + method: 'POST', + body: formData, + }); + + const data = await response.json(); + + // Mark fetch as completed and decrement pending + // We do this BEFORE adding to queue to avoid double counting + fetchCompleted = true; + pendingRequests--; + + if (!response.ok) { + throw new Error(data.error || 'Failed to generate image'); + } + + // Check if backend returned a queue + if (data.queue && data.prompts && Array.isArray(data.prompts)) { + console.log('Backend returned queue with', data.prompts.length, 'prompts'); + // Add all prompts to the queue + data.prompts.forEach(processedPrompt => { + generationQueue.push({ + prompt: processedPrompt, + note: '', + aspectRatio, + resolution, + apiKey, + model: selectedModel, + }); + }); + } else if (data.image) { + console.log('Backend returned single image'); + // Single image - add to queue for consistent processing + generationQueue.push({ + prompt: prompt, + note: note, + aspectRatio, + resolution, + apiKey, + model: selectedModel, + immediateResult: { + image: data.image, + image_data: data.image_data + } + }); + } else { + throw new Error('Unexpected response from server'); + } + + // Update counter after adding to queue + updateQueueCounter(); + + // Start processing queue only if not already processing + if (!isProcessingQueue) { + console.log('Starting queue processing'); + processNextInQueue(); + } else { + console.log('Already processing, item added to queue'); + } + } catch (error) { + console.error('Error in addToQueue:', error); + + // If fetch failed (didn't complete), we need to decrement pendingRequests + if (!fetchCompleted) { + pendingRequests--; + } + + updateQueueCounter(); + showError(error.message); } } @@ -1323,7 +1541,14 @@ document.addEventListener('DOMContentLoaded', () => { function applyMetadata(metadata) { if (!metadata) return; if (metadata.prompt) promptInput.value = metadata.prompt; - if (metadata.note) promptNoteInput.value = metadata.note; + + // If metadata doesn't have 'note' field, set to empty string instead of keeping current value + if (metadata.hasOwnProperty('note')) { + promptNoteInput.value = metadata.note || ''; + } else { + promptNoteInput.value = ''; + } + if (metadata.aspect_ratio) aspectRatioInput.value = metadata.aspect_ratio; if (metadata.resolution) resolutionInput.value = metadata.resolution; @@ -1381,62 +1606,6 @@ document.addEventListener('DOMContentLoaded', () => { } } - function buildGenerateFormData(fields) { - const formData = new FormData(); - - Object.entries(fields).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - formData.append(key, value); - } - }); - - slotManager.getReferenceFiles().forEach(file => { - formData.append('reference_images', file, file.name); - }); - - const referencePaths = slotManager.getReferencePaths(); - if (referencePaths && referencePaths.length > 0) { - formData.append('reference_image_paths', JSON.stringify(referencePaths)); - } - - return formData; - } - - function loadSettings() { - if (typeof localStorage === 'undefined') return {}; - try { - const saved = localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!saved) return {}; - - const { apiKey, aspectRatio, resolution, prompt, promptNote, referenceImages } = JSON.parse(saved); - if (apiKey) apiKeyInput.value = apiKey; - if (aspectRatio) aspectRatioInput.value = aspectRatio; - if (resolution) resolutionInput.value = resolution; - if (prompt) promptInput.value = prompt; - if (promptNote) promptNoteInput.value = promptNote; - return { apiKey, aspectRatio, resolution, prompt, promptNote, referenceImages }; - } catch (error) { - console.warn('Unable to load cached settings', error); - return {}; - } - } - - function persistSettings() { - if (typeof localStorage === 'undefined') return; - try { - const settings = { - apiKey: apiKeyInput.value.trim(), - aspectRatio: aspectRatioInput.value, - resolution: resolutionInput.value, - prompt: promptInput.value.trim(), - promptNote: promptNoteInput.value.trim(), - referenceImages: slotManager.serializeReferenceImages(), - }; - localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); - } catch (error) { - console.warn('Unable to persist settings', error); - } - } function handleGenerateShortcut(event) { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { diff --git a/static/style.css b/static/style.css index 842f7d9..a7b7199 100644 --- a/static/style.css +++ b/static/style.css @@ -1779,22 +1779,20 @@ button#generate-btn:disabled { align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 20px; color: var(--text-primary); font-size: 0.875rem; - font-weight: 500; - z-index: 100; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + font-weight: 600; + z-index: 9999; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); transition: all 0.3s ease; } .queue-counter.hidden { - opacity: 0; - transform: translateY(-10px); - pointer-events: none; + display: none; } .queue-icon { diff --git a/templates/index.html b/templates/index.html index 23d2e8c..0b1a238 100644 --- a/templates/index.html +++ b/templates/index.html @@ -410,6 +410,16 @@ rel="noreferrer">aistudio.google.com/api-keys
+