""" NAIA-WEB App Builder Main Gradio application assembly """ import gradio as gr import random import tempfile import os import uuid import huggingface_hub from pathlib import Path from typing import Optional from core.generation_service import GenerationService, GenerationRequest from core.api_service import CharacterReferenceData, process_reference_image from core.autocomplete_service import gradio_search_tags, preload_autocomplete_data from data.tag_store import TagStore from data.character_store import get_character_store from ui.components.settings_panel import ( create_settings_panel, LOCALSTORAGE_JS ) from ui.components.prompt_tabs import create_prompt_tabs from ui.components.generation_panel import create_generation_panel from ui.components.quick_search import parse_tag_input from ui.components.output_panel import create_output_panel, create_generation_info_panel from ui.components.wildcard_panel import ( create_wildcard_panel, create_wildcard_template_input, WILDCARD_CSS ) from utils.constants import ( PERSON_CATEGORIES, PERSON_LABELS, PERSON_AUTO_TAGS, RATING_OPTIONS, RATING_LABELS, RATING_SUFFIX_TAGS, QUALITY_TAGS_NEGATIVE, MODEL_ID_MAP, DEFAULT_RESOLUTIONS, DEFAULT_RESOLUTION ) # Custom CSS for responsive behavior CUSTOM_CSS = """ /* Two-column layout */ .resizable-row { display: flex !important; gap: 16px; flex-wrap: wrap; } .resizable-row > .left-col { width: 540px !important; min-width: 280px !important; max-width: 540px !important; flex: 0 0 auto !important; } /* Mobile: stack columns vertically */ @media (max-width: 900px) { .resizable-row { flex-direction: column !important; } .resizable-row > .left-col, .resizable-row > div { width: 100% !important; max-width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; } } /* Output image container - relative for overlay positioning */ .output-image-container { position: relative !important; display: block !important; /* Prevent Gradio flex behavior */ border: none !important; background: transparent !important; padding: 0 !important; } /* Make gr.Image fill container */ .output-image-container > div { position: relative !important; } #naia-output-image { position: relative !important; width: 100% !important; } .output-image img { max-height: 50vh; object-fit: contain; width: 100%; } /* Hide default image control buttons (Gradio 6.x) */ #naia-output-image .icon-buttons, #naia-output-image .image-button, #naia-output-image button.icon, #naia-output-image [class*="icon-button"], #naia-output-image .svelte-, #naia-output-image > div > div:last-child:not(:first-child), #naia-output-image button[aria-label], #naia-output-image .toolbar { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; } /* Download button overlay - anchored to image container */ #naia-download-btn, .download-overlay-btn { position: absolute !important; top: auto !important; left: auto !important; bottom: 12px !important; right: 12px !important; z-index: 100 !important; padding: 6px 12px !important; font-size: 0.85em !important; background: rgba(255, 255, 255, 0.95) !important; border: 1px solid var(--border-color-primary) !important; border-radius: 4px !important; box-shadow: 0 2px 6px rgba(0,0,0,0.15) !important; min-width: auto !important; width: auto !important; height: auto !important; margin: 0 !important; } #naia-download-btn:hover, .download-overlay-btn:hover { background: white !important; transform: translateY(-1px) !important; box-shadow: 0 4px 8px rgba(0,0,0,0.2) !important; transition: all 0.15s ease !important; } #naia-download-btn, .download-overlay-btn { transition: all 0.15s ease !important; cursor: pointer !important; } /* Prompt section - always visible */ .prompt-section { border: 1px solid var(--border-color-primary); border-radius: 8px; padding: 12px; margin-top: 8px; } /* Prompt tabs - white background */ .prompt-section .tabs, .prompt-section .tab-nav, .prompt-section > div > div:first-child { background: white !important; background-color: white !important; } .prompt-section button[role="tab"], .prompt-section .tab-nav button, .prompt-section .tabitem { background: white !important; background-color: white !important; } /* Gradio 6.x specific selectors */ #naia-prompt-tabs { background: white !important; } #naia-prompt-tabs > div:first-child, #naia-prompt-tabs > div:first-child > button { background: white !important; background-color: white !important; } /* Random Prompt Filter section */ /* Random Prompt Filter section */ .filter-section-title { font-size: 0.9em !important; margin: 10px 0 6px 12px !important; color: #444 !important; font-weight: 600 !important; } .random-filter-row { display: flex !important; gap: 16px !important; flex-wrap: wrap !important; margin-bottom: 12px !important; margin-left: 12px !important; align-items: center !important; overflow: visible !important; } .random-filter-row label { font-size: 0.9em !important; display: flex !important; align-items: center !important; } .random-filter-row .wrap { display: flex !important; align-items: center !important; } .random-filter-row span { white-space: nowrap !important; } #naia-filter-characteristics, #naia-filter-clothes, #naia-filter-location { min-width: auto !important; width: auto !important; flex: 0 0 auto !important; background: transparent !important; border: none !important; padding: 0 !important; margin: 0 !important; } /* Random Guide Accordion styling */ #naia-random-guide { border-color: #E97132 !important; } /* Prompt engineering tabs styling */ .guide-column { border-left: 1px solid var(--border-color-primary) !important; padding-left: 16px !important; } .prompt-guide { font-size: 0.85em !important; color: var(--body-text-color-subdued) !important; line-height: 1.5 !important; } .prompt-guide h3 { margin-top: 0 !important; margin-bottom: 8px !important; font-size: 1em !important; color: var(--body-text-color) !important; } .prompt-guide code { background: var(--background-fill-secondary) !important; padding: 1px 4px !important; border-radius: 3px !important; font-size: 0.9em !important; } .prompt-guide pre { background: var(--background-fill-secondary) !important; padding: 8px !important; border-radius: 4px !important; margin: 4px 0 !important; white-space: pre-wrap !important; word-wrap: break-word !important; word-break: break-word !important; overflow-x: hidden !important; } .prompt-guide table { font-size: 0.9em !important; margin: 8px 0 !important; } .prompt-guide th, .prompt-guide td { padding: 4px 8px !important; border: 1px solid var(--border-color-primary) !important; } /* Auto-expand textboxes for prompt engineering tabs */ .auto-expand-textbox { flex: 1 !important; } .auto-expand-textbox textarea { min-height: 150px !important; height: 100% !important; resize: vertical !important; } #naia-pre-prompt, #naia-post-prompt, #naia-auto-hide { height: 100% !important; } /* Pagination controls - 3 equal parts */ .qs-pagination { display: flex !important; align-items: center !important; gap: 4px !important; padding: 4px 0 !important; flex-wrap: nowrap !important; } .qs-pagination > * { flex: 1 1 0 !important; /* Equal distribution */ min-width: 0 !important; } .qs-pagination button { width: 100% !important; padding: 6px 8px !important; font-size: 1em !important; font-weight: bold !important; } #naia-qs-page-info-container { text-align: center !important; font-size: 0.9em !important; padding: 0 !important; white-space: nowrap !important; } #naia-qs-page-text { font-weight: 500; } /* Tag button grid - 2 columns for mobile compatibility */ .qs-tag-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; padding: 4px 0; max-height: 400px; overflow-y: auto; background: transparent !important; } /* Quick Search tag grid - transparent background for tag area only */ #naia-qs-tags-container, #naia-qs-tags-container > div, .qs-tag-grid { background: transparent !important; background-color: transparent !important; } /* Hidden input for JS-to-Gradio communication */ .hidden-input { display: none !important; } /* Tag action popup - positioned above grid */ .qs-tag-popup { position: absolute; top: 0; left: 50%; transform: translate(-50%, -100%); background: #ffffff !important; background-color: #ffffff !important; border: 1px solid #ccc; border-radius: 8px; padding: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.25); z-index: 1001; min-width: 200px; text-align: center; margin-top: -8px; opacity: 1 !important; } .qs-tag-popup * { background: transparent !important; } .qs-tag-popup-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); z-index: 1000; } /* Quick Search guide accordion styling */ #naia-qs-guide { margin-top: 8px !important; } #naia-qs-guide > div:not(.label-wrap) { background: white !important; } #naia-qs-guide .prose { font-size: 0.9em !important; } #naia-qs-guide .prose p { margin: 6px 0 !important; line-height: 1.5 !important; } .qs-tag-popup h4 { margin: 0 0 12px 0; font-size: 0.95em; word-break: break-all; } .qs-tag-popup-btns { display: flex; gap: 8px; justify-content: center; } .qs-tag-popup-btns button { padding: 8px 16px; border-radius: 4px; border: 1px solid var(--border-color-primary); cursor: pointer; font-size: 0.9em; min-width: 80px; } .qs-tag-popup-btns .include-btn { background: rgba(34, 197, 94, 0.15); border-color: rgb(34, 197, 94); color: rgb(22, 128, 61); } .qs-tag-popup-btns .include-btn:hover { background: rgba(34, 197, 94, 0.3); } .qs-tag-popup-btns .exclude-btn { background: rgba(239, 68, 68, 0.15); border-color: rgb(239, 68, 68); color: rgb(185, 28, 28); } .qs-tag-popup-btns .exclude-btn:hover { background: rgba(239, 68, 68, 0.3); } .qs-tag-btn { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px; border: 1px solid var(--border-color-primary); border-radius: 4px; background: var(--background-fill-secondary); cursor: pointer; font-size: 0.85em; transition: all 0.1s ease; user-select: none; } .qs-tag-btn:hover { background: var(--background-fill-primary); border-color: var(--primary-500); } .qs-tag-btn:active { transform: scale(0.98); } .qs-tag-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 4px; } .qs-tag-count { color: var(--body-text-color-subdued); font-size: 0.9em; flex-shrink: 0; } .qs-tag-btn.included { background: rgba(34, 197, 94, 0.15); border-color: rgb(34, 197, 94); } .qs-tag-btn.excluded { background: rgba(239, 68, 68, 0.15); border-color: rgb(239, 68, 68); } /* Status labels row - flex wrap for natural line break */ .status-labels-row { display: flex !important; flex-wrap: wrap !important; align-items: center !important; gap: 4px 8px !important; margin: 4px 0 !important; } .status-labels-row > * { flex: 0 1 auto !important; width: auto !important; min-width: 0 !important; max-width: 100% !important; } /* Random status label styling - minimal */ .random-status-label, #naia-random-status, #naia-random-status * { font-size: 0.95em; color: var(--body-text-color); padding: 0; margin: 0; background: transparent !important; word-wrap: break-word !important; overflow-wrap: break-word !important; white-space: normal !important; word-break: break-word !important; } /* Status separator */ .status-separator { color: #ccc; font-size: 0.85em; } /* Button row styling */ .action-buttons-row { display: flex !important; gap: 8px; align-items: stretch !important; } .action-buttons-row > * { height: auto !important; min-height: 100% !important; } /* Options bar - horizontal toolbar for resolution and future controls */ .options-bar { display: flex !important; flex-direction: row !important; flex-wrap: wrap !important; align-items: center !important; justify-content: flex-start !important; gap: 8px 12px !important; margin: 4px 0 12px 0 !important; padding: 0 !important; background: transparent !important; border: none !important; } .options-bar > * { flex: 0 0 auto !important; width: auto !important; min-width: auto !important; max-width: none !important; } .options-bar .option-label { font-size: 0.9em !important; font-weight: 600 !important; color: var(--body-text-color) !important; white-space: nowrap !important; } /* Remove all borders/backgrounds from elements in options bar */ .options-bar .gr-group, .options-bar .gr-box, .options-bar .gr-form, .options-bar .block, .options-bar > div { flex: 0 0 auto !important; width: auto !important; min-width: auto !important; border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; } /* Resolution dropdown - compact */ #naia-resolution { width: 120px !important; min-width: 120px !important; max-width: 120px !important; } #naia-resolution input { padding: 4px 8px !important; } /* Random checkbox - compact inline */ #naia-random-resolution { width: auto !important; min-width: auto !important; flex-shrink: 0 !important; } #naia-random-resolution label { font-size: 0.85em !important; } /* Manage button - compact */ #naia-manage-resolution { width: auto !important; min-width: auto !important; padding: 4px 10px !important; font-size: 0.85em !important; } /* Resolution manager popup */ .resolution-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; border: 1px solid var(--border-color-primary); border-radius: 8px; padding: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 1000; max-height: 80vh; overflow-y: auto; min-width: 300px; } .resolution-popup-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); z-index: 999; } .resolution-popup h4 { margin: 0 0 12px 0; } .resolution-item { display: flex; align-items: center; padding: 8px; border-bottom: 1px solid var(--border-color-primary); } .resolution-item:last-child { border-bottom: none; } .resolution-item label { flex: 1; cursor: pointer; } /* Character slot styling */ #naia-char-prompt-accordion .group { border: 1px solid var(--border-color-primary); border-radius: 6px; padding: 8px 12px; margin-bottom: 8px; background: var(--background-fill-secondary); } /* Character search popup styling */ #naia-char-search-popup { border: 1px solid var(--border-color-primary); border-radius: 8px; padding: 12px; margin-bottom: 12px; background: var(--background-fill-primary); box-shadow: 0 2px 8px rgba(0,0,0,0.1); } #naia-char-search-results { font-size: 0.9em; } #naia-char-search-results table { cursor: pointer; } #naia-char-search-results tr:hover { background: var(--background-fill-secondary); } /* API Settings - no token warning style */ #naia-api-settings-accordion.no-token { background: linear-gradient(135deg, rgba(255, 200, 200, 0.6) 0%, rgba(255, 150, 150, 0.4) 50%, rgba(255, 200, 200, 0.6) 100%) !important; border: 1px solid rgba(220, 100, 100, 0.5) !important; border-radius: 8px !important; } #naia-api-settings-accordion.no-token > button.label-wrap { background: transparent !important; } /* Autocomplete dropdown styling */ .naia-autocomplete-dropdown { font-family: inherit !important; font-size: 0.9em !important; } .naia-autocomplete-item { display: flex !important; justify-content: space-between !important; padding: 8px 12px !important; cursor: pointer !important; border-bottom: 1px solid #eee !important; transition: background 0.1s ease !important; } .naia-autocomplete-item:last-child { border-bottom: none !important; } .naia-autocomplete-item:hover, .naia-autocomplete-item.selected { background: #e3f2fd !important; } .naia-autocomplete-item .ac-tag { flex: 1 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; margin-right: 8px !important; } .naia-autocomplete-item .ac-count { flex-shrink: 0 !important; font-size: 0.85em !important; } /* Hidden component (visible to DOM but not visible to user) */ .hidden-component { position: absolute !important; left: -9999px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; } /* History Row (Left Column below settings) */ .history-row { display: grid !important; grid-template-columns: repeat(5, 1fr) !important; gap: 8px !important; margin-top: 12px !important; padding: 0 4px !important; } .history-item { aspect-ratio: 1/1 !important; overflow: hidden !important; border-radius: 6px !important; border: 1px solid var(--border-color-primary) !important; cursor: pointer !important; padding: 0 !important; position: relative !important; background: var(--background-fill-secondary) !important; box-shadow: 0 1px 3px rgba(0,0,0,0.1) !important; } .history-item:hover { border-color: var(--primary-500) !important; transform: translateY(-2px) !important; box-shadow: 0 4px 6px rgba(0,0,0,0.15) !important; } /* Force ALL descendants to be visible */ .history-item * { visibility: visible !important; } /* Hide the icon panel at top */ .history-item .icon-button-wrapper, .history-item .top-panel { display: none !important; height: 0 !important; overflow: hidden !important; } /* Force image to fit square */ .history-item img { width: 100% !important; height: 100% !important; object-fit: contain !important; background: #ffffff !important; visibility: visible !important; display: block !important; position: static !important; } /* All containers must fill parent */ .history-item .image-container { width: 100% !important; height: 100% !important; position: absolute !important; top: 0 !important; left: 0 !important; display: flex !important; flex-direction: column !important; } .history-item .image-container > button { width: 100% !important; height: 100% !important; flex: 1 !important; padding: 0 !important; margin: 0 !important; border: none !important; background: transparent !important; display: flex !important; align-items: center !important; justify-content: center !important; } .history-item .image-frame { width: 100% !important; height: 100% !important; display: flex !important; align-items: center !important; justify-content: center !important; } """ LIGHTBOX_CSS = """ /* Lightbox Overlay */ #naia-lightbox-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.9); z-index: 99999; display: none; align-items: flex-start; /* Changed from center to top-align for HF iframe */ justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; padding: 10px 20px; /* Reduced top padding */ box-sizing: border-box; } #naia-lightbox-overlay.active { display: flex !important; opacity: 1; pointer-events: auto; } /* Lightbox Content Layout */ #naia-lightbox-overlay .lb-container { display: flex; flex-direction: row; width: 100%; height: 100%; max-width: 1600px; /* Increased from 1400px */ gap: 8px; /* Reduced from 10px */ justify-content: center; margin: 0 auto; } /* Image Area */ #naia-lightbox-overlay .lb-image-area { flex: 1; display: flex; align-items: flex-start; /* Top-align for HF iframe compatibility */ justify-content: center; min-width: 0; } #naia-lightbox-overlay .lb-image-area img { max-width: 100%; max-height: 1152px; /* Fixed height for HF iframe - display only, original preserved */ object-fit: contain; border-radius: 4px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); } /* Controls Area - Compact vertical buttons */ #naia-lightbox-overlay .lb-controls { width: 70px; /* Reduced from 280px */ display: flex; flex-direction: column; justify-content: center; gap: 6px; /* Reduced from 12px */ flex-shrink: 0; } /* Buttons Common */ #naia-lightbox-overlay .lb-btn { border: none; cursor: pointer; font-family: inherit; font-weight: 600; transition: all 0.2s; border-radius: 6px; } #naia-lightbox-overlay .lb-btn:disabled { opacity: 0.6; cursor: wait; filter: grayscale(0.8); pointer-events: none; } #naia-lightbox-overlay .lb-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.2); } #naia-lightbox-overlay .lb-btn:active:not(:disabled) { transform: translateY(0); } /* Navigation Buttons */ #naia-lightbox-overlay .lb-nav-row { display: flex; gap: 8px; } #naia-lightbox-overlay .nav-btn { flex: 1; background: #e2e8f0; color: #333; font-size: 1.2em; padding: 10px 0; } #naia-lightbox-overlay .nav-btn:hover:not(:disabled) { background: #cbd5e1; } /* Action Buttons */ #naia-lightbox-overlay .lb-action-col { display: flex; flex-direction: column; gap: 10px; } #naia-lightbox-overlay .action-btn { width: 100%; padding: 16px 8px; color: white; font-size: 0.75em; text-align: center; line-height: 1.3; white-space: pre-line; /* Allow line breaks */ min-height: 48px; /* Minimum touch target size for mobile */ } /* Color Variants */ #naia-lightbox-overlay .action-btn.bg-orange { background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); } #naia-lightbox-overlay .action-btn.bg-dark-orange { /* Darker orange for Re-Generate as requested */ background: linear-gradient(135deg, #c2410c 0%, #9a3412 100%); } #naia-lightbox-overlay .action-btn.bg-blue { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); } #naia-lightbox-overlay .action-btn:hover:not(:disabled) { filter: brightness(1.1); } /* Mobile Responsive */ @media screen and (max-width: 900px) { #naia-lightbox-overlay { padding: 10px !important; align-items: center !important; justify-content: center !important; overflow: hidden !important; display: flex !important; } #naia-lightbox-overlay .lb-container { display: flex !important; flex-direction: column !important; flex-wrap: nowrap !important; gap: 6px !important; width: 100% !important; max-width: 100% !important; height: 100% !important; max-height: 100% !important; overflow: hidden !important; } #naia-lightbox-overlay .lb-image-area { order: 1 !important; display: flex !important; align-items: center !important; justify-content: center !important; flex: 1 1 auto !important; min-height: 0 !important; width: 100% !important; max-height: none !important; overflow: hidden !important; } #naia-lightbox-overlay .lb-image-area img { max-height: 100% !important; max-width: 100% !important; width: auto !important; height: auto !important; object-fit: contain !important; } #naia-lightbox-overlay .lb-controls { order: 2 !important; display: flex !important; width: 100% !important; flex-direction: row !important; justify-content: center !important; align-items: center !important; gap: 6px !important; flex: 0 0 auto !important; height: auto !important; padding-bottom: env(safe-area-inset-bottom, 0px) !important; } #naia-lightbox-overlay .lb-nav-row { display: none !important; } #naia-lightbox-overlay .lb-action-col { display: flex !important; width: 100% !important; flex-direction: row !important; gap: 6px !important; } #naia-lightbox-overlay .action-btn { flex: 1 !important; padding: 8px 4px !important; font-size: 0.65em !important; min-height: 44px !important; white-space: nowrap !important; } } """ CUSTOM_CSS += """ #naia-output-image { cursor: zoom-in !important; } """ + WILDCARD_CSS def format_random_status(rating: str, person: str, include_tags: list, exclude_tags: list, event_count: int) -> str: """Format the random status label""" rating_display = RATING_LABELS.get(rating, rating).upper()[0] person_display = PERSON_LABELS.get(person, person) # Format include tags (max 2 shown) if include_tags: if len(include_tags) <= 2: include_str = ", ".join(include_tags) else: include_str = f"{include_tags[0]}, {include_tags[1]}, ... ({len(include_tags)})" else: include_str = "none" # Format exclude tags (max 2 shown) if exclude_tags: if len(exclude_tags) <= 2: exclude_str = ", ".join(exclude_tags) else: exclude_str = f"{exclude_tags[0]}, {exclude_tags[1]}, ... ({len(exclude_tags)})" else: exclude_str = "none" return f"Random Rating: {rating_display}, Category: {person_display}, Total Events: {event_count:,}, Include: {include_str}, Exclude: {exclude_str}" def format_api_params( model: str, resolution: str, steps: int, scale: float, cfg_rescale: float, sampler: str, seed: int, prompt: str, negative: str ) -> str: """Format API parameters for display""" width, height = resolution.lower().replace(' ', '').split('x') model_id = MODEL_ID_MAP.get(model, model) return f"""Model: {model_id} Resolution: {width} x {height} Steps: {steps} CFG Scale: {scale} CFG Rescale: {cfg_rescale} Sampler: {sampler} Seed: {seed} Prompt Length: {len(prompt)} chars Negative Length: {len(negative)} chars""" def build_app() -> gr.Blocks: """ Build the main Gradio application. Layout Strategy: - Left column: Quick Search + Generation Settings (collapsible) - Right column: Output image, buttons, status, prompt input (always visible) - Resizable via CSS """ # Initialize services generation_service = GenerationService() _ensure_quick_search_data() tag_store = TagStore() with gr.Blocks(title="NAIA-WEB", css=get_css(), head=get_head()) as app: # Header gr.Markdown("# NAIA-WEB-Lite Random Image Generator") gr.Markdown("NAI Image Generation Web Interface for Hugging Face Spaces") with gr.Row(elem_classes=["resizable-row"]): # Left Column - Controls with gr.Column(min_width=280, elem_classes=["left-col"]): # API Settings Panel with gr.Accordion( "API Settings", open=False, elem_id="naia-api-settings-accordion" ): settings = create_settings_panel() # Quick Search with gr.Accordion( "Quick Search", open=False, elem_id="naia-quick-search-accordion", elem_classes=["quick-search-section"] ): # Quick Search contents (inline, not using create_quick_search) with gr.Group(): # Rating selection - default to Sensitive rating_choices = [(RATING_LABELS[r], r) for r in RATING_OPTIONS] qs_rating = gr.Radio( choices=rating_choices, value="s", # Sensitive default label="Rating", elem_id="naia-qs-rating" ) # Person category - default to 1girl_solo person_choices = [(PERSON_LABELS[p], p) for p in PERSON_CATEGORIES] qs_person = gr.Dropdown( choices=person_choices, value="1girl_solo", # 1 Girl Solo default label="Person Category", elem_id="naia-qs-person" ) # Include/Exclude tags with gr.Row(): qs_include = gr.Textbox( label="Include Tags", placeholder="tag1, tag2, ...", lines=2, elem_id="naia-qs-include" ) qs_exclude = gr.Textbox( label="Exclude Tags", placeholder="tag1, tag2, ...", lines=2, elem_id="naia-qs-exclude" ) # Refresh and Reset buttons with gr.Row(): qs_refresh_btn = gr.Button( "Refresh Tags", size="sm", elem_id="naia-qs-refresh" ) qs_reset_btn = gr.Button( "Reset", size="sm", variant="secondary", elem_id="naia-qs-reset" ) # Recommended tags display with pagination # Hidden textbox to store tags JSON for JS to read qs_cached_tags = gr.Textbox(visible=False, elem_id="naia-qs-cached-tags") # State to track current page qs_current_page = gr.State(value=1) # Pagination row - 3 equal parts with gr.Row(elem_classes=["qs-pagination"]): qs_prev_btn = gr.Button("<", size="sm", scale=1, elem_id="naia-qs-prev") qs_page_info = gr.HTML('1/1', elem_id="naia-qs-page-info-container") qs_next_btn = gr.Button(">", size="sm", scale=1, elem_id="naia-qs-next") # Tag button grid (rendered via HTML + JS) qs_tags_html = gr.HTML( '
', elem_id="naia-qs-tags-container" ) # Hidden textbox to receive tag click events from JS # Note: Using elem_classes to hide via CSS because visible=False may not render to DOM qs_tag_action = gr.Textbox(elem_id="naia-qs-tag-action", elem_classes=["hidden-input"]) # Global Ban Section gr.Markdown("### Global Ban (Be careful to insert correct tag)") global_ban = gr.Textbox( label="", show_label=False, placeholder="furry, guro, futanari, scat, cyclops, yaoi, manly", elem_id="naia-global-ban", lines=1, interactive=True ) # Quick Search usage guide (Accordion) with gr.Accordion("How to use Quick Search?", open=False, elem_id="naia-qs-guide"): gr.Markdown(""" **1.** NAIA-WEB generates images using random prompts via **Get Random Prompt**. **2.** Select **Rating** and **Person Category** to control content level and character composition. **3.** Add tags to **Include Tags** to only get prompts containing those tags. Add tags to **Exclude Tags** to filter out prompts with those tags. **4.** If you manually type tags, click **Refresh Tags** to update the tag list. **Tip:** Click any tag button above to quickly add it to Include or Exclude. """) # Generation Settings (without resolution - moved to prompt header) # Closed by default - settings load via Bridge Pattern (gr.update) with gr.Accordion( "Generation Settings", open=False, elem_id="naia-generation-settings-accordion", elem_classes=["generation-section"] ): gen = create_generation_panel() # History Widget (Row of 5 square thumbnails) with gr.Row(elem_classes=["history-row"], elem_id="naia-history-row"): history_slots = [] for i in range(5): img = gr.Image( type="filepath", label=f"History {i+1}", show_label=False, interactive=False, elem_id=f"naia-history-{i}", elem_classes=["history-item"] ) history_slots.append(img) # Right Column - Output and Actions with gr.Column(min_width=400): output = create_output_panel() output_image = output["image"] # History state history_data = gr.State(value=[]) # Action buttons row with gr.Row(elem_classes=["action-buttons-row"]): get_random_btn = gr.Button( "Get Random Prompt", variant="secondary", size="lg", elem_id="naia-get-random" ) generate_btn = gr.Button( "Generate", variant="primary", size="lg", elem_id="naia-generate-btn" ) random_generate_btn = gr.Button( "🎰 Random + Generate", variant="primary", size="lg", elem_id="naia-random-generate-btn" ) # Status labels row (Random + Character on same line with | separator) with gr.Row(elem_classes=["status-labels-row"]): random_status_label = gr.Markdown( "Random Rating: S, Category: 1 Girl, Solo, Include: none, Exclude: none, Total Events: --", elem_classes=["random-status-label"], elem_id="naia-random-status" ) gr.HTML('|') char_status_label = gr.Markdown( "Character Prompt: OFF | Character Reference: OFF", elem_classes=["random-status-label"], elem_id="naia-char-status" ) # Options bar: Resolution controls (and future controls) with gr.Row(elem_classes=["options-bar"]): gr.HTML('Resolution:') resolution = gr.Dropdown( choices=DEFAULT_RESOLUTIONS, value=DEFAULT_RESOLUTION, label="", show_label=False, allow_custom_value=False, container=False, elem_id="naia-resolution" ) random_resolution = gr.Checkbox( value=True, label="Random", container=False, elem_id="naia-random-resolution" ) manage_res_btn = gr.Button( "Manage", size="sm", elem_id="naia-manage-resolution" ) # Separator gr.HTML('|') # Character feature toggles activate_char_prompt = gr.Checkbox( value=False, label="Character Prompt", container=False, elem_id="naia-activate-char-prompt" ) activate_char_ref = gr.Checkbox( value=False, label="Character Reference", container=False, elem_id="naia-activate-char-ref" ) # Separator before Wildcard Mode gr.HTML('|') # Wildcard Mode checkbox wildcard_mode = gr.Checkbox( value=False, label="Wildcard Mode", container=False, elem_id="naia-wildcard-mode" ) # Auto Save checkbox auto_save = gr.Checkbox( value=False, label="Auto Save", container=False, interactive=True, elem_id="naia-auto-save" ) # Hidden state for enabled resolutions enabled_resolutions = gr.State(value=DEFAULT_RESOLUTIONS.copy()) # Resolution manager popup (hidden by default) with gr.Column(visible=False, elem_id="resolution-popup-container") as res_popup: gr.Markdown("#### Manage Resolutions") # Custom Resolution Section with gr.Group(): gr.Markdown("##### Custom Resolution") use_custom_res = gr.Checkbox( label="Use Custom Resolution", value=False, elem_id="naia-use-custom-res" ) with gr.Row(visible=False) as custom_res_inputs: custom_width = gr.Number( label="Width", value=1024, step=64, minimum=64, precision=0, elem_id="naia-custom-width" ) custom_height = gr.Number( label="Height", value=1024, step=64, minimum=64, precision=0, elem_id="naia-custom-height" ) custom_res_warning = gr.Markdown( "", visible=False, elem_id="naia-custom-res-warning" ) apply_custom_btn = gr.Button( "Apply Custom Resolution", size="sm", visible=False, elem_id="naia-apply-custom-btn" ) gr.HTML("
") gr.Markdown("Toggle which resolutions are available for random selection:") res_checkboxes = gr.CheckboxGroup( choices=DEFAULT_RESOLUTIONS, value=DEFAULT_RESOLUTIONS, label="", elem_id="naia-res-checkboxes" ) close_popup_btn = gr.Button("Close", size="sm", elem_id="naia-close-popup") # Wildcard Template Input (visible only when Wildcard Mode enabled) wc_template_components = create_wildcard_template_input() wc_template_group = wc_template_components["template_group"] wc_template_input = wc_template_components["template_input"] # Prompt section header gr.Markdown("### Prompt") # Prompt section (always visible, not in accordion) with gr.Group(elem_classes=["prompt-section", "prompt-tabs"]): prompt_components = create_prompt_tabs() positive_prompt = prompt_components["positive_prompt"] negative_prompt = prompt_components["negative_prompt"] pre_prompt = prompt_components["pre_prompt"] post_prompt = prompt_components["post_prompt"] auto_hide = prompt_components["auto_hide"] prompt_tabs = prompt_components["tabs"] # Random prompt filter checkboxes filter_characteristics = prompt_components["filter_characteristics"] filter_clothes = prompt_components["filter_clothes"] filter_location = prompt_components["filter_location"] # Character Prompt Panel (collapsible, shown when activated) # Multiple character slots with Add/Remove functionality MAX_CHAR_SLOTS = 6 with gr.Accordion( "Character Prompt", open=True, visible=False, elem_id="naia-char-prompt-accordion" ) as char_prompt_panel: gr.Markdown("Configure character-specific prompts (NAID4.5 only)") # Search / Add / Remove buttons with gr.Row(): char_search_btn = gr.Button( "πŸ” Search", size="sm", elem_id="naia-char-search-btn" ) add_char_btn = gr.Button( "+ Add", size="sm", elem_id="naia-add-char-btn" ) remove_char_btn = gr.Button( "- Remove", size="sm", elem_id="naia-remove-char-btn" ) char_slot_count = gr.Number( value=1, visible=False, elem_id="naia-char-slot-count" ) # Character Search Popup (hidden by default) with gr.Column(visible=False, elem_id="naia-char-search-popup") as char_search_popup: gr.Markdown("#### Character Search") with gr.Row(): char_search_input = gr.Textbox( label="", placeholder="Search character name...", lines=1, scale=4, elem_id="naia-char-search-input" ) char_search_go_btn = gr.Button( "Search", size="sm", scale=1, elem_id="naia-char-search-go" ) char_search_results = gr.Dataframe( headers=["Character", "Count"], datatype=["str", "number"], column_count=(2, "fixed"), row_count=(8, "fixed"), interactive=False, elem_id="naia-char-search-results" ) char_search_detail = gr.Textbox( label="Selected Character", placeholder="Click a character from the list above", lines=3, interactive=False, elem_id="naia-char-search-detail" ) with gr.Row(): char_search_copy_btn = gr.Button( "πŸ“‹ Copy", variant="primary", size="sm", elem_id="naia-char-search-copy" ) char_search_close_btn = gr.Button( "Close", size="sm", elem_id="naia-char-search-close" ) # Character slots (pre-created, visibility controlled) char_slots = [] char_enables = [] char_prompts = [] char_negatives = [] for i in range(MAX_CHAR_SLOTS): with gr.Group( visible=(i == 0), elem_id=f"naia-char-slot-{i}" ) as slot: enable = gr.Checkbox( value=(i == 0), # Only C1 enabled by default label=f"C{i+1}", container=False, elem_id=f"naia-char-enable-{i}" ) prompt = gr.Textbox( label="Prompt", placeholder=f"Character {i+1} tags (e.g., 1girl, blonde hair, blue eyes, ...)", lines=2, elem_id=f"naia-char-prompt-{i}" ) negative = gr.Textbox( label="Negative (optional)", placeholder="Leave empty to use default", lines=1, elem_id=f"naia-char-negative-{i}" ) char_slots.append(slot) char_enables.append(enable) char_prompts.append(prompt) char_negatives.append(negative) # Character Reference Panel (collapsible, shown when activated) # Reference image for character generation (NAID4.5 only) with gr.Accordion( "Character Reference", open=True, visible=False, elem_id="naia-char-ref-accordion" ) as char_ref_panel: gr.Markdown("Upload reference image for character (NAID4.5 only)") # Controls first (above image) for better interaction with gr.Row(): char_ref_style_aware = gr.Checkbox( value=True, label="Style Aware", info="Include style from reference", interactive=True, elem_id="naia-char-ref-style-aware" ) char_ref_fidelity = gr.Slider( minimum=0.0, maximum=1.0, value=0.75, step=0.05, label="Fidelity", info="How closely to follow the reference", interactive=True, elem_id="naia-char-ref-fidelity" ) # Image below controls (upload only, no webcam) char_ref_image = gr.Image( label="Reference Image", type="filepath", height=200, sources=["upload"], elem_id="naia-char-ref-image" ) # Wildcard Manager (collapsible, above Generation Info) with gr.Accordion( "Wildcard Manager", open=False, elem_id="naia-wildcard-accordion" ): wc_panel = create_wildcard_panel() # Generation Info (collapsible, at bottom) gen_info = create_generation_info_panel() # Settings (collapsible) with gr.Accordion("Settings", open=False, elem_id="naia-settings-accordion"): disable_autocomplete = gr.Checkbox( value=False, label="Disable Autocomplete", container=False, interactive=True, elem_id="naia-disable-autocomplete" ) # Hidden bridge component for settings loading # This receives localStorage data from JS and passes to Python settings_bridge = gr.Textbox(visible=False, elem_id="naia-settings-bridge") # Hidden components for autocomplete (visible=True but hidden via CSS) # Gradio's visible=False doesn't render to DOM, so we use CSS to hide # Query is sent from JS, results are returned to JS autocomplete_query = gr.Textbox( visible=True, elem_id="naia-autocomplete-query", elem_classes=["hidden-component"] ) autocomplete_results = gr.Textbox( visible=True, elem_id="naia-autocomplete-results", elem_classes=["hidden-component"] ) # Hidden component for wildcard expanded prompt (JS β†’ Python) wc_expanded_prompt = gr.Textbox( visible=True, elem_id="naia-wc-expanded-prompt", elem_classes=["hidden-component"] ) # ========== Event Handlers ========== # ========== History Restore Handler ========== def make_restore_func(idx): def restore(history): if idx >= len(history): return ( gr.update(), gr.update(), gr.update(), gr.update(), gr.update() ) data = history[idx] # Use original prompts for restoration restore_pos = data.get("original_pos", data["pos"]) restore_neg = data.get("original_neg", data["neg"]) return ( data["image"], data["info"], restore_pos, restore_neg, data["pos"], # Processed Pos data["neg"], # Processed Neg gr.update(value=data["dl_path"], visible=True) ) return restore for i, slot in enumerate(history_slots): slot.select( fn=make_restore_func(i), inputs=[history_data], outputs=[ output_image, gen_info["info"], positive_prompt, negative_prompt, gen_info["processed_prompt"], gen_info["processed_negative"], output["download_btn"] ] ) # ========== Lightbox JS ========== # Create global overlay and attach listeners LIGHTBOX_JS = """ () => { const setupLightbox = () => { let overlay = document.getElementById('naia-lightbox-overlay'); if (overlay) return; // Create Overlay Structure overlay = document.createElement('div'); overlay.id = 'naia-lightbox-overlay'; // HTML Content overlay.innerHTML = `
Preview
`; document.body.appendChild(overlay); // Elements const img = overlay.querySelector('img'); const prevBtn = overlay.querySelector('#lb-prev'); const nextBtn = overlay.querySelector('#lb-next'); const regenBtn = overlay.querySelector('#lb-regen'); const randomBtn = overlay.querySelector('#lb-random'); const downloadBtn = overlay.querySelector('#lb-download'); let currentIndex = 0; // 0 = Newest let isGenerating = false; // --- Helper Functions --- // Determine current index by matching src with history slots const syncIndex = () => { const currentSrc = img.src; // Decode src to handle URL encoding differences const decodedCurrent = decodeURIComponent(currentSrc); currentIndex = 0; // Default to newest for (let i = 0; i < 5; i++) { const slot = document.querySelector(`#naia-history-${i} img`); if (slot) { // Check exact match or endsWith match (for relative/absolute diffs) const slotSrc = decodeURIComponent(slot.src); if (slotSrc === decodedCurrent || (slotSrc.length > 20 && decodedCurrent.endsWith(slotSrc.split('/').pop()))) { currentIndex = i; break; } } } updateNavButtons(); }; const updateNavButtons = () => { // Start with basic bounds check let canGoPrev = currentIndex < 4; // Can go older let canGoNext = currentIndex > 0; // Can go newer // Logic check: Next slot must create image if (canGoPrev) { const prevSlotImg = document.querySelector(`#naia-history-${currentIndex + 1} img`); if (!prevSlotImg || !prevSlotImg.src) canGoPrev = false; } prevBtn.disabled = !canGoPrev || isGenerating; nextBtn.disabled = !canGoNext || isGenerating; regenBtn.disabled = isGenerating; randomBtn.disabled = isGenerating; }; const setGeneratingState = (generating) => { isGenerating = generating; updateNavButtons(); if (generating) { regenBtn.innerHTML = "..."; randomBtn.innerHTML = "..."; } else { regenBtn.innerHTML = "Re
Gen"; randomBtn.innerHTML = "🎰 Rand
Gen"; } }; // Dynamic image sizing for HuggingFace iframe compatibility const adjustImageSize = () => { const docWidth = document.documentElement.clientWidth || window.innerWidth; // Mobile: let CSS handle it (vh works correctly on mobile) if (docWidth < 900) { img.style.maxHeight = ''; // Clear JS override, use CSS return; } // Desktop: check for HuggingFace iframe const MAX_HEIGHT = 1000; const docHeight = document.documentElement.scrollHeight || document.body.scrollHeight; let viewportHeight = window.innerHeight; // Detect HuggingFace iframe: height much larger than typical viewport const isHFIframe = window.self !== window.top && viewportHeight > 1500 && Math.abs(docHeight - viewportHeight) < 100; if (isHFIframe) { // HuggingFace auto-resize iframe - estimate from width viewportHeight = Math.min(Math.round(docWidth * 0.55), 900); } const targetHeight = Math.min(viewportHeight * 0.95, MAX_HEIGHT); img.style.maxHeight = targetHeight + 'px'; }; // Apply sizing on image load img.onload = () => { adjustImageSize(); }; const navigate = (direction) => { if (isGenerating) return; const newIndex = currentIndex + direction; if (newIndex < 0 || newIndex > 4) return; const targetSlot = document.querySelector(`#naia-history-${newIndex}`); if (targetSlot) { // Robust click simulation for Gradio 4/5 structure // 1. Try finding the internal image - usually the trigger for 'select' const internalImg = targetSlot.querySelector('img'); if (internalImg) { internalImg.click(); } else { // 2. Fallback to container click with proper event dispatch targetSlot.click(); // Dispatch explicit check for custom handlers targetSlot.dispatchEvent(new MouseEvent('click', { view: window, bubbles: true, cancelable: true })); } } }; // --- Event Listeners --- prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); }); nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); }); regenBtn.addEventListener('click', (e) => { e.stopPropagation(); if (isGenerating) return; setGeneratingState(true); const btn = document.getElementById('naia-generate-btn'); if (btn) btn.click(); }); randomBtn.addEventListener('click', (e) => { e.stopPropagation(); if (isGenerating) return; setGeneratingState(true); const btn = document.getElementById('naia-random-generate-btn'); if (btn) btn.click(); }); downloadBtn.addEventListener('click', (e) => { e.stopPropagation(); // For newest image (index 0), use main download button if (currentIndex === 0) { const mainDownloadLink = document.querySelector('#naia-download-btn a[download]'); if (mainDownloadLink && mainDownloadLink.href) { mainDownloadLink.click(); return; } } // For history images, get the file path from history slot // History slots use type="filepath", so img.src contains the actual path const historySlot = document.querySelector(`#naia-history-${currentIndex} img`); if (historySlot && historySlot.src) { const link = document.createElement('a'); link.href = historySlot.src; // Extract filename from path (e.g., naia_12345_abc.png) const urlPath = new URL(historySlot.src, window.location.origin).pathname; const filename = urlPath.split('/').pop() || `naia_image_${Date.now()}.png`; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); return; } // Final fallback: use lightbox img.src const link = document.createElement('a'); link.href = img.src; link.download = `naia_image_${Date.now()}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); // Close on background click overlay.addEventListener('click', (e) => { const closeTargets = ['lb-container', 'lb-image-area', 'lb-controls', 'lb-nav-row', 'lb-action-col']; if (e.target === overlay || closeTargets.some(cls => e.target.classList.contains(cls))) { overlay.classList.remove('active'); // Restore scrolling document.body.style.overflow = ''; // Reset manual positioning styles overlay.style.position = ''; overlay.style.top = ''; overlay.style.left = ''; overlay.style.width = ''; overlay.style.height = ''; } }); // --- Viewport Positioning --- // HuggingFace Spaces uses auto-resizing iframes where fixed positioning fails // All viewport measurements (innerHeight, 100vh, visualViewport) return document height const isInIframe = window.self !== window.top; // For HuggingFace iframe: estimate viewport height from document width // Typical aspect ratios: 16:9 (desktop), 4:3 (tablet), or use reasonable default const getEstimatedViewportHeight = () => { if (window.visualViewport && window.visualViewport.height) { return window.visualViewport.height; } const docWidth = document.documentElement.clientWidth || window.innerWidth; const innerH = window.innerHeight; // If innerHeight is much larger than width, we're in auto-resize iframe if (innerH > docWidth * 1.5 && innerH > 1000) { // Mobile Portrait (e.g. 390px width) if (docWidth < 600) return Math.min(docWidth * 2.2, innerH); // Desktop/Tablet (e.g. >900px, but expanded iframe) return Math.min(Math.round(docWidth * 0.625), 900); } return innerH; }; const updateOverlayPosition = (clickEvent) => { if (!overlay.classList.contains('active')) return; const viewportWidth = document.documentElement.clientWidth || window.innerWidth; // Always prefer visualViewport height if available for mobile correctness const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; const docHeight = document.documentElement.scrollHeight; // Detect giant iframe scenario (Auto-resizing iframe on HF) // Condition: Iframe is much taller than a typical screen (>1500px) AND // current window.innerHeight is also huge (meaning it's reporting the full iframe height, not viewport) const isAutoResizedIframe = isInIframe && docHeight > 1500 && window.innerHeight > 1200; const lbContainer = overlay.querySelector('.lb-container'); if (isAutoResizedIframe) { // HuggingFace auto-resize iframe: // 1. Make the overlay cover the WHOLE document (backdrop) overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = docHeight + 'px'; // 2. Position the CONTAINER (image wrapper) at the click level if (lbContainer) { lbContainer.style.position = 'absolute'; lbContainer.style.left = '0'; lbContainer.style.width = '100%'; let targetTop = 0; if (clickEvent && clickEvent.pageY !== undefined) { // Center roughly around the click. // Assuming a typical image/modal height of ~600-800px max on mobile, // we want the center of the image to be at the click. // Let's offset by ~350px (approx half viewport) targetTop = clickEvent.pageY - 350; } else if (overlay._lastTarget) { try { const rect = overlay._lastTarget.getBoundingClientRect(); // In this environment, rect.top is effectively pageY targetTop = rect.top - 350; } catch(e) { targetTop = docHeight / 2; // Fallback to middle } } // Clamp container top targetTop = Math.max(10, targetTop); if (targetTop + 600 > docHeight) { // heuristic end check targetTop = docHeight - 800; } lbContainer.style.top = targetTop + 'px'; // Remove flex centering effects which might fight with absolute top overlay.style.alignItems = 'flex-start'; } } else { // Normal browser or regular iframe: fixed positioning works overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.alignItems = 'center'; // Restore flex centering // Reset container overrides if (lbContainer) { lbContainer.style.position = ''; lbContainer.style.top = ''; lbContainer.style.left = ''; lbContainer.style.width = ''; } } // Adaptive Width Constraint for large screens if (lbContainer) { if (viewportWidth > 2000) { lbContainer.style.maxWidth = '1800px'; lbContainer.style.margin = isAutoResizedIframe ? '0 auto' : '0 auto'; } else { lbContainer.style.maxWidth = '100%'; lbContainer.style.margin = isAutoResizedIframe ? '0': '0'; } } }; // Listen for viewport changes (F11, window resize, zoom, scroll, etc.) if (window.visualViewport) { window.visualViewport.addEventListener('resize', updateOverlayPosition); window.visualViewport.addEventListener('scroll', updateOverlayPosition); } window.addEventListener('resize', updateOverlayPosition); window.addEventListener('scroll', updateOverlayPosition); // For iframe absolute positioning document.addEventListener('fullscreenchange', updateOverlayPosition); // --- Main Trigger --- document.addEventListener('click', (e) => { const target = e.target; overlay._lastTarget = target; // Store for positioning const container = document.querySelector('#naia-output-image'); const isMainImage = container && container.contains(target) && target.tagName === 'IMG'; if (isMainImage && target.src) { img.src = target.src; // Prevent body scroll document.body.style.overflow = 'hidden'; // Show overlay (CSS .active handles display: flex) overlay.classList.add('active'); // Apply positioning based on environment updateOverlayPosition(e); // Pass click event for coordinate calculation // Apply dynamic image sizing adjustImageSize(); // Sync generating state from main UI // Sync generating state from main UI const mainGenerating = window.NAIAGenerate && window.NAIAGenerate.isGenerating(); if (!mainGenerating) { setGeneratingState(false); } else { setGeneratingState(true); } syncIndex(); e.preventDefault(); e.stopPropagation(); } }, true); // --- Improved Auto Update Observer --- const observerConfig = { attributes: true, childList: true, subtree: true, attributeFilter: ['src'] }; const outputContainer = document.querySelector('#naia-output-image'); const outputObserver = new MutationObserver((mutations) => { let updated = false; for (const mutation of mutations) { // 1. Check if IMG src attribute changed if (mutation.type === 'attributes' && mutation.attributeName === 'src' && mutation.target.tagName === 'IMG') { img.src = mutation.target.src; updated = true; } // 2. Check if new IMG node added (Gradio often replaces the whole IMG) if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.tagName === 'IMG') { img.src = node.src; updated = true; } else if (node.querySelector) { const nestedImg = node.querySelector('img'); if (nestedImg) { img.src = nestedImg.src; updated = true; } } }); } // 3. Check for specific Gradio loading states or buttons to re-enable } if (updated) { setGeneratingState(false); // Enable buttons if (overlay.classList.contains('active')) { syncIndex(); } } }); if (outputContainer) { outputObserver.observe(outputContainer, observerConfig); } else { // Fallback polling if container not ready setTimeout(() => { const container = document.querySelector('#naia-output-image'); if (container) outputObserver.observe(container, observerConfig); }, 1000); } }; if (document.readyState === 'complete') { setupLightbox(); } else { window.addEventListener('load', setupLightbox); } } """ app.load(None, None, None, js=LIGHTBOX_JS) # Token management settings["save_btn"].click( fn=lambda token: f"Token saved ({len(token)} chars)" if token else "No token to save", inputs=[settings["token_input"]], outputs=[settings["status"]], js="(token) => { if(token) localStorage.setItem('naia_api_token', token); window.NAIAUpdateLabel && window.NAIAUpdateLabel(true); return token; }" ) settings["clear_btn"].click( fn=lambda: ("", "Token cleared"), inputs=[], outputs=[settings["token_input"], settings["status"]], js="() => { localStorage.removeItem('naia_api_token'); window.NAIAUpdateLabel && window.NAIAUpdateLabel(false); return []; }" ) # ========== Resolution Handlers ========== # Manual resolution selection -> uncheck Random resolution.change( fn=lambda: False, inputs=[], outputs=[random_resolution] ) # Manage button -> show popup manage_res_btn.click( fn=lambda: gr.update(visible=True), inputs=[], outputs=[res_popup] ) # Close popup close_popup_btn.click( fn=lambda: gr.update(visible=False), inputs=[], outputs=[res_popup] ) # Update enabled resolutions from checkboxes res_checkboxes.change( fn=lambda checked: checked if checked else DEFAULT_RESOLUTIONS.copy(), inputs=[res_checkboxes], outputs=[enabled_resolutions] ) # ========== Custom Resolution Handlers ========== def toggle_custom_inputs(enabled): return gr.update(visible=enabled), gr.update(visible=enabled) use_custom_res.change( fn=toggle_custom_inputs, inputs=[use_custom_res], outputs=[custom_res_inputs, apply_custom_btn] ) def validate_custom_res(w, h): if w is None or h is None: return gr.update(visible=False, value="") msg = "" # Check 64 multiple if w % 64 != 0 or h % 64 != 0: msg += "⚠️ Resolution must be a multiple of 64.
" # Check 1MP limit pixels = w * h if pixels > 1024 * 1024: msg += "πŸ”΄ Resolution exceeds 1MP (1024x1024). This will incur Anlas cost!" return gr.update(value=msg, visible=bool(msg)) custom_width.change( fn=validate_custom_res, inputs=[custom_width, custom_height], outputs=[custom_res_warning] ) custom_height.change( fn=validate_custom_res, inputs=[custom_width, custom_height], outputs=[custom_res_warning] ) def apply_custom_resolution(w, h): # Enforce 64 multiple (round to nearest) w = int(round(w / 64) * 64) h = int(round(h / 64) * 64) res_str = f"{w} x {h}" # Add to choices if not present new_choices = DEFAULT_RESOLUTIONS.copy() if res_str not in new_choices: new_choices.append(res_str) return ( gr.update(choices=new_choices, value=res_str), # Update dropdown False, # Uncheck Random gr.update(visible=False) # Close popup ) apply_custom_btn.click( fn=apply_custom_resolution, inputs=[custom_width, custom_height], outputs=[resolution, random_resolution, res_popup] ) # ========== Character Panel Handlers ========== # Helper function to build character status text def build_char_status(char_prompt_active, char_ref_active, char_ref_img, *enables): """Build character status label text""" parts = [] # Character Prompt status if char_prompt_active: active_count = sum(1 for e in enables if e) parts.append(f"Character Prompt: {active_count} slot(s)") else: parts.append("Character Prompt: OFF") # Character Reference status if char_ref_active and char_ref_img: parts.append("Character Reference: Loaded") elif char_ref_active: parts.append("Character Reference: No Image") else: parts.append("Character Reference: OFF") return " | ".join(parts) # Toggle Character Prompt panel visibility + update status def toggle_char_prompt_panel(active, char_ref_active, char_ref_img, *enables): status = build_char_status(active, char_ref_active, char_ref_img, *enables) return gr.update(visible=active), status activate_char_prompt.change( fn=toggle_char_prompt_panel, inputs=[activate_char_prompt, activate_char_ref, char_ref_image, *char_enables], outputs=[char_prompt_panel, char_status_label] ) # Toggle Character Reference panel visibility + update status def toggle_char_ref_panel(char_ref_active, char_prompt_active, char_ref_img, *enables): status = build_char_status(char_prompt_active, char_ref_active, char_ref_img, *enables) return gr.update(visible=char_ref_active), status activate_char_ref.change( fn=toggle_char_ref_panel, inputs=[activate_char_ref, activate_char_prompt, char_ref_image, *char_enables], outputs=[char_ref_panel, char_status_label] ) # Update status when character reference image changes def update_char_ref_status(char_ref_img, char_prompt_active, char_ref_active, *enables): return build_char_status(char_prompt_active, char_ref_active, char_ref_img, *enables) char_ref_image.change( fn=update_char_ref_status, inputs=[char_ref_image, activate_char_prompt, activate_char_ref, *char_enables], outputs=[char_status_label] ) # Toggle Wildcard Mode - show/hide template input def toggle_wildcard_mode(is_active): return gr.update(visible=is_active) wildcard_mode.change( fn=toggle_wildcard_mode, inputs=[wildcard_mode], outputs=[wc_template_group], js="(isActive) => { if (window.NAIASettings) window.NAIASettings.save(); return [isActive]; }" ) # Update status when any character enable checkbox changes for enable_cb in char_enables: enable_cb.change( fn=lambda *args: build_char_status(args[0], args[1], args[2], *args[3:]), inputs=[activate_char_prompt, activate_char_ref, char_ref_image, *char_enables], outputs=[char_status_label] ) # Add Character slot def add_character_slot(current_count, char_prompt_active, char_ref_active, char_ref_img, *enables): current_count = int(current_count) new_count = min(current_count + 1, MAX_CHAR_SLOTS) # Return visibility updates for all slots + new count + status updates = [gr.update(visible=(i < new_count)) for i in range(MAX_CHAR_SLOTS)] updates.append(new_count) # New slot is enabled by default, so add 1 to current active count # Count only currently enabled slots that will remain visible, plus the new one current_active = sum(1 for i, e in enumerate(enables) if e and i < current_count) new_enables = list(enables) if new_count > current_count: new_enables[new_count - 1] = True # New slot enabled by default status = build_char_status(char_prompt_active, char_ref_active, char_ref_img, *new_enables) updates.append(status) return updates add_char_btn.click( fn=add_character_slot, inputs=[char_slot_count, activate_char_prompt, activate_char_ref, char_ref_image, *char_enables], outputs=char_slots + [char_slot_count, char_status_label] ) # Remove Character slot def remove_character_slot(current_count, char_prompt_active, char_ref_active, char_ref_img, *enables): current_count = int(current_count) new_count = max(current_count - 1, 1) # Minimum 1 slot # Return visibility updates for all slots + new count + status updates = [gr.update(visible=(i < new_count)) for i in range(MAX_CHAR_SLOTS)] updates.append(new_count) # Only count enabled slots that will remain visible visible_enables = [enables[i] if i < new_count else False for i in range(MAX_CHAR_SLOTS)] status = build_char_status(char_prompt_active, char_ref_active, char_ref_img, *visible_enables) updates.append(status) return updates remove_char_btn.click( fn=remove_character_slot, inputs=[char_slot_count, activate_char_prompt, activate_char_ref, char_ref_image, *char_enables], outputs=char_slots + [char_slot_count, char_status_label] ) # Character Search popup handlers char_search_btn.click( fn=lambda: gr.update(visible=True), inputs=[], outputs=[char_search_popup] ) char_search_close_btn.click( fn=lambda: gr.update(visible=False), inputs=[], outputs=[char_search_popup] ) # Character search function using danbooru_character data def search_characters(query): """Search characters by name using character_store""" store = get_character_store() if query and len(query) >= 2: results = store.search(query, min_count=20, limit=50) else: # Show popular characters when no query results = store.get_popular_characters(limit=50) return [[r.name, r.count] for r in results] char_search_go_btn.click( fn=search_characters, inputs=[char_search_input], outputs=[char_search_results] ) # Also search on Enter key in search input char_search_input.submit( fn=search_characters, inputs=[char_search_input], outputs=[char_search_results] ) # Show character details when selected from list def show_character_detail(evt: gr.SelectData): """Show selected character's name and tags (comma-separated)""" if evt.value: char_name = evt.row_value[0] if evt.row_value else evt.value store = get_character_store() char_info = store.get_character(char_name) if char_info and char_info.tags: return f"{char_info.name}, {char_info.tags}" return char_name return "" char_search_results.select( fn=show_character_detail, inputs=[], outputs=[char_search_detail] ) # Copy to clipboard char_search_copy_btn.click( fn=lambda x: x, # Pass through inputs=[char_search_detail], outputs=[], js="""(text) => { if (text) { navigator.clipboard.writeText(text).then(() => { console.log('NAIA-WEB: Copied to clipboard'); }); } return []; }""" ) # ========== Quick Search Handlers ========== TAGS_PER_PAGE = 20 # 2 columns x 10 rows # Cache for tag list (avoids re-filtering on every page) # Key: (rating, person, include_text, exclude_text) # Value: {"all_tags": [...], "event_count": int, "random_label": str} _qs_cache = {"key": None, "data": None} def _get_cached_tags(rating, person, include_text, exclude_text): """Get cached tag list or compute and cache if needed.""" cache_key = (rating, person, include_text, exclude_text) # Return cached data if key matches if _qs_cache["key"] == cache_key and _qs_cache["data"] is not None: return _qs_cache["data"] # Compute fresh data include_tags = parse_tag_input(include_text) exclude_tags = parse_tag_input(exclude_text) # Add auto-tags for person category (these are already indexed, shouldn't appear in list) auto_tags = PERSON_AUTO_TAGS.get(person, []) include_tags_with_auto = list(set(include_tags + auto_tags)) if not tag_store.is_available() or not tag_store.load_partition(rating, person): data = { "all_tags": [], "event_count": 0, "random_label": format_random_status(rating, person, include_tags, exclude_tags, 0) } _qs_cache["key"] = cache_key _qs_cache["data"] = data return data # Fetch ALL tags at once (expensive but only done once per filter change) # Use include_tags_with_auto to exclude auto-tags from results event_count = tag_store.get_filtered_event_count(include_tags_with_auto, exclude_tags) all_tags = tag_store.get_top_tags(include_tags_with_auto, exclude_tags, limit=20000, offset=0) random_label = format_random_status(rating, person, include_tags, exclude_tags, event_count) data = { "all_tags": [[t.tag, t.count] for t in all_tags], "event_count": event_count, "random_label": random_label } _qs_cache["key"] = cache_key _qs_cache["data"] = data return data def fetch_tags_page(rating, person, include_text, exclude_text, page): """Fetch one page of tags (20 items) from cache. Returns JSON with tags and pagination info.""" import json # Get cached tags (computes once per filter, then reuses) cached = _get_cached_tags(rating, person, include_text, exclude_text) all_tags = cached["all_tags"] total_tags = len(all_tags) total_pages = max(1, (total_tags + TAGS_PER_PAGE - 1) // TAGS_PER_PAGE) # Ensure page is within valid range page = max(1, int(page) if page else 1) page = min(page, total_pages) offset = (page - 1) * TAGS_PER_PAGE # Slice cached tags for this page (instant) page_tags = all_tags[offset:offset + TAGS_PER_PAGE] result = { "tags": page_tags, "page": page, "total_pages": total_pages, "event_count": cached["event_count"] } return json.dumps(result), cached["random_label"] def fetch_first_page(rating, person, include_text, exclude_text): """Fetch first page of tags (invalidates cache on filter change).""" try: # Clear cache to force fresh fetch on filter change _qs_cache["key"] = None _qs_cache["data"] = None return fetch_tags_page(rating, person, include_text, exclude_text, 1) except Exception as e: print(f"NAIA-WEB: Quick Search fetch failed: {e}") import traceback traceback.print_exc() # Return empty/error state to unblock UI return '{"tags": [], "page": 1, "total_pages": 1, "event_count": 0}', "Error fetching tags" def generate_quick_search_prompt( rating, person, include_text, exclude_text, auto_hide_text, filter_chars=False, filter_clothes=False, filter_loc=False ): """Generate random prompt from Quick Search with Auto-hide applied and rating suffix""" from core.prompt_processor import PromptProcessor, PromptContext from data.tag_store import get_filter_manager include_tags = parse_tag_input(include_text) exclude_tags = parse_tag_input(exclude_text) result = tag_store.generate_random_prompt( rating=rating, person=person, include_tags=include_tags, exclude_tags=exclude_tags ) if result.success: # Get tags list for filtering tags_list = result.tags if result.tags else result.prompt.split(", ") # Apply random prompt filters (character features, clothes, location) if filter_chars or filter_clothes or filter_loc: filter_manager = get_filter_manager() tags_list, removed = filter_manager.filter_tags( tags_list, remove_characteristics=filter_chars, remove_clothes=filter_clothes, remove_location_background=filter_loc ) prompt = ", ".join(tags_list) # Apply Auto-hide if configured auto_hide_text = auto_hide_text or "" if auto_hide_text.strip(): auto_hide_tags = {t.strip() for t in auto_hide_text.split(',') if t.strip()} processor = PromptProcessor() context = PromptContext( positive_prompt=prompt, negative_prompt="", use_quality_tags=False, auto_hide_tags=auto_hide_tags ) # Only apply auto-hide step context = processor._remove_auto_hide_tags(context) context = processor._cleanup_prompt(context) prompt = context.positive_prompt # Add rating suffix tags to the end of prompt rating_tags = RATING_SUFFIX_TAGS.get(rating, []) if rating_tags: prompt = prompt + ", " + ", ".join(rating_tags) random_label = format_random_status(rating, person, include_tags, exclude_tags, result.event_count) return prompt, random_label else: return "", f"Error: {result.error_message}" def generate_wildcard_prompt(expanded_prompt: str, auto_hide_text: str): """ Process expanded wildcard prompt (already expanded by JS). Applies Auto-hide and returns the final prompt. """ from core.prompt_processor import PromptProcessor, PromptContext if not expanded_prompt or not expanded_prompt.strip(): return "", "Wildcard Mode: Template is empty" prompt = expanded_prompt.strip() # Apply Auto-hide if configured auto_hide_text = auto_hide_text or "" if auto_hide_text.strip(): auto_hide_tags = {t.strip() for t in auto_hide_text.split(',') if t.strip()} processor = PromptProcessor() context = PromptContext( positive_prompt=prompt, negative_prompt="", use_quality_tags=False, auto_hide_tags=auto_hide_tags ) context = processor._remove_auto_hide_tags(context) context = processor._cleanup_prompt(context) prompt = context.positive_prompt return prompt, "Wildcard Mode: Template expanded" def handle_tag_action(action_str, rating, person, include_text, exclude_text): """Handle tag action from JS. action_str format: 'include:tagname' or 'exclude:tagname'""" if not action_str or ':' not in action_str: return gr.update(), gr.update(), gr.update(), gr.update(), gr.update() action, tag = action_str.split(':', 1) if action == "include": current_tags = parse_tag_input(include_text) if tag not in current_tags: current_tags.append(tag) new_include = ", ".join(current_tags) # Fetch first page with new filters tags_json, random_label = fetch_first_page(rating, person, new_include, exclude_text) return new_include, exclude_text, tags_json, random_label, 1 # Reset to page 1 elif action == "exclude": current_tags = parse_tag_input(exclude_text) if tag not in current_tags: current_tags.append(tag) new_exclude = ", ".join(current_tags) # Fetch first page with new filters tags_json, random_label = fetch_first_page(rating, person, include_text, new_exclude) return include_text, new_exclude, tags_json, random_label, 1 # Reset to page 1 return gr.update(), gr.update(), gr.update(), gr.update(), gr.update() def go_prev_page(rating, person, include_text, exclude_text, current_page): """Go to previous page""" new_page = max(1, current_page - 1) tags_json, random_label = fetch_tags_page(rating, person, include_text, exclude_text, new_page) return tags_json, new_page def go_next_page(rating, person, include_text, exclude_text, current_page): """Go to next page""" new_page = current_page + 1 tags_json, random_label = fetch_tags_page(rating, person, include_text, exclude_text, new_page) return tags_json, new_page # Helper functions to disable/enable action buttons def disable_action_buttons(): """Disable all action buttons during operation""" return ( gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False) ) def enable_action_buttons(): """Re-enable all action buttons after operation""" return ( gr.update(interactive=True), gr.update(interactive=True), gr.update(interactive=True) ) def handle_get_random_prompt( is_wildcard_mode: bool, wc_expanded: str, rating, person, include_text, exclude_text, auto_hide_text, filter_chars: bool, filter_clothes: bool, filter_loc: bool ): """ Unified handler for Get Random Prompt button. In Wildcard Mode: uses expanded prompt from JS Otherwise: uses Quick Search random generation """ if is_wildcard_mode and wc_expanded and wc_expanded.strip(): # Wildcard Mode: process the JS-expanded prompt return generate_wildcard_prompt(wc_expanded, auto_hide_text) else: # Normal Mode: Quick Search random generation return generate_quick_search_prompt( rating, person, include_text, exclude_text, auto_hide_text, filter_chars, filter_clothes, filter_loc ) # JavaScript to check wildcard mode and expand template before calling Python GET_RANDOM_JS = """ (is_wc_mode, wc_expanded, rating, person, include, exclude, auto_hide, f_chars, f_clothes, f_loc) => { // Save settings if (window.NAIASettings) window.NAIASettings.save(); // Check wildcard mode checkbox const wcModeContainer = document.querySelector('#naia-wildcard-mode'); const wcModeCheckbox = wcModeContainer ? wcModeContainer.querySelector('input[type="checkbox"]') : null; const isWildcardMode = wcModeCheckbox ? wcModeCheckbox.checked : false; if (isWildcardMode && window.NAIAWildcard) { // Get template from input const templateContainer = document.querySelector('#naia-wildcard-template'); const templateInput = templateContainer ? templateContainer.querySelector('textarea') : null; const template = templateInput ? templateInput.value : ''; // Expand wildcards const expanded = window.NAIAWildcard.expand(template); return [true, expanded, rating, person, include, exclude, auto_hide, f_chars, f_clothes, f_loc]; } // Normal mode: pass through with empty expanded return [false, '', rating, person, include, exclude, auto_hide, f_chars, f_clothes, f_loc]; } """ # Get Random Prompt button - applies Auto-hide and saves settings get_random_btn.click( fn=disable_action_buttons, inputs=[], outputs=[get_random_btn, generate_btn, random_generate_btn] ).then( fn=handle_get_random_prompt, inputs=[ wildcard_mode, wc_expanded_prompt, qs_rating, qs_person, qs_include, qs_exclude, auto_hide, filter_characteristics, filter_clothes, filter_location ], outputs=[positive_prompt, random_status_label], js=GET_RANDOM_JS ).then( fn=enable_action_buttons, inputs=[], outputs=[get_random_btn, generate_btn, random_generate_btn] ) # Outputs for fetch_tags_page fetch_outputs = [qs_cached_tags, random_status_label] # JS code to render tags from server response RENDER_TAGS_JS = """(tagsJson) => { if (window.NAIAQuickSearch && tagsJson) { try { const data = JSON.parse(tagsJson); window.NAIAQuickSearch.setPageData(data.tags, data.page, data.total_pages, data.event_count); } catch(e) { console.log('NAIA-WEB: Error parsing tags:', e); } } return []; }""" # Quick Search controls to disable during loading qs_controls = [qs_rating, qs_person, qs_prev_btn, qs_next_btn, qs_refresh_btn, qs_reset_btn] def disable_qs_controls(): """Disable Quick Search controls during loading""" return [gr.update(interactive=False) for _ in qs_controls] def enable_qs_controls(): """Re-enable Quick Search controls after loading""" return [gr.update(interactive=True) for _ in qs_controls] def reset_tags(): """Reset include/exclude tags to empty""" return "", "" # Reset button - clear include/exclude and refresh qs_reset_btn.click( fn=disable_qs_controls, inputs=[], outputs=qs_controls ).then( fn=reset_tags, inputs=[], outputs=[qs_include, qs_exclude] ).then( fn=fetch_first_page, inputs=[qs_rating, qs_person, qs_include, qs_exclude], outputs=fetch_outputs ).then( fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js=RENDER_TAGS_JS ).then( fn=enable_qs_controls, inputs=[], outputs=qs_controls ) # Refresh tags button - fetch first page with blocking qs_refresh_btn.click( fn=disable_qs_controls, inputs=[], outputs=qs_controls ).then( fn=fetch_first_page, inputs=[qs_rating, qs_person, qs_include, qs_exclude], outputs=fetch_outputs ).then( fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js=RENDER_TAGS_JS ).then( fn=enable_qs_controls, inputs=[], outputs=qs_controls ) # Handle tag action from JS (via hidden textbox change) with blocking qs_tag_action.change( fn=disable_qs_controls, inputs=[], outputs=qs_controls ).then( fn=handle_tag_action, inputs=[qs_tag_action, qs_rating, qs_person, qs_include, qs_exclude], outputs=[qs_include, qs_exclude, qs_cached_tags, random_status_label, qs_current_page] ).then( fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js=RENDER_TAGS_JS ).then( fn=enable_qs_controls, inputs=[], outputs=qs_controls ) # Auto-refresh tags when filters change with blocking for component in [qs_rating, qs_person]: component.change( fn=disable_qs_controls, inputs=[], outputs=qs_controls ).then( fn=fetch_first_page, inputs=[qs_rating, qs_person, qs_include, qs_exclude], outputs=fetch_outputs ).then( fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js=RENDER_TAGS_JS ).then( fn=enable_qs_controls, inputs=[], outputs=qs_controls ) # Pagination buttons - with blocking qs_prev_btn.click( fn=disable_qs_controls, inputs=[], outputs=qs_controls ).then( fn=go_prev_page, inputs=[qs_rating, qs_person, qs_include, qs_exclude, qs_current_page], outputs=[qs_cached_tags, qs_current_page] ).then( fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js=RENDER_TAGS_JS ).then( fn=enable_qs_controls, inputs=[], outputs=qs_controls ) qs_next_btn.click( fn=disable_qs_controls, inputs=[], outputs=qs_controls ).then( fn=go_next_page, inputs=[qs_rating, qs_person, qs_include, qs_exclude, qs_current_page], outputs=[qs_cached_tags, qs_current_page] ).then( fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js=RENDER_TAGS_JS ).then( fn=enable_qs_controls, inputs=[], outputs=qs_controls ) # ========== Generation Handler ========== async def handle_generate( history: list, # History state as first param token: str, pos_prompt: str, neg_prompt: str, pre_prompt_text: str, post_prompt_text: str, auto_hide_text: str, res: str, use_random_res: bool, enabled_res: list, model: str, steps: int, scale: float, cfg_rescale: float, sampler: str, noise_schedule: str, variety_plus: bool, seed: Optional[int], char_prompt_enabled: bool, char_ref_enabled: bool, char_ref_image: Optional[str], char_ref_style_aware: bool, char_ref_fidelity: float, *char_slot_data # 6 enables + 6 prompts + 6 negatives = 18 values ): """Handle image generation request""" # Handle None values token = token or "" pos_prompt = pos_prompt or "" neg_prompt = neg_prompt or "" pre_prompt_text = pre_prompt_text or "" post_prompt_text = post_prompt_text or "" auto_hide_text = auto_hide_text or "" # Helper to build error return with unchanged history def error_return(msg): # Return: image, info, pos(in), neg(in), pos(out), neg(out), download_btn, history, *5 slots return (None, msg, "", "", "", "", gr.update(visible=False), history) + tuple(gr.update() for _ in range(5)) if not token.strip(): return error_return("Error: No API token configured. Please set your token in Settings.") if not pos_prompt.strip(): return error_return("Error: Please enter a prompt.") # Select resolution (random or fixed) if use_random_res and enabled_res: selected_res = random.choice(enabled_res) else: selected_res = res # Note: Default negative prompt is handled by prompt_processor # Don't set it here to avoid duplication # Parse auto-hide tags auto_hide_tags = {t.strip() for t in auto_hide_text.split(',') if t.strip()} # Convert seed seed_val = int(seed) if seed and seed > 0 else None # Build character prompts list from active slots character_prompts = [] if char_prompt_enabled and char_slot_data: # char_slot_data: [enable0, enable1, ..., enable5, prompt0, ..., prompt5, neg0, ..., neg5] num_slots = MAX_CHAR_SLOTS enables = char_slot_data[:num_slots] prompts = char_slot_data[num_slots:num_slots*2] negatives = char_slot_data[num_slots*2:num_slots*3] for i in range(num_slots): if enables[i] and prompts[i] and prompts[i].strip(): character_prompts.append((prompts[i].strip(), negatives[i].strip() if negatives[i] else "")) # character_prompts collected # Build character reference data if enabled character_reference = None if char_ref_enabled and char_ref_image: try: image_base64 = process_reference_image(char_ref_image) character_reference = CharacterReferenceData( image_base64=image_base64, style_aware=char_ref_style_aware, fidelity=float(char_ref_fidelity) ) except Exception as e: print(f"NAIA-WEB: Failed to load character reference: {e}") request = GenerationRequest( positive_prompt=pos_prompt, negative_prompt=neg_prompt, resolution=selected_res, model=model, steps=int(steps), scale=float(scale), cfg_rescale=float(cfg_rescale), sampler=sampler, noise_schedule=noise_schedule, variety_plus=variety_plus, seed=seed_val, use_quality_tags=True, # Always use quality tags for positive pre_prompt=pre_prompt_text.strip(), post_prompt=post_prompt_text.strip(), auto_hide_tags=auto_hide_tags, character_prompts=character_prompts if character_prompts else [], character_reference=character_reference, ) try: result = await generation_service.generate(token, request) except Exception as e: print(f"NAIA-WEB: Generation failed with exception: {e}") import traceback traceback.print_exc() return error_return(f"Error: Internal generation error: {str(e)}") if result.success: # Format API parameters for info display info_text = format_api_params( model=model, resolution=selected_res, steps=int(steps), scale=float(scale), cfg_rescale=float(cfg_rescale), sampler=sampler, seed=result.seed_used, prompt=result.processed_prompt, negative=result.processed_negative ) # Save image to temp file for download # Save image to temp file for download temp_dir = tempfile.gettempdir() download_path = os.path.join(temp_dir, f"naia_{result.seed_used}_{uuid.uuid4().hex}.png") result.image.save(download_path, format="PNG") # Update history # Store both processed prompts (for display) and original inputs (for restoration) new_entry = { "image": result.image, "info": info_text, "pos": result.processed_prompt, # Final processed (for Generation Info) "neg": result.processed_negative, # Final processed (for Generation Info) "original_pos": pos_prompt, # User's original input (for restoration) "original_neg": neg_prompt, # User's original input (for restoration) "dl_path": download_path } new_history = [new_entry] + (history or []) if len(new_history) > 5: new_history = new_history[:5] # Build history slot updates (use file path instead of PIL image) slot_updates = [] for i in range(5): if i < len(new_history): slot_updates.append(gr.update(value=new_history[i]["dl_path"])) else: slot_updates.append(gr.update()) return ( result.image, info_text, pos_prompt, # Input Box (Original) neg_prompt, # Input Box (Original) result.processed_prompt, # Final Display (Processed) result.processed_negative, # Final Display (Processed) gr.update(value=download_path, visible=True), new_history, *slot_updates ) else: return error_return(f"Error: {result.error_message}") # ========== Settings Load via app.load (Bridge Pattern) ========== # Uses Gradio's app.load event to properly load settings on page load # JS reads localStorage -> Python function distributes to components def distribute_settings(settings_json: str): """Parse settings JSON and return values for each component""" import json # Helper functions for safe type conversion def safe_int(val, default=None): if val is None or val == '': return default try: return int(val) except (ValueError, TypeError): return default def safe_float(val, default=None): if val is None or val == '': return default try: return float(val) except (ValueError, TypeError): return default # Total outputs: 15 base + 6 slots + 6 enables + 6 prompts + 6 negatives + 1 slot_count + 1 global_ban + 1 qs_exclude + 3 wildcard = 45 total_outputs = 45 if not settings_json: # Return None for all outputs (no change) return [gr.update()] * total_outputs try: settings = json.loads(settings_json) print(f"NAIA-WEB: Distributing settings: {list(settings.keys())}") # Base settings (13 outputs) - with safe type conversion for Slider values steps_val = safe_int(settings.get('steps')) scale_val = safe_float(settings.get('scale')) cfg_rescale_val = safe_float(settings.get('cfg_rescale')) base_outputs = [ gr.update(value=settings.get('negative_prompt')) if settings.get('negative_prompt') else gr.update(), gr.update(value=settings.get('pre_prompt')) if settings.get('pre_prompt') else gr.update(), gr.update(value=settings.get('post_prompt')) if settings.get('post_prompt') else gr.update(), gr.update(value=settings.get('auto_hide')) if settings.get('auto_hide') else gr.update(), gr.update(value=settings.get('model')) if settings.get('model') else gr.update(), gr.update(value=steps_val) if steps_val is not None else gr.update(), gr.update(value=scale_val) if scale_val is not None else gr.update(), gr.update(value=cfg_rescale_val) if cfg_rescale_val is not None else gr.update(), gr.update(value=settings.get('sampler')) if settings.get('sampler') else gr.update(), gr.update(value=settings.get('noise_schedule')) if settings.get('noise_schedule') else gr.update(), gr.update(value=settings.get('variety_plus')) if settings.get('variety_plus') is not None else gr.update(), gr.update(value=settings.get('random_resolution')) if settings.get('random_resolution') is not None else gr.update(), gr.update(value=settings.get('enabled_resolutions')) if settings.get('enabled_resolutions') else gr.update(), gr.update(value=settings.get('auto_save')) if settings.get('auto_save') is not None else gr.update(), gr.update(value=settings.get('disable_autocomplete')) if settings.get('disable_autocomplete') is not None else gr.update(), ] # Character prompt slots char_slots_data = settings.get('char_slots', []) print(f"NAIA-WEB: char_slots_data = {char_slots_data}") # Determine how many slots to show (minimum 1) if char_slots_data: max_index = max(slot.get('index', 0) for slot in char_slots_data) num_visible_slots = max(max_index + 1, 1) print(f"NAIA-WEB: max_index={max_index}, num_visible_slots={num_visible_slots}") else: num_visible_slots = 1 print(f"NAIA-WEB: No char slots, num_visible_slots=1") # Build slot visibility updates (6 outputs) slot_visibility = [] for i in range(MAX_CHAR_SLOTS): slot_visibility.append(gr.update(visible=(i < num_visible_slots))) # Build enable/prompt/negative updates (6 + 6 + 6 = 18 outputs) enable_updates = [] prompt_updates = [] negative_updates = [] # Create lookup dict for saved slots saved_slots = {slot.get('index'): slot for slot in char_slots_data} for i in range(MAX_CHAR_SLOTS): if i in saved_slots: slot = saved_slots[i] enable_updates.append(gr.update(value=slot.get('enabled', True))) prompt_updates.append(gr.update(value=slot.get('prompt', ''))) negative_updates.append(gr.update(value=slot.get('negative', ''))) else: # Default values for empty slots enable_updates.append(gr.update(value=(i == 0))) # Only C1 enabled by default prompt_updates.append(gr.update(value='')) negative_updates.append(gr.update(value='')) # Slot count update slot_count_update = gr.update(value=num_visible_slots) # Global Ban & QS Exclude updates # Apply Global Ban to Exclude Tags on startup global_ban_val = settings.get('global_ban', "furry, guro, futanari, scat, cyclops, yaoi, manly") global_ban_update = gr.update(value=global_ban_val) qs_exclude_update = gr.update(value=global_ban_val) # Wildcard settings updates wildcard_mode_val = settings.get('wildcard_mode', False) wildcard_template_val = settings.get('wildcard_template', '') wildcard_mode_update = gr.update(value=wildcard_mode_val) wildcard_template_update = gr.update(value=wildcard_template_val) # Show template group only if wildcard mode is enabled wildcard_template_group_update = gr.update(visible=wildcard_mode_val) if char_slots_data: print(f"NAIA-WEB: Loaded {len(char_slots_data)} character slot(s), showing {num_visible_slots} slot(s)") return base_outputs + slot_visibility + enable_updates + prompt_updates + negative_updates + [slot_count_update, global_ban_update, qs_exclude_update, wildcard_mode_update, wildcard_template_update, wildcard_template_group_update] except Exception as e: print(f"NAIA-WEB: Error distributing settings: {e}") return [gr.update()] * total_outputs # Load settings on page load app.load( fn=distribute_settings, inputs=[settings_bridge], outputs=[ # Base settings (15) negative_prompt, pre_prompt, post_prompt, auto_hide, gen["model"], gen["steps"], gen["scale"], gen["cfg_rescale"], gen["sampler"], gen["noise_schedule"], gen["variety_plus"], random_resolution, res_checkboxes, auto_save, disable_autocomplete, # Character slots visibility (6) *char_slots, # Character enables (6) *char_enables, # Character prompts (6) *char_prompts, # Character negatives (6) *char_negatives, # Slot count (1) char_slot_count, # Global Ban updates (2) global_ban, qs_exclude, # Wildcard settings (3) wildcard_mode, wc_template_input, wc_template_group ], js="""() => { const SETTINGS_KEY = 'naia_settings'; try { const saved = localStorage.getItem(SETTINGS_KEY); if (!saved) return ''; return saved; } catch (e) { return ''; } }""" ).then( fn=lambda: None, inputs=[], outputs=[], js="""() => { // Close Generation Settings accordion const accordion = document.querySelector('#naia-generation-settings-accordion'); if (accordion) { const button = accordion.querySelector('button.label-wrap, .label-wrap'); if (button && button.getAttribute('aria-expanded') === 'true') { button.click(); } } }""" ).then( # Auto-load first page of Top Tags on page load fn=fetch_first_page, inputs=[qs_rating, qs_person, qs_include, qs_exclude], outputs=fetch_outputs ).then( # Store tags in JS and try to render (handles case where accordion is already open) fn=lambda tags_json: tags_json, inputs=[qs_cached_tags], outputs=[], js="""(tagsJson) => { if (window.NAIAQuickSearch && tagsJson) { try { const data = JSON.parse(tagsJson); window.NAIAQuickSearch.setPageData(data.tags, data.page, data.total_pages, data.event_count); } catch(e) {} } return []; }""" ).then( # Auto-trigger Get Random Prompt after tags loaded fn=generate_quick_search_prompt, inputs=[qs_rating, qs_person, qs_include, qs_exclude, auto_hide], outputs=[positive_prompt, random_status_label] ) # Generate button - with JS to load token from localStorage and start timer generate_btn.click( fn=disable_action_buttons, inputs=[], outputs=[get_random_btn, generate_btn, random_generate_btn] ).then( fn=handle_generate, inputs=[ history_data, # History state first settings["token_input"], positive_prompt, negative_prompt, pre_prompt, post_prompt, auto_hide, resolution, random_resolution, enabled_resolutions, gen["model"], gen["steps"], gen["scale"], gen["cfg_rescale"], gen["sampler"], gen["noise_schedule"], gen["variety_plus"], gen["seed"], activate_char_prompt, activate_char_ref, char_ref_image, char_ref_style_aware, char_ref_fidelity, *char_enables, *char_prompts, *char_negatives ], outputs=[ output["image"], gen_info["info"], positive_prompt, # Input Box (Original) negative_prompt, # Input Box (Original) gen_info["processed_prompt"], # Display Box gen_info["processed_negative"], # Display Box output["download_btn"], history_data, *history_slots ], js="""(history, token, posPrompt, negPrompt, prePrompt, postPrompt, ...restArgs) => { // Start the progress timer if (window.NAIAGenerate) window.NAIAGenerate.start(); // Save current settings to localStorage if (window.NAIASettings) window.NAIASettings.save(); // Expand wildcards in prompts using NAIAWildcard if (window.NAIAWildcard) { posPrompt = window.NAIAWildcard.expand(posPrompt || ''); prePrompt = window.NAIAWildcard.expand(prePrompt || ''); postPrompt = window.NAIAWildcard.expand(postPrompt || ''); // Expand wildcards in character prompts and negatives // restArgs layout: [auto_hide, resolution, random_resolution, enabled_resolutions, // model, steps, scale, cfg_rescale, sampler, noise_schedule, variety_plus, seed, // activate_char_prompt, activate_char_ref, char_ref_image, char_ref_style_aware, char_ref_fidelity, // ...char_enables(6), ...char_prompts(6), ...char_negatives(6)] // char_prompts start at index 23 (6 enables before them at 17-22) // char_negatives start at index 29 const charPromptsStart = 23; const charNegativesStart = 29; for (let i = 0; i < 6; i++) { if (restArgs[charPromptsStart + i]) { restArgs[charPromptsStart + i] = window.NAIAWildcard.expand(restArgs[charPromptsStart + i]); } if (restArgs[charNegativesStart + i]) { restArgs[charNegativesStart + i] = window.NAIAWildcard.expand(restArgs[charNegativesStart + i]); } } } // If token is empty, try to load from localStorage if (!token || !token.trim()) { const savedToken = localStorage.getItem('naia_api_token'); if (savedToken) { // Also update the input field const container = document.querySelector('#naia-token-input'); if (container) { const input = container.querySelector('input, textarea'); if (input) { input.value = savedToken; input.dispatchEvent(new Event('input', { bubbles: true })); } } return [history, savedToken, posPrompt, negPrompt, prePrompt, postPrompt, ...restArgs]; } } return [history, token, posPrompt, negPrompt, prePrompt, postPrompt, ...restArgs]; }""", show_progress="hidden" ).then( fn=enable_action_buttons, inputs=[], outputs=[get_random_btn, generate_btn, random_generate_btn], js="() => { if (window.NAIAGenerate) window.NAIAGenerate.stop(); }" ) # Random + Generate button - chains Get Random Prompt then Generate random_generate_btn.click( fn=disable_action_buttons, inputs=[], outputs=[get_random_btn, generate_btn, random_generate_btn] ).then( fn=handle_get_random_prompt, inputs=[ wildcard_mode, wc_expanded_prompt, qs_rating, qs_person, qs_include, qs_exclude, auto_hide, filter_characteristics, filter_clothes, filter_location ], outputs=[positive_prompt, random_status_label], js=GET_RANDOM_JS ).then( fn=handle_generate, inputs=[ history_data, # History state first settings["token_input"], positive_prompt, negative_prompt, pre_prompt, post_prompt, auto_hide, resolution, random_resolution, enabled_resolutions, gen["model"], gen["steps"], gen["scale"], gen["cfg_rescale"], gen["sampler"], gen["noise_schedule"], gen["variety_plus"], gen["seed"], activate_char_prompt, activate_char_ref, char_ref_image, char_ref_style_aware, char_ref_fidelity, *char_enables, *char_prompts, *char_negatives ], outputs=[ output["image"], gen_info["info"], positive_prompt, # Input Box (Original) negative_prompt, # Input Box (Original) gen_info["processed_prompt"], # Display Box gen_info["processed_negative"], # Display Box output["download_btn"], history_data, *history_slots ], js="""(history, token, posPrompt, negPrompt, prePrompt, postPrompt, ...restArgs) => { // Start the progress timer if (window.NAIAGenerate) window.NAIAGenerate.start(); // Expand wildcards in pre/post prompts (positive already expanded by Get Random) if (window.NAIAWildcard) { prePrompt = window.NAIAWildcard.expand(prePrompt || ''); postPrompt = window.NAIAWildcard.expand(postPrompt || ''); // Expand wildcards in character prompts and negatives // restArgs layout: [auto_hide, resolution, random_resolution, enabled_resolutions, // model, steps, scale, cfg_rescale, sampler, noise_schedule, variety_plus, seed, // activate_char_prompt, activate_char_ref, char_ref_image, char_ref_style_aware, char_ref_fidelity, // ...char_enables(6), ...char_prompts(6), ...char_negatives(6)] const charPromptsStart = 23; const charNegativesStart = 29; for (let i = 0; i < 6; i++) { if (restArgs[charPromptsStart + i]) { restArgs[charPromptsStart + i] = window.NAIAWildcard.expand(restArgs[charPromptsStart + i]); } if (restArgs[charNegativesStart + i]) { restArgs[charNegativesStart + i] = window.NAIAWildcard.expand(restArgs[charNegativesStart + i]); } } } // If token is empty, try to load from localStorage if (!token || !token.trim()) { const savedToken = localStorage.getItem('naia_api_token'); if (savedToken) { // Also update the input field const container = document.querySelector('#naia-token-input'); if (container) { const input = container.querySelector('input, textarea'); if (input) { input.value = savedToken; input.dispatchEvent(new Event('input', { bubbles: true })); } } return [history, savedToken, posPrompt, negPrompt, prePrompt, postPrompt, ...restArgs]; } } return [history, token, posPrompt, negPrompt, prePrompt, postPrompt, ...restArgs]; }""", show_progress="hidden" ).then( fn=enable_action_buttons, inputs=[], outputs=[get_random_btn, generate_btn, random_generate_btn], js="() => { if (window.NAIAGenerate) window.NAIAGenerate.stop(); }" ) # ========== Autocomplete Handler ========== def handle_autocomplete_search(query: str) -> str: """Handle autocomplete search request from JS Query format: "mode:search_term" Modes: - all: search all categories - no_character: exclude character category - character_only: only search characters """ import json if not query or len(query.strip()) < 1: return "[]" # Parse mode and search term query = query.strip() mode = 'all' search_term = query if ':' in query: parts = query.split(':', 1) if parts[0] in ('all', 'no_character', 'character_only', 'general_only'): mode = parts[0] search_term = parts[1] if not search_term or len(search_term.strip()) < 1: return "[]" # Detect implicit mode from search term prefix if search_term.lower().startswith("artist:") and len(search_term) >= 7: mode = 'artist_only' search_term = search_term[7:].strip() # Get results from autocomplete service from core.autocomplete_service import get_autocomplete_service service = get_autocomplete_service() if mode == 'character_only': # Only search characters results = service.search_characters(search_term.strip(), limit=15) results = [[r.tag, r.count, r.category] for r in results] elif mode == 'artist_only': # Search only artists st = search_term.strip() if not st: results = service.get_popular_tags(limit=15, category="artist") else: results = service.search_artists(st, limit=15) results = [[r.tag, r.count, r.category] for r in results] elif mode == 'no_character': # Search all but filter out characters all_results = service.search(search_term.strip(), limit=30) results = [[r.tag, r.count, r.category] for r in all_results if r.category != 'character'][:15] elif mode == 'general_only': # Search only general tags results = service.search_generals(search_term.strip(), limit=15) results = [[r.tag, r.count, r.category] for r in results] else: # Search all results = gradio_search_tags(search_term.strip(), limit=15) return json.dumps(results) # JS to pass results to NAIAAutocomplete AUTOCOMPLETE_RESULTS_JS = """(resultsJson) => { if (window.NAIAAutocomplete && resultsJson) { try { const results = JSON.parse(resultsJson); window.NAIAAutocomplete.setResults(results); } catch(e) {} } return []; }""" autocomplete_query.change( fn=handle_autocomplete_search, inputs=[autocomplete_query], outputs=[autocomplete_results] ).then( fn=lambda x: x, inputs=[autocomplete_results], outputs=[], js=AUTOCOMPLETE_RESULTS_JS ) # ========== Wildcard Manager Handlers ========== def handle_wildcard_upload(files): """Handle .txt file upload for wildcards. Returns JS code to add files to LocalStorage. """ if not files: return "" import json file_data = [] for file_path in files: try: # Read file content with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # Get filename from path filename = Path(file_path).name # Parse lines (skip empty and comments) lines = [ line.strip() for line in content.split('\n') if line.strip() and not line.strip().startswith('#') ] file_data.append({ 'filename': filename, 'lines': lines }) except Exception as e: print(f"NAIA-WEB: Error reading wildcard file: {e}") return json.dumps(file_data) # Upload .txt files handler wc_panel["upload_file_btn"].upload( fn=handle_wildcard_upload, inputs=[wc_panel["upload_file_btn"]], outputs=[wc_panel["wc_action"]] ).then( fn=lambda: None, inputs=[], outputs=[], js="""() => { const actionInput = document.querySelector('#naia-wc-action textarea'); if (!actionInput || !actionInput.value) return; try { const files = JSON.parse(actionInput.value); const category = window.WildcardManagerUI ? window.WildcardManagerUI.selectedCategory : 'default'; for (const file of files) { window.NAIAWildcard.addFile(category, file.filename, file.lines); } // Clear action input actionInput.value = ''; // Re-render UI if (window.WildcardManagerUI) { window.WildcardManagerUI.render(); } } catch(e) { console.error('Error processing uploaded files:', e); } }""" ) def handle_wildcard_import(file_path): """Handle JSON import for wildcards.""" if not file_path: return "" try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() return content except Exception as e: print(f"NAIA-WEB: Error reading import file: {e}") return "" # Import JSON handler wc_panel["import_all_btn"].upload( fn=handle_wildcard_import, inputs=[wc_panel["import_all_btn"]], outputs=[wc_panel["wc_action"]] ).then( fn=lambda: None, inputs=[], outputs=[], js="""() => { const actionInput = document.querySelector('#naia-wc-action textarea'); if (!actionInput || !actionInput.value) return; try { if (window.WildcardManagerUI) { window.WildcardManagerUI.handleImport(actionInput.value); } // Clear action input actionInput.value = ''; } catch(e) { console.error('Error importing wildcards:', e); alert('Import failed. Invalid JSON format.'); } }""" ) return app def get_css(): """Return CSS for the app""" return CUSTOM_CSS def get_head(): """Return head HTML for the app""" return LOCALSTORAGE_JS + f"" def _ensure_quick_search_data(): """ Ensure ./data/quick_search exists. If missing, download from a private HF dataset repo via HF_TOKEN. """ local_qs_dir = Path(__file__).parent.parent / "data" / "quick_search" # ui/../data/quick_search if local_qs_dir.exists() and any(local_qs_dir.iterdir()): print(f"NAIA-WEB: quick_search data found at {local_qs_dir}") return repo_id = os.environ.get("QS_DATASET_REPO", "").strip() token = os.environ.get("HF_TOKEN", "").strip() subdir = os.environ.get("QS_SUBDIR", "quick_search").strip() # dataset λ‚΄ 폴더λͺ… if not repo_id: print("NAIA-WEB: QS_DATASET_REPO not set. quick_search will be unavailable.") return if not token: print("NAIA-WEB: HF_TOKEN not set. quick_search will be unavailable.") return try: from huggingface_hub import snapshot_download print(f"NAIA-WEB: Downloading quick_search from dataset: {repo_id}/{subdir}") # dataset 전체λ₯Ό λ°›λ˜, quick_search ν΄λ”λ§Œ allow νŒ¨ν„΄μœΌλ‘œ μ œν•œ cache_path = snapshot_download( repo_id=repo_id, repo_type="dataset", token=token, allow_patterns=[f"{subdir}/*"], ) src_dir = Path(cache_path) / subdir if not src_dir.exists(): print(f"NAIA-WEB: ERROR - downloaded dataset has no '{subdir}' directory.") return local_qs_dir.mkdir(parents=True, exist_ok=True) # symlink κ°€λŠ₯ν•˜λ©΄ symlink, μ•ˆ 되면 copy # (HF Spaces λ¦¬λˆ…μŠ€μ—μ„œλŠ” 보톡 symlink κ°€λŠ₯) for p in src_dir.iterdir(): dst = local_qs_dir / p.name if dst.exists(): continue try: dst.symlink_to(p) except Exception: # fallback: copy file import shutil if p.is_dir(): shutil.copytree(p, dst) else: shutil.copy2(p, dst) print(f"NAIA-WEB: quick_search ready at {local_qs_dir}") except Exception as e: print(f"NAIA-WEB: Failed to prepare quick_search data: {e}")