NAIA / ui /app_builder.py
baqu2213's picture
Upload app_builder.py
78ff0ff verified
"""
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)">
&lt;
</button>
<button class="lb-btn nav-btn" id="lb-next" title="Next (Newer)">
&gt;
</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}")