Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| 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('<span id="naia-qs-page-text">1/1</span>', 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( | |
| '<div class="qs-tag-grid" id="naia-qs-tag-grid"></div>', | |
| 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 <span style='font-size: 0.8em; font-weight: normal; color: #888;'>(Be careful to insert correct tag)</span>") | |
| 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('<span class="status-separator">|</span>') | |
| 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('<span class="option-label">Resolution:</span>') | |
| 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('<span class="option-label" style="color:#ccc; margin: 0 4px;">|</span>') | |
| # 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('<span class="option-label" style="color:#ccc; margin: 0 4px;">|</span>') | |
| # 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("<hr style='margin: 16px 0; border: 0; border-top: 1px solid #eee;'>") | |
| 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 = ` | |
| <div class="lb-container"> | |
| <div class="lb-image-area"> | |
| <img src="" alt="Preview"> | |
| </div> | |
| <div class="lb-controls"> | |
| <div class="lb-nav-row" style="display:none !important;"> | |
| <button class="lb-btn nav-btn" id="lb-prev" title="Previous (Older)"> | |
| < | |
| </button> | |
| <button class="lb-btn nav-btn" id="lb-next" title="Next (Newer)"> | |
| > | |
| </button> | |
| </div> | |
| <div class="lb-action-col"> | |
| <button class="lb-btn action-btn bg-dark-orange" id="lb-regen">Re<br>Gen</button> | |
| <button class="lb-btn action-btn bg-orange" id="lb-random">๐ฐ Rand<br>Gen</button> | |
| <button class="lb-btn action-btn bg-blue" id="lb-download">๐ฅ Save<br>Image</button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| 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<br>Gen"; | |
| randomBtn.innerHTML = "๐ฐ Rand<br>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.<br>" | |
| # Check 1MP limit | |
| pixels = w * h | |
| if pixels > 1024 * 1024: | |
| msg += "<span style='color: red; font-weight: bold;'>๐ด Resolution exceeds 1MP (1024x1024). This will incur Anlas cost!</span>" | |
| 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"<style>{LIGHTBOX_CSS}</style>" | |
| 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}") | |