// ==UserScript==
// @name WTR LAB Novel Image Generator
// @namespace http://tampermonkey.net/
// @version 5.6
// @description Generate images from selected text in novels using various AI providers. Now with provider profile management for OpenAI-compatible APIs and enhanced generation history.
// @author MasuRii
// @match https://wtr-lab.com/en/novel/*/*/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wtr-lab.com
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect *
// @license MIT
// ==/UserScript==
;(function () {
'use strict'
// --- 1. STYLES ---
const styles = `
/* === CSS Custom Properties (Design Tokens) === */
:root {
/* Color Palette - Modern Dark Theme */
--nig-color-bg-primary: #1a1a1e;
--nig-color-bg-secondary: #2d2d32;
--nig-color-bg-tertiary: #3a3a40;
--nig-color-bg-elevated: #404046;
--nig-color-text-primary: #f0f0f0;
--nig-color-text-secondary: #b4b4b8;
--nig-color-text-muted: #8a8a8e;
--nig-color-border: #55555a;
--nig-color-border-light: #6a6a6e;
/* Accent Colors */
--nig-color-accent-primary: #6366f1;
--nig-color-accent-secondary: #8b5cf6;
--nig-color-accent-success: #10b981;
--nig-color-accent-warning: #f59e0b;
--nig-color-accent-error: #ef4444;
/* Interactive States */
--nig-color-hover-primary: #5855eb;
--nig-color-hover-secondary: #7c3aed;
--nig-color-hover-success: #059669;
--nig-color-hover-error: #dc2626;
/* Spacing Scale */
--nig-space-xs: 0.25rem;
--nig-space-sm: 0.5rem;
--nig-space-md: 0.75rem;
--nig-space-lg: 1rem;
--nig-space-xl: 1.5rem;
--nig-space-2xl: 2rem;
--nig-space-3xl: 3rem;
/* Typography Scale */
--nig-font-size-xs: 0.75rem;
--nig-font-size-sm: 0.875rem;
--nig-font-size-base: 1rem;
--nig-font-size-lg: 1.125rem;
--nig-font-size-xl: 1.25rem;
--nig-font-size-2xl: 1.5rem;
--nig-font-size-3xl: 1.875rem;
/* Border Radius */
--nig-radius-sm: 0.375rem;
--nig-radius-md: 0.5rem;
--nig-radius-lg: 0.75rem;
--nig-radius-xl: 1rem;
/* Shadows */
--nig-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--nig-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--nig-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4);
--nig-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5);
/* Transitions */
--nig-transition-fast: 0.15s ease-out;
--nig-transition-normal: 0.2s ease-out;
--nig-transition-slow: 0.3s ease-out;
/* Breakpoints */
--nig-breakpoint-sm: 640px;
--nig-breakpoint-md: 768px;
--nig-breakpoint-lg: 1024px;
--nig-breakpoint-xl: 1280px;
}
/* === Base Components === */
.nig-button {
position: absolute;
z-index: 99998;
background: var(--nig-color-accent-primary);
color: white;
border: none;
border-radius: var(--nig-radius-md);
padding: var(--nig-space-sm) var(--nig-space-md);
font-size: var(--nig-font-size-sm);
font-weight: 500;
cursor: pointer;
box-shadow: var(--nig-shadow-md);
display: none;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
transition: all var(--nig-transition-normal);
transform: translateY(0);
}
.nig-button:hover {
background: var(--nig-color-hover-primary);
transform: translateY(-1px);
box-shadow: var(--nig-shadow-lg);
}
.nig-button:active {
transform: translateY(0);
box-shadow: var(--nig-shadow-sm);
}
.nig-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 99999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: var(--nig-space-lg);
}
.nig-modal-content {
background: var(--nig-color-bg-secondary);
color: var(--nig-color-text-primary);
padding: var(--nig-space-2xl);
border-radius: var(--nig-radius-xl);
box-shadow: var(--nig-shadow-xl);
width: 100%;
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
position: relative;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
border: 1px solid var(--nig-color-border);
animation: nig-modal-appear 0.2s ease-out;
}
@keyframes nig-modal-appear {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.nig-modal-content ul {
padding-left: var(--nig-space-xl);
}
.nig-modal-content li {
margin-bottom: var(--nig-space-md);
}
.nig-close-btn {
position: absolute;
top: var(--nig-space-lg);
right: var(--nig-space-lg);
font-size: var(--nig-font-size-2xl);
font-weight: 300;
cursor: pointer;
color: var(--nig-color-text-muted);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--nig-radius-md);
transition: all var(--nig-transition-fast);
}
.nig-close-btn:hover {
color: var(--nig-color-text-primary);
background: var(--nig-color-bg-tertiary);
}
.nig-modal-content h2 {
margin-top: 0;
border-bottom: 1px solid var(--nig-color-border);
padding-bottom: var(--nig-space-lg);
font-size: var(--nig-font-size-2xl);
font-weight: 600;
letter-spacing: -0.025em;
}
/* === Form Elements === */
.nig-form-group {
margin-bottom: var(--nig-space-xl);
}
.nig-form-group label {
display: block;
margin-bottom: var(--nig-space-sm);
font-weight: 500;
color: var(--nig-color-text-primary);
font-size: var(--nig-font-size-sm);
}
.nig-form-group small.nig-hint {
color: var(--nig-color-text-muted);
font-weight: normal;
display: block;
margin-top: var(--nig-space-sm);
margin-bottom: var(--nig-space-sm);
min-height: 1.2em;
font-size: var(--nig-font-size-xs);
line-height: 1.4;
}
.nig-form-group input,
.nig-form-group select,
.nig-form-group textarea {
width: 100%;
padding: var(--nig-space-sm) var(--nig-space-md);
border-radius: var(--nig-radius-md);
border: 1px solid var(--nig-color-border);
background: var(--nig-color-bg-tertiary);
color: var(--nig-color-text-primary);
font-size: var(--nig-font-size-sm);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
transition: all var(--nig-transition-fast);
outline: none;
}
.nig-form-group input:focus,
.nig-form-group select:focus,
.nig-form-group textarea:focus {
border-color: var(--nig-color-accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
background: var(--nig-color-bg-elevated);
}
.nig-form-group select:disabled {
background: var(--nig-color-bg-tertiary);
color: var(--nig-color-text-muted);
cursor: not-allowed;
}
.nig-form-group textarea {
resize: vertical;
min-height: 80px;
line-height: 1.5;
}
.nig-form-group-inline {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--nig-space-lg);
align-items: end;
}
.nig-form-group-inline label {
margin-bottom: var(--nig-space-sm);
}
.nig-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: var(--nig-space-lg);
}
.nig-checkbox-group label {
display: flex;
align-items: center;
margin-right: 0;
font-weight: normal;
cursor: pointer;
color: var(--nig-color-text-secondary);
}
.nig-checkbox-group input {
width: auto;
margin-right: var(--nig-space-sm);
margin-bottom: 0;
transform: scale(1.1);
}
/* === Buttons === */
.nig-save-btn {
background: var(--nig-color-accent-success);
color: white;
padding: var(--nig-space-md) var(--nig-space-xl);
border: none;
border-radius: var(--nig-radius-md);
cursor: pointer;
font-size: var(--nig-font-size-base);
font-weight: 500;
transition: all var(--nig-transition-normal);
box-shadow: var(--nig-shadow-sm);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--nig-space-sm);
}
.nig-save-btn:hover {
background: var(--nig-color-hover-success);
transform: translateY(-1px);
box-shadow: var(--nig-shadow-md);
}
.nig-fetch-models-btn {
padding: var(--nig-space-sm) var(--nig-space-md);
margin-left: var(--nig-space-md);
border-radius: var(--nig-radius-md);
border: 1px solid var(--nig-color-border);
background: var(--nig-color-accent-primary);
color: white;
cursor: pointer;
font-size: var(--nig-font-size-sm);
font-weight: 500;
transition: all var(--nig-transition-normal);
}
.nig-fetch-models-btn:hover {
background: var(--nig-color-hover-primary);
transform: translateY(-1px);
}
.nig-delete-btn {
padding: var(--nig-space-sm) var(--nig-space-md);
margin-left: var(--nig-space-md);
border-radius: var(--nig-radius-md);
border: 1px solid var(--nig-color-border);
background: var(--nig-color-accent-error);
color: white;
cursor: pointer;
font-size: var(--nig-font-size-sm);
font-weight: 500;
transition: all var(--nig-transition-normal);
}
.nig-delete-btn:hover {
background: var(--nig-color-hover-error);
transform: translateY(-1px);
}
.nig-history-cleanup-btn {
background: var(--nig-color-accent-error);
color: white;
padding: var(--nig-space-sm) var(--nig-space-md);
border: none;
border-radius: var(--nig-radius-md);
cursor: pointer;
font-size: var(--nig-font-size-sm);
font-weight: 500;
transition: all var(--nig-transition-normal);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--nig-space-sm);
}
.nig-history-cleanup-btn:hover {
background: var(--nig-color-hover-error);
transform: translateY(-1px);
}
/* === Tab System === */
.nig-tabs {
display: flex;
border-bottom: 1px solid var(--nig-color-border);
margin-bottom: var(--nig-space-xl);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.nig-tabs::-webkit-scrollbar {
display: none;
}
.nig-tab {
padding: var(--nig-space-md) var(--nig-space-xl);
cursor: pointer;
border-radius: var(--nig-radius-md) var(--nig-radius-md) 0 0;
background: transparent;
color: var(--nig-color-text-secondary);
font-size: var(--nig-font-size-sm);
font-weight: 500;
transition: all var(--nig-transition-fast);
white-space: nowrap;
border: 1px solid transparent;
border-bottom: none;
}
.nig-tab:hover {
background: var(--nig-color-bg-tertiary);
color: var(--nig-color-text-primary);
}
.nig-tab.active {
background: var(--nig-color-bg-tertiary);
color: var(--nig-color-text-primary);
border: 1px solid var(--nig-color-border);
border-bottom: 1px solid var(--nig-color-bg-tertiary);
box-shadow: 0 -2px 0 var(--nig-color-accent-primary) inset;
}
.nig-tab-content {
display: none;
animation: nig-content-fade 0.2s ease-out;
}
.nig-tab-content.active {
display: block;
}
@keyframes nig-content-fade {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* === Modern Utilities Tab === */
.nig-utilities-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--nig-space-xl);
margin-top: var(--nig-space-xl);
}
.nig-utility-card {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-lg);
padding: var(--nig-space-xl);
transition: all var(--nig-transition-normal);
}
.nig-utility-card:hover {
border-color: var(--nig-color-border-light);
box-shadow: var(--nig-shadow-md);
transform: translateY(-2px);
}
.nig-utility-card h4 {
margin: 0 0 var(--nig-space-md) 0;
color: var(--nig-color-text-primary);
font-size: var(--nig-font-size-lg);
font-weight: 600;
}
.nig-utility-card p {
margin: 0 0 var(--nig-space-lg) 0;
color: var(--nig-color-text-secondary);
font-size: var(--nig-font-size-sm);
line-height: 1.5;
}
.nig-utility-card .nig-save-btn {
width: 100%;
justify-content: center;
}
/* === History System === */
.nig-history-list {
list-style: none;
padding: 0;
max-height: 400px;
overflow-y: auto;
}
.nig-history-item {
background: var(--nig-color-bg-tertiary);
padding: var(--nig-space-lg);
border-radius: var(--nig-radius-md);
margin-bottom: var(--nig-space-md);
border: 1px solid var(--nig-color-border);
transition: all var(--nig-transition-fast);
}
.nig-history-item:hover {
border-color: var(--nig-color-border-light);
box-shadow: var(--nig-shadow-sm);
}
.nig-history-item small {
display: block;
color: var(--nig-color-text-muted);
margin-bottom: var(--nig-space-sm);
font-size: var(--nig-font-size-xs);
}
.nig-history-item a {
color: var(--nig-color-accent-primary);
text-decoration: none;
word-break: break-all;
font-weight: 500;
transition: color var(--nig-transition-fast);
}
.nig-history-item a:hover {
color: var(--nig-color-hover-primary);
}
.nig-history-cleanup {
padding: var(--nig-space-lg);
background: var(--nig-color-bg-tertiary);
border-radius: var(--nig-radius-md);
margin-bottom: var(--nig-space-xl);
display: flex;
align-items: center;
gap: var(--nig-space-lg);
border: 1px solid var(--nig-color-border);
}
.nig-history-cleanup input[type="number"] {
width: 80px;
padding: var(--nig-space-sm);
}
/* === Image Gallery === */
.nig-image-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--nig-space-xl);
margin-top: var(--nig-space-xl);
}
.nig-image-container {
position: relative;
border-radius: var(--nig-radius-lg);
overflow: hidden;
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
transition: all var(--nig-transition-normal);
}
.nig-image-container:hover {
box-shadow: var(--nig-shadow-lg);
transform: translateY(-2px);
}
.nig-image-container img {
width: 100%;
height: auto;
display: block;
transition: transform var(--nig-transition-slow);
}
.nig-image-container:hover img {
transform: scale(1.02);
}
.nig-image-actions {
position: absolute;
top: var(--nig-space-md);
right: var(--nig-space-md);
display: flex;
gap: var(--nig-space-sm);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
padding: var(--nig-space-sm);
border-radius: var(--nig-radius-md);
opacity: 0;
transition: opacity var(--nig-transition-normal);
}
.nig-image-container:hover .nig-image-actions {
opacity: 1;
}
.nig-image-actions button {
background: rgba(0, 0, 0, 0.8);
border: none;
border-radius: var(--nig-radius-md);
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--nig-font-size-base);
color: white;
transition: all var(--nig-transition-fast);
}
.nig-image-actions button:hover {
background: white;
transform: scale(1.1);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: 18px;
}
.nig-api-prompt-link {
color: var(--nig-color-accent-primary);
text-decoration: none;
transition: color var(--nig-transition-fast);
}
.nig-api-prompt-link:hover {
color: var(--nig-color-hover-primary);
text-decoration: underline;
}
.nig-provider-settings {
display: none;
}
/* === Prompt Container === */
.nig-prompt-container {
background: var(--nig-color-bg-tertiary);
border-radius: var(--nig-radius-md);
margin-bottom: var(--nig-space-lg);
border: 1px solid var(--nig-color-border);
transition: all var(--nig-transition-normal);
}
.nig-prompt-header {
padding: var(--nig-space-lg);
cursor: pointer;
font-weight: 500;
display: flex;
align-items: center;
user-select: none;
color: var(--nig-color-text-primary);
transition: color var(--nig-transition-fast);
}
.nig-prompt-header:hover {
color: var(--nig-color-accent-primary);
}
.nig-prompt-header::before {
content: '▸';
margin-right: var(--nig-space-md);
transition: transform var(--nig-transition-normal);
color: var(--nig-color-text-muted);
}
.nig-prompt-container.expanded .nig-prompt-header::before {
transform: rotate(90deg);
color: var(--nig-color-accent-primary);
}
.nig-prompt-text {
display: none;
padding: 0 var(--nig-space-lg) var(--nig-space-lg) var(--nig-space-lg);
border-top: 1px solid var(--nig-color-border);
word-break: break-word;
color: var(--nig-color-text-secondary);
line-height: 1.6;
}
.nig-prompt-container.expanded .nig-prompt-text {
display: block;
animation: nig-expand 0.2s ease-out;
}
@keyframes nig-expand {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 200px;
}
}
/* === Button Footer === */
.nig-button-footer {
margin-top: var(--nig-space-3xl);
padding-top: var(--nig-space-xl);
border-top: 1px solid var(--nig-color-border);
text-align: center;
}
/* === Status Widget === */
.nig-status-widget {
position: fixed;
bottom: var(--nig-space-xl);
left: var(--nig-space-xl);
z-index: 100000;
background: var(--nig-color-bg-secondary);
color: var(--nig-color-text-primary);
padding: var(--nig-space-md) var(--nig-space-lg);
border-radius: var(--nig-radius-lg);
box-shadow: var(--nig-shadow-xl);
display: none;
align-items: center;
gap: var(--nig-space-md);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: var(--nig-font-size-sm);
font-weight: 500;
transition: all var(--nig-transition-normal);
border: 1px solid var(--nig-color-border);
backdrop-filter: blur(10px);
}
.nig-status-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.nig-status-widget.loading .nig-status-icon {
box-sizing: border-box;
border: 2px solid var(--nig-color-text-muted);
border-top-color: var(--nig-color-accent-primary);
border-radius: 50%;
animation: nig-spin 1s linear infinite;
}
.nig-status-widget.success {
background: var(--nig-color-accent-success);
color: white;
cursor: pointer;
border-color: var(--nig-color-accent-success);
}
.nig-status-widget.success:hover {
background: var(--nig-color-hover-success);
transform: translateY(-2px);
box-shadow: var(--nig-shadow-lg);
}
.nig-status-widget.error {
background: var(--nig-color-accent-error);
border-color: var(--nig-color-accent-error);
}
@keyframes nig-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* === Error Modal === */
#nig-error-reason {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
padding: var(--nig-space-lg);
border-radius: var(--nig-radius-md);
margin-top: var(--nig-space-sm);
max-height: 150px;
overflow-y: auto;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
color: var(--nig-color-text-primary);
line-height: 1.5;
}
.nig-error-prompt {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
padding: var(--nig-space-lg);
border-radius: var(--nig-radius-md);
margin-top: var(--nig-space-lg);
max-height: 200px;
overflow-y: auto;
word-break: break-word;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
width: 100%;
resize: vertical;
min-height: 80px;
color: var(--nig-color-text-primary);
line-height: 1.5;
}
.nig-error-actions {
margin-top: var(--nig-space-xl);
}
.nig-retry-btn {
background: var(--nig-color-accent-primary);
color: white;
padding: var(--nig-space-md) var(--nig-space-xl);
border: none;
border-radius: var(--nig-radius-md);
cursor: pointer;
font-size: var(--nig-font-size-base);
font-weight: 500;
transition: all var(--nig-transition-normal);
box-shadow: var(--nig-shadow-sm);
}
.nig-retry-btn:hover {
background: var(--nig-color-hover-primary);
transform: translateY(-1px);
box-shadow: var(--nig-shadow-md);
}
/* === Responsive Design === */
/* Mobile First Base (up to 767px) */
@media (max-width: 767px) {
.nig-modal-content {
margin: var(--nig-space-md);
padding: var(--nig-space-xl);
max-height: 95vh;
border-radius: var(--nig-radius-lg);
}
.nig-modal-overlay {
padding: var(--nig-space-sm);
}
.nig-button {
position: fixed;
bottom: var(--nig-space-3xl);
left: 50% !important;
transform: translateX(-50%);
top: auto !important;
padding: var(--nig-space-sm) var(--nig-space-lg);
font-size: var(--nig-font-size-sm);
z-index: 100001;
min-height: 44px; /* Touch target */
border-radius: var(--nig-radius-lg);
}
.nig-tabs {
margin: 0 calc(-1 * var(--nig-space-xl)) var(--nig-space-xl) calc(-1 * var(--nig-space-xl));
padding: 0 var(--nig-space-xl);
}
.nig-tab {
padding: var(--nig-space-md) var(--nig-space-lg);
font-size: var(--nig-font-size-xs);
}
.nig-form-group-inline {
grid-template-columns: 1fr;
gap: var(--nig-space-md);
}
.nig-checkbox-group {
flex-direction: column;
gap: var(--nig-space-sm);
}
.nig-checkbox-group label {
justify-content: flex-start;
}
.nig-utilities-grid {
grid-template-columns: 1fr;
gap: var(--nig-space-lg);
}
.nig-utility-card {
padding: var(--nig-space-lg);
}
.nig-history-cleanup {
flex-direction: column;
align-items: stretch;
gap: var(--nig-space-md);
}
.nig-history-cleanup input[type="number"] {
width: 100%;
}
.nig-status-widget {
bottom: var(--nig-space-lg);
left: var(--nig-space-md);
right: var(--nig-space-md);
padding: var(--nig-space-md);
font-size: var(--nig-font-size-xs);
}
.nig-image-gallery {
grid-template-columns: 1fr;
gap: var(--nig-space-lg);
}
.nig-modal-content h2 {
font-size: var(--nig-font-size-xl);
padding-right: 48px; /* Space for close button */
}
}
/* Tablet (768px to 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.nig-modal-content {
max-width: 700px;
}
.nig-utilities-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.nig-image-gallery {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
/* Desktop (1024px and up) */
@media (min-width: 1024px) {
.nig-modal-content {
max-width: 1000px;
}
.nig-utilities-grid {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.nig-image-gallery {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.nig-form-group-inline {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* Enhanced hover states for desktop */
.nig-tab:hover {
background: var(--nig-color-bg-tertiary);
}
.nig-history-item:hover {
transform: translateY(-1px);
}
}
/* Large Desktop (1280px and up) */
@media (min-width: 1280px) {
.nig-modal-content {
max-width: 1200px;
}
.nig-utilities-grid {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}
}
/* Print Styles */
@media print {
.nig-modal-overlay,
.nig-status-widget,
.nig-button {
display: none !important;
}
.nig-modal-content {
box-shadow: none;
border: 1px solid #000;
background: white;
color: black;
}
}
/* High Contrast Mode Support */
@media (prefers-contrast: high) {
:root {
--nig-color-bg-primary: #000000;
--nig-color-bg-secondary: #1a1a1a;
--nig-color-bg-tertiary: #2a2a2a;
--nig-color-text-primary: #ffffff;
--nig-color-border: #666666;
}
}
/* Reduced Motion Support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* === Panel Configuration Grid Layout === */
.nig-config-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--nig-space-xl);
}
.nig-config-section {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-lg);
padding: var(--nig-space-xl);
}
.nig-provider-container {
display: grid;
gap: var(--nig-space-lg);
}
.nig-provider-header {
margin-bottom: var(--nig-space-lg);
padding-bottom: var(--nig-space-lg);
border-bottom: 1px solid var(--nig-color-border);
}
.nig-provider-header h3 {
margin: 0 0 var(--nig-space-sm) 0;
color: var(--nig-color-text-primary);
font-size: var(--nig-font-size-lg);
font-weight: 600;
display: flex;
align-items: center;
gap: var(--nig-space-sm);
}
.nig-provider-header p {
margin: 0;
color: var(--nig-color-text-secondary);
font-size: var(--nig-font-size-sm);
line-height: 1.5;
}
.nig-provider-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--nig-space-lg);
margin-bottom: var(--nig-space-lg);
}
.nig-provider-settings {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-lg);
padding: var(--nig-space-xl);
transition: all var(--nig-transition-normal);
}
.nig-provider-settings:hover {
border-color: var(--nig-color-border-light);
box-shadow: var(--nig-shadow-sm);
}
.nig-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--nig-space-lg);
margin-bottom: var(--nig-space-lg);
}
/* === Styling Tab Layout === */
.nig-styling-container {
display: grid;
gap: var(--nig-space-xl);
}
.nig-styling-intro {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-lg);
padding: var(--nig-space-lg);
margin-bottom: var(--nig-space-lg);
}
.nig-styling-intro p {
margin: 0;
color: var(--nig-color-text-secondary);
font-size: var(--nig-font-size-sm);
line-height: 1.6;
}
.nig-style-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--nig-space-xl);
}
.nig-style-section {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-lg);
padding: var(--nig-space-xl);
}
.nig-section-header {
margin-bottom: var(--nig-space-lg);
padding-bottom: var(--nig-space-lg);
border-bottom: 1px solid var(--nig-color-border);
}
.nig-section-header h4 {
margin: 0;
color: var(--nig-color-text-primary);
font-size: var(--nig-font-size-lg);
font-weight: 600;
}
/* === History Tab Layout === */
.nig-history-container {
display: grid;
gap: var(--nig-space-xl);
}
.nig-history-cleanup {
background: var(--nig-color-bg-tertiary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-lg);
padding: var(--nig-space-xl);
display: grid;
gap: var(--nig-space-lg);
}
.nig-cleanup-info h4 {
margin: 0 0 var(--nig-space-sm) 0;
color: var(--nig-color-text-primary);
font-size: var(--nig-font-size-lg);
font-weight: 600;
}
.nig-cleanup-info p {
margin: 0;
color: var(--nig-color-text-secondary);
font-size: var(--nig-font-size-sm);
line-height: 1.5;
}
.nig-cleanup-controls {
display: flex;
align-items: center;
gap: var(--nig-space-md);
flex-wrap: wrap;
}
.nig-cleanup-controls label {
color: var(--nig-color-text-primary);
font-weight: 500;
font-size: var(--nig-font-size-sm);
}
.nig-cleanup-controls input[type="number"] {
width: 80px;
padding: var(--nig-space-sm);
background: var(--nig-color-bg-primary);
border: 1px solid var(--nig-color-border);
border-radius: var(--nig-radius-md);
color: var(--nig-color-text-primary);
}
/* === Enhanced Responsive Grid === */
@media (min-width: 768px) {
.nig-config-grid {
grid-template-columns: 1fr;
}
.nig-provider-controls {
grid-template-columns: repeat(2, 1fr);
}
.nig-form-grid {
grid-template-columns: repeat(2, 1fr);
}
.nig-style-grid {
grid-template-columns: 1fr;
}
.nig-cleanup-controls {
justify-content: flex-start;
}
}
@media (min-width: 1024px) {
.nig-config-grid {
grid-template-columns: 1fr;
}
.nig-provider-controls {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.nig-form-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.nig-style-grid {
grid-template-columns: 1fr;
}
.nig-provider-settings:hover {
transform: translateY(-2px);
box-shadow: var(--nig-shadow-md);
}
}
@media (min-width: 1280px) {
.nig-config-grid {
grid-template-columns: 1fr;
}
.nig-provider-controls {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.nig-style-grid {
grid-template-columns: repeat(2, 1fr);
}
.nig-styling-container {
grid-template-columns: 1fr;
}
}
/* === File Input Styling === */
input[type="file"] {
border: 2px dashed var(--nig-color-border);
background: var(--nig-color-bg-primary);
padding: var(--nig-space-xl);
border-radius: var(--nig-radius-lg);
color: var(--nig-color-text-secondary);
transition: all var(--nig-transition-normal);
cursor: pointer;
}
input[type="file"]:hover {
border-color: var(--nig-color-accent-primary);
background: var(--nig-color-bg-elevated);
}
input[type="file"]:focus {
outline: none;
border-color: var(--nig-color-accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
`
// --- 2. DEFAULTS & STORAGE HELPERS ---
const PROMPT_CATEGORIES = [
{name: 'None', description: 'No additional styling will be added to your prompt.', subStyles: []},
{
name: 'Anime',
description:
'Blends Japanese animation with global twists. Sub-styles often mix eras, genres, or crossovers for dynamic outputs.',
subStyles: [
{name: 'None', value: 'none', description: 'Use only the main style name as a prefix (e.g., "Anime style, ...").'},
{
name: 'Studio Ghibli-Inspired',
description: 'Whimsical, nature-focused fantasy with soft lines and emotional depth.',
value: 'Studio Ghibli style, '
},
{
name: 'Cyberpunk Anime',
description: 'Neon-lit dystopias with high-tech mechs and gritty urban vibes.',
value: 'Cyberpunk anime style, '
},
{
name: 'Semi-Realistic Anime',
description: 'Blends lifelike proportions with expressive anime eyes and shading.',
value: 'Semi-realistic anime style, '
},
{name: 'Mecha', description: 'Giant robots and mechanical suits in epic battles.', value: 'Mecha anime style, '},
{
name: 'Dynamic Action',
description: 'High-energy movements with power effects and intense expressions.',
value: 'Dynamic action anime style, '
},
{
name: 'Soft Romantic',
description: 'Emotional interactions with gentle colors and sparkling accents.',
value: 'Soft romantic anime style, '
},
{
name: 'Dark Fantasy Anime',
description: 'Grim, horror-tinged worlds with demons and shadows.',
value: 'Dark fantasy anime style, '
},
{
name: 'Retro 80s Anime',
description: 'Vintage cel-shaded look with bold lines and synth vibes.',
value: '80s retro anime style, '
},
{
name: 'Portal Fantasy',
description: 'World-crossing elements with magical adaptations and RPG motifs.',
value: 'Portal fantasy anime style, '
},
{
name: 'Slice-of-Life',
description: 'Everyday moments with relatable characters and cozy vibes.',
value: 'Slice-of-life anime style, '
},
{
name: 'Serialized Narrative',
description: 'Panel-like compositions for ongoing story flows.',
value: 'Serialized narrative anime style, '
},
{
name: 'Group Dynamic',
description: 'Interactions among multiple characters with balanced focus.',
value: 'Group dynamic anime style, '
}
]
},
{
name: 'Realism/Photorealism',
description:
'Excels in portraits and scenes mimicking photography, with sub-styles varying by subject or technique.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Realism/Photorealism style, ...").'
},
{
name: 'Hyperrealism',
description: 'Ultra-detailed, almost tangible textures and lighting.',
value: 'Hyperrealistic, '
},
{
name: 'Cinematic Realism',
description: 'Film-like depth with dramatic angles and color grading.',
value: 'Cinematic realism, '
},
{
name: 'Portrait Photorealism',
description: 'Human faces with natural skin, eyes, and expressions.',
value: 'Portrait photorealism, '
},
{
name: 'Architectural Realism',
description: 'Precise building renders with environmental details.',
value: 'Architectural realism, '
},
{
name: 'Nature Photorealism',
description: 'Verdant landscapes with dew and foliage intricacies.',
value: 'Nature photorealism, '
},
{
name: 'Close-Up Detail',
description: 'Intimate views highlighting textures and fine elements.',
value: 'Close-up realistic style, '
},
{
name: 'Historical Realism',
description: 'Period-accurate clothing and settings with grit.',
value: 'Historical realism, '
},
{name: 'Urban Realism', description: 'Bustling city life with crowds and neon realism.', value: 'Urban realism, '},
{name: 'Stylized Realism', description: 'Subtle artistic tweaks on photoreal bases.', value: 'Stylized realism, '},
{
name: 'Documentary Style',
description: 'Raw, unpolished scenes like news photography.',
value: 'Documentary photo style, '
},
{
name: 'Object Focus Realism',
description: 'Clear, highlighted items with neutral lighting.',
value: 'Object-focused realism, '
},
{
name: 'Wildlife Realism',
description: 'Animals in habitats with fur and feather fidelity.',
value: 'Wildlife realism, '
},
{
name: 'Detailed Portrait',
description: 'Lifelike faces with expressive features.',
value: 'Detailed portrait realism, '
},
{
name: 'Environmental Immersion',
description: 'Rich settings enveloping subjects.',
value: 'Environmental immersion realism, '
}
]
},
{
name: 'Fantasy',
description: 'Epic worlds of magic and myth, with sub-styles spanning tones from whimsical to grim.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Fantasy style, ...").'
},
{
name: 'High Fantasy',
description: 'Medieval realms with elves, dragons, and quests.',
value: 'High fantasy art, '
},
{
name: 'Dark Fantasy',
description: 'Grimdark horror with undead and moral ambiguity.',
value: 'Dark fantasy art, '
},
{name: 'Urban Fantasy', description: 'Magic in modern cities, like hidden witches.', value: 'Urban fantasy art, '},
{
name: 'Steampunk Fantasy',
description: 'Victorian tech with gears and airships.',
value: 'Steampunk fantasy style, '
},
{
name: 'Fairy Tale',
description: 'Whimsical tales with enchanted woods and creatures.',
value: 'Fairy tale illustration style, '
},
{
name: 'Heroic Adventure',
description: 'Bold explorers with raw magic and ancient relics.',
value: 'Heroic adventure fantasy art, '
},
{
name: 'Creature Emphasis',
description: 'Fantastical beings in natural or enchanted environments.',
value: 'Creature-focused fantasy art, '
},
{
name: 'Ethereal Grace',
description: 'Elegant figures in luminous, forested settings.',
value: 'Ethereal grace fantasy style, '
},
{
name: 'Rugged Craftsmanship',
description: 'Stout builders in forged, underground realms.',
value: 'Rugged craftsmanship fantasy style, '
},
{name: 'Gothic Fantasy', description: 'Haunted castles with vampires and storms.', value: 'Gothic fantasy art, '},
{
name: 'Beast Majesty',
description: 'Powerful scaled creatures in dramatic poses.',
value: 'Majestic beast fantasy art, '
},
{
name: 'Celestial Fantasy',
description: 'Starry realms with gods and floating islands.',
value: 'Celestial fantasy art, '
},
{
name: 'Oriental Myth',
description: 'Asian folklore elements with harmonious nature.',
value: 'Oriental myth fantasy style, '
},
{
name: 'Treasure Hunt Vibe',
description: 'Exploratory scenes with hidden wonders.',
value: 'Treasure hunt fantasy art, '
}
]
},
{
name: 'Sci-Fi',
description: 'Futuristic visions from gritty cyber worlds to cosmic explorations.',
subStyles: [
{name: 'None', value: 'none', description: 'Use only the main style name as a prefix (e.g., "Sci-Fi style, ...").'},
{name: 'Cyberpunk', description: 'Neon dystopias with hackers and megacorps.', value: 'Cyberpunk style, '},
{name: 'Retro-Futurism', description: '1950s optimism with ray guns and chrome.', value: 'Retro-futurism, '},
{name: 'Biopunk', description: 'Organic tech with genetic mutations.', value: 'Biopunk sci-fi style, '},
{
name: 'Interstellar Epic',
description: 'Vast cosmic tales with diverse species and ships.',
value: 'Interstellar epic sci-fi art, '
},
{
name: 'Mechanical Suit',
description: 'Armored machines in high-tech conflicts.',
value: 'Mechanical suit sci-fi style, '
},
{name: 'Post-Human', description: 'Cyborgs and AI in evolved societies.', value: 'Post-human sci-fi art, '},
{
name: 'Hard Sci-Fi',
description: 'Physics-based realism with tech schematics.',
value: 'Hard sci-fi illustration, '
},
{name: 'Dieselpunk', description: '1930s grit with riveted machines.', value: 'Dieselpunk style, '},
{name: 'Astro-Mythology', description: 'Space gods and cosmic myths.', value: 'Astro-mythology art, '},
{name: 'Eco-Sci-Fi', description: 'Post-apocalypse with bio-domes.', value: 'Eco-sci-fi art, '},
{
name: 'Survival Wasteland',
description: 'Harsh, ruined landscapes with resilient figures.',
value: 'Survival wasteland sci-fi style, '
},
{
name: 'Cosmic Discovery',
description: 'Unknown worlds with exploratory tech.',
value: 'Cosmic discovery sci-fi art, '
}
]
},
{
name: 'Retro/Vintage',
description: 'Nostalgic aesthetics from bygone eras, revived with AI flair.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Retro/Vintage style, ...").'
},
{name: 'Art Deco', description: 'Geometric luxury with gold and symmetry.', value: 'Art Deco style, '},
{name: 'Art Nouveau', description: 'Flowing organic lines and floral motifs.', value: 'Art Nouveau style, '},
{name: 'Vintage Poster', description: 'Bold typography and illustrative ads.', value: 'Vintage poster style, '},
{name: 'Chromolithography', description: 'Vibrant, printed color layers from 1900s.', value: 'Chromolithography, '},
{name: 'Baroque', description: 'Ornate drama with rich drapery.', value: 'Baroque painting style, '},
{name: 'Ukiyo-e', description: 'Japanese woodblock prints with flat colors.', value: 'Ukiyo-e style, '},
{name: '1950s Retro', description: 'Atomic age optimism with pastels.', value: '1950s retro style, '},
{
name: 'Playful Figure',
description: 'Charming, stylized poses with vintage flair.',
value: 'Playful vintage figure style, '
},
{name: 'Edwardian', description: 'Lacy elegance with soft pastels.', value: 'Edwardian era style, '},
{name: 'Mid-Century Modern', description: 'Clean lines and bold geometrics.', value: 'Mid-century modern style, '},
{
name: 'Ink Scroll',
description: 'Brush-like lines evoking ancient manuscripts.',
value: 'Ink scroll vintage style, '
}
]
},
{
name: 'Surrealism',
description: 'Dreamlike distortions challenging reality, inspired by masters.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Surrealism style, ...").'
},
{
name: 'Fluid Distortion',
description: 'Melting forms and impossible blends.',
value: 'Fluid distortion surrealism, '
},
{
name: 'Paradoxical Objects',
description: 'Everyday items in illogical arrangements.',
value: 'Paradoxical object surrealism, '
},
{
name: 'Ernst Collage Surreal',
description: 'Layered fragments for uncanny narratives.',
value: 'Ernst collage surrealism, '
},
{
name: 'Kahlo Autobiographical',
description: 'Personal symbolism with thorny motifs.',
value: 'Frida Kahlo style surrealism, '
},
{name: 'Biomorphic Surreal', description: 'Organic, creature-like hybrids.', value: 'Biomorphic surrealism, '},
{
name: 'Dreamlike Landscapes',
description: 'Floating islands and inverted gravity.',
value: 'Dreamlike surreal landscape, '
},
{
name: 'Freudian Symbolic',
description: 'Subconscious icons like eyes and stairs.',
value: 'Freudian symbolic surrealism, '
},
{
name: 'Pop Surrealism',
description: 'Whimsical grotesquery with candy colors.',
value: 'Pop surrealism, lowbrow art, '
},
{name: 'Hyper-Surreal', description: 'Exaggerated distortions in vivid detail.', value: 'Hyper-surrealism, '},
{name: 'Eco-Surreal', description: 'Nature twisted with human elements.', value: 'Eco-surrealism, '},
{name: 'Mechanical Surreal', description: 'Machines fused with flesh.', value: 'Mechanical surrealism, '},
{
name: 'Inner Vision',
description: 'Symbolic inner thoughts with blended realities.',
value: 'Inner vision surrealism, '
}
]
},
{
name: 'Cartoon/Illustration',
description: 'Exaggerated, narrative-driven visuals for fun and storytelling.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Cartoon/Illustration style, ...").'
},
{
name: 'Pixar 3D',
description: 'Polished, expressive CG with emotional arcs.',
value: 'Pixar 3D animation style, '
},
{
name: 'Disney Classic',
description: 'Hand-drawn whimsy with fluid animation.',
value: 'Classic Disney animation style, '
},
{name: 'DreamWorks', description: 'Edgy humor with detailed backgrounds.', value: 'DreamWorks animation style, '},
{
name: 'Adventure Time',
description: 'Surreal candy lands with bold shapes.',
value: 'Adventure Time cartoon style, '
},
{
name: 'Simpsons',
description: 'Yellow-skinned satire with clean outlines.',
value: 'The Simpsons cartoon style, '
},
{
name: 'Rick and Morty',
description: 'Sci-fi absurdity with warped perspectives.',
value: 'Rick and Morty cartoon style, '
},
{
name: 'Narrative Panel',
description: 'Sequential art with shaded storytelling.',
value: 'Narrative panel illustration style, '
},
{
name: 'Whimsical Illustration',
description: 'Gentle, colorful drawings for light-hearted scenes.',
value: 'Whimsical illustration style, '
},
{name: 'Webtoon', description: 'Vertical scroll with vibrant digital ink.', value: 'Webtoon style, '},
{
name: 'Manhua Flow',
description: 'Dynamic lines and vibrant digital shading.',
value: 'Manhua flow illustration style, '
},
{
name: 'Cover Art Focus',
description: 'Striking compositions for thematic highlights.',
value: 'Cover art illustration, '
}
]
},
{
name: 'Traditional Painting',
description: 'Emulates historical mediums like oils and watercolors for timeless appeal.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Traditional Painting style, ...").'
},
{
name: 'Impressionism',
description: 'Loose brushstrokes capturing light moments.',
value: 'Impressionist painting, '
},
{
name: 'Renaissance',
description: 'Balanced compositions with chiaroscuro.',
value: 'Renaissance painting style, '
},
{name: 'Oil Painting', description: 'Rich, layered textures with glazing.', value: 'Oil painting, '},
{name: 'Watercolor', description: 'Translucent washes for ethereal softness.', value: 'Watercolor painting, '},
{name: 'Baroque', description: 'Dramatic tenebrism and opulent details.', value: 'Baroque painting, '},
{name: 'Romanticism', description: 'Emotional storms and heroic figures.', value: 'Romanticism painting, '},
{name: 'Pointillism', description: 'Dot-based color mixing for vibrancy.', value: 'Pointillism style, '},
{name: 'Fresco', description: 'Mural-like with aged plaster effects.', value: 'Fresco painting style, '},
{name: 'Encaustic', description: 'Waxy, heated layers for luminous depth.', value: 'Encaustic painting, '},
{name: 'Acrylic', description: 'Bold, matte finishes with quick drying.', value: 'Acrylic painting, '},
{name: 'Gouache', description: 'Opaque vibrancy like matte poster paint.', value: 'Gouache painting, '},
{name: 'Sumi-e', description: 'Minimalist ink washes for Zen simplicity.', value: 'Sumi-e ink wash painting, '},
{
name: 'Oriental Brushwork',
description: 'Minimalist inks for balanced compositions.',
value: 'Oriental brushwork style, '
},
{name: 'Era Line Art', description: 'Detailed etchings for historical depth.', value: 'Era line art traditional, '}
]
},
{
name: 'Digital Art',
description: 'Modern, tech-infused creations from pixels to vectors.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Digital Art style, ...").'
},
{
name: 'Vector Illustration',
description: 'Scalable, flat colors with clean paths.',
value: 'Vector illustration, '
},
{
name: 'Blended Landscape',
description: 'Layered digital environments for immersive backdrops.',
value: 'Blended digital landscape, '
},
{name: 'Neon Glow', description: 'Vibrant outlines with electric luminescence.', value: 'Neon glow digital art, '},
{name: 'Holographic', description: 'Shimmering, 3D projections with refractions.', value: 'Holographic style, '},
{
name: 'World-Building Sketch',
description: 'Conceptual layers for expansive scenes.',
value: 'World-building digital sketch, '
},
{
name: 'Community Render',
description: 'Polished digital interpretations of characters.',
value: 'Community render digital style, '
}
]
},
{
name: 'Wuxia/Xianxia',
description:
'Eastern-inspired martial and spiritual themes with energy flows, ancient motifs, and harmonious or intense atmospheres.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Wuxia/Xianxia style, ...").'
},
{
name: 'Qi Energy Flow',
description: 'Subtle auras and internal power visualizations.',
value: 'Qi energy flow style, '
},
{name: 'Martial Grace', description: 'Fluid poses and disciplined movements.', value: 'Martial grace style, '},
{
name: 'Spiritual Realm',
description: 'Misty, elevated worlds with ethereal elements.',
value: 'Spiritual realm style, '
},
{
name: 'Ancient Sect Aesthetic',
description: 'Traditional architecture and robed figures.',
value: 'Ancient sect aesthetic, '
},
{
name: 'Demonic Shadow',
description: 'Darkened energies and mysterious silhouettes.',
value: 'Demonic shadow style, '
},
{
name: 'Dynasty Elegance',
description: 'Silk textures and jade accents in historical tones.',
value: 'Dynasty elegance style, '
}
]
},
{
name: 'Romance',
description: 'Tender or passionate human connections with soft lighting, expressions, and atmospheric details.',
subStyles: [
{
name: 'None',
value: 'none',
description: 'Use only the main style name as a prefix (e.g., "Romance style, ...").'
},
{
name: 'Gentle Intimacy',
description: 'Close, affectionate moments with warm hues.',
value: 'Gentle intimacy romance style, '
},
{
name: 'Urban Affection',
description: 'Modern settings with subtle romantic gestures.',
value: 'Urban affection style, '
},
{
name: 'Enchanted Bond',
description: 'Magical elements enhancing emotional ties.',
value: 'Enchanted bond romance style, '
},
{
name: 'Tension Build',
description: 'Subtle conflicts leading to connection.',
value: 'Tension build romance style, '
},
{
name: 'Blushing Softness',
description: 'Delicate emotions with pastel accents.',
value: 'Blushing softness style, '
},
{
name: 'Melancholic Yearning',
description: 'Poignant separations with evocative moods.',
value: 'Melancholic yearning romance art, '
}
]
}
]
const TOP_MODELS = [
{name: 'Deliberate', desc: 'Versatile, high-quality realism and detail.'},
{name: 'Anything Diffusion', desc: 'Anime-style specialist.'},
{name: "ICBINP - I Can't Believe It's Not Photography", desc: 'Photorealistic focus; excels in lifelike portraits.'},
{name: 'stable_diffusion', desc: 'The classic base model; reliable all-rounder.'},
{name: 'AlbedoBase XL (SDXL)', desc: 'SDXL variant; strong for high-res, detailed scenes.'},
{name: 'Nova Anime XL', desc: 'Anime and illustration; vibrant, dynamic characters.'},
{name: 'Dreamshaper', desc: 'Creative and dreamy outputs; good for artistic concepts.'},
{name: 'Hentai Diffusion', desc: 'NSFW/anime erotica specialist.'},
{name: 'CyberRealistic Pony', desc: 'Realistic with a cyberpunk twist.'},
{name: 'Flux.1-Schnell fp8 (Compact)', desc: 'Newer, fast-generation model for quick results.'}
]
const DEFAULTS = {
selectedProvider: 'Pollinations',
loggingEnabled: false,
// Prompt Styling
mainPromptStyle: 'None',
subPromptStyle: 'none',
customStyleEnabled: false,
customStyleText: '',
// Global Negative Prompting
enableNegPrompt: true,
globalNegPrompt: 'ugly, blurry, deformed, disfigured, poor details, bad anatomy, low quality',
// Google
googleApiKey: '',
model: 'imagen-4.0-generate-001',
numberOfImages: 1,
imageSize: '1024',
aspectRatio: '1:1',
personGeneration: 'allow_adult',
// AI Horde
aiHordeApiKey: '0000000000',
aiHordeModel: 'AlbedoBase XL (SDXL)',
aiHordeSampler: 'k_dpmpp_2m',
aiHordeSteps: 25,
aiHordeCfgScale: 7,
aiHordeWidth: 512,
aiHordeHeight: 512,
aiHordePostProcessing: [],
aiHordeSeed: '',
// Pollinations.ai
pollinationsModel: 'flux',
pollinationsWidth: 512,
pollinationsHeight: 512,
pollinationsSeed: '',
pollinationsEnhance: true,
pollinationsNologo: false,
pollinationsPrivate: false,
pollinationsSafe: true,
pollinationsToken: '',
// OpenAI Compatible
openAICompatProfiles: {},
openAICompatActiveProfileUrl: '',
openAICompatModelManualInput: false
}
// --- Logging Helpers ---
let loggingEnabled = DEFAULTS.loggingEnabled
async function updateLoggingStatus() {
loggingEnabled = await GM_getValue('loggingEnabled', DEFAULTS.loggingEnabled)
}
function log(...args) {
if (loggingEnabled) {
console.log('[NIG]', ...args)
}
}
async function getConfig() {
const config = {}
for (const key in DEFAULTS) {
config[key] = await GM_getValue(key, DEFAULTS[key])
}
return config
}
async function setConfig(key, value) {
await GM_setValue(key, value)
}
function downloadFile(filename, content, mimeType) {
const blob = new Blob([content], {type: mimeType})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
async function exportConfig() {
const config = await getConfig()
const configJson = JSON.stringify(config, null, 2)
const date = new Date().toISOString().split('T')[0]
downloadFile(`nig_config_${date}.json`, configJson, 'application/json')
alert('Configuration exported successfully as a JSON file.')
}
async function importConfig(jsonString) {
try {
const importedConfig = JSON.parse(jsonString.trim())
let importedCount = 0
let errorCount = 0
for (const key in importedConfig) {
if (DEFAULTS.hasOwnProperty(key)) {
await GM_setValue(key, importedConfig[key])
importedCount++
} else {
console.warn(`[NIG] Skipping unknown configuration key: ${key}`)
errorCount++
}
}
if (importedCount > 0) {
alert(
`Configuration imported successfully! ${importedCount} settings updated. ${errorCount} unknown settings skipped.`
)
// Reload the form to reflect new settings and save them to ensure consistency
await populateConfigForm()
await saveConfig()
} else {
alert('Import failed: No valid configuration keys found in the provided JSON.')
}
} catch (e) {
console.error('[NIG] Import failed:', e)
alert('Import failed: Invalid JSON format. Please ensure the file content is valid JSON.')
}
}
function handleImportFile(event) {
const file = event.target.files[0]
if (!file) {
return
}
const reader = new FileReader()
reader.onload = e => {
importConfig(e.target.result)
// Clear the file input after import
event.target.value = ''
}
reader.onerror = () => {
alert('Error reading file.')
event.target.value = ''
}
reader.readAsText(file)
}
async function getHistory() {
return JSON.parse(await GM_getValue('history', '[]'))
}
async function addToHistory(item) {
const history = await getHistory()
history.unshift(item)
if (history.length > 100) history.pop()
await GM_setValue('history', JSON.stringify(history))
}
// --- CACHE HELPERS ---
const CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000 // 24 hours
async function getCachedModels() {
return JSON.parse(await GM_getValue('cachedModels', '{}'))
}
async function setCachedModels(provider, models) {
const cache = await getCachedModels()
cache[provider] = models
await GM_setValue('cachedModels', JSON.stringify(cache))
}
async function clearCachedModels(provider = null) {
if (provider) {
const cache = await getCachedModels()
delete cache[provider]
await GM_setValue('cachedModels', JSON.stringify(cache))
log(`Cleared cached models for ${provider}.`)
} else {
// Clear model lists for Pollinations and AI Horde
await GM_setValue('cachedModels', '{}')
// Clear model selection for OpenAI Compatible profiles
const profiles = await GM_getValue('openAICompatProfiles', {})
for (const url in profiles) {
profiles[url].model = ''
}
await GM_setValue('openAICompatProfiles', profiles)
await GM_setValue('openAICompatModelManualInput', false)
await GM_setValue('openAICompatActiveProfileUrl', '')
log('Cleared all cached models and reset OpenAI Compatible model selections.')
alert('All cached models have been cleared. They will be re-fetched when you next open the settings.')
}
}
// --- 3. UI ELEMENTS ---
let generateBtn, configPanel, imageViewer, googleApiPrompt, pollinationsAuthPrompt, statusWidget, errorModal
let currentSelection = ''
function createUI() {
const styleSheet = document.createElement('style')
styleSheet.innerText = styles
document.head.appendChild(styleSheet)
// Add Google Material Symbols font
const materialSymbolsLink = document.createElement('link')
materialSymbolsLink.rel = 'stylesheet'
materialSymbolsLink.href =
'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0'
document.head.appendChild(materialSymbolsLink)
generateBtn = document.createElement('button')
generateBtn.className = 'nig-button'
generateBtn.innerHTML = '🎨 Generate Image'
generateBtn.addEventListener('click', onGenerateClick)
document.body.appendChild(generateBtn)
statusWidget = document.createElement('div')
statusWidget.id = 'nig-status-widget'
statusWidget.className = 'nig-status-widget'
statusWidget.innerHTML = `<div class="nig-status-icon"></div><span class="nig-status-text"></span>`
document.body.appendChild(statusWidget)
}
function createConfigPanel() {
if (document.getElementById('nig-config-panel')) return
const googleSettingsHTML = `
<div class="nig-form-group"><label for="nig-google-api-key">Gemini API Key</label><input type="password" id="nig-google-api-key"></div>
<div class="nig-form-group"><label for="nig-model">Imagen Model</label><select id="nig-model"><option value="imagen-4.0-generate-001">Imagen 4.0 Standard</option><option value="imagen-4.0-ultra-generate-001">Imagen 4.0 Ultra</option><option value="imagen-4.0-fast-generate-001">Imagen 4.0 Fast</option><option value="imagen-3.0-generate-002">Imagen 3.0 Standard</option></select></div>
<div class="nig-form-group"><label for="nig-num-images">Number of Images (1-4)</label><input type="number" id="nig-num-images" min="1" max="4" step="1"></div>
<div class="nig-form-group"><label for="nig-image-size">Image Size</label><select id="nig-image-size"><option value="1024">1K</option><option value="2048">2K</option></select></div>
<div class="nig-form-group"><label for="nig-aspect-ratio">Aspect Ratio</label><select id="nig-aspect-ratio"><option value="1:1">1:1</option><option value="3:4">3:4</option><option value="4:3">4:3</option><option value="9:16">9:16</option><option value="16:9">16:9</option></select></div>
<div class="nig-form-group"><label for="nig-person-gen">Person Generation</label><select id="nig-person-gen"><option value="dont_allow">Don't Allow</option><option value="allow_adult">Allow Adults</option><option value="allow_all">Allow All</option></select></div>`
const pollinationsSettingsHTML = `
<div class="nig-form-group"><label for="nig-pollinations-model">Model</label><select id="nig-pollinations-model"><option>Loading models...</option></select></div>
<div class="nig-form-group-inline">
<div><label for="nig-pollinations-width">Width</label><input type="number" id="nig-pollinations-width" min="64" max="2048" step="64"></div>
<div><label for="nig-pollinations-height">Height</label><input type="number" id="nig-pollinations-height" min="64" max="2048" step="64"></div>
</div>
<div class="nig-form-group"><label for="nig-pollinations-seed">Seed (optional)</label><input type="text" id="nig-pollinations-seed" placeholder="Leave blank for random"></div>
<div class="nig-form-group"><label>Options</label>
<div class="nig-checkbox-group">
<label><input type="checkbox" id="nig-pollinations-enhance">Enhance Prompt</label>
<label><input type="checkbox" id="nig-pollinations-safe">Safe Mode (NSFW Filter)</label>
<label><input type="checkbox" id="nig-pollinations-nologo">No Logo (Registered Users)</label>
<label><input type="checkbox" id="nig-pollinations-private">Private (Won't appear in feed)</label>
</div>
</div>
<div class="nig-form-group">
<label for="nig-pollinations-token">API Token (Optional)</label>
<input type="password" id="nig-pollinations-token" placeholder="Enter token for premium models">
<small class="nig-hint">Get a token from <a href="https://auth.pollinations.ai" target="_blank" class="nig-api-prompt-link">auth.pollinations.ai</a> for higher rate limits and access to restricted models.</small>
</div>
`
const openAICompatSettingsHTML = `
<div class="nig-form-group">
<label for="nig-openai-compat-profile-select">Saved Profiles</label>
<div class="nig-form-group-inline">
<select id="nig-openai-compat-profile-select"></select>
<button id="nig-openai-compat-delete-profile" class="nig-delete-btn">Delete</button>
</div>
</div>
<div class="nig-form-group">
<label for="nig-openai-compat-base-url">Base URL</label>
<input type="text" id="nig-openai-compat-base-url" placeholder="e.g., https://api.example.com/v1">
<small class="nig-hint">For a list of free public providers, check out the <a href="https://github.com/zukixa/cool-ai-stuff" target="_blank" class="nig-api-prompt-link">cool-ai-stuff</a> repository.</small>
</div>
<div class="nig-form-group">
<label for="nig-openai-compat-api-key">API Key</label>
<input type="password" id="nig-openai-compat-api-key">
</div>
<div class="nig-form-group">
<label for="nig-openai-compat-model">Model</label>
<div id="nig-openai-model-container-select">
<div class="nig-form-group-inline">
<select id="nig-openai-compat-model" style="width: 100%;"><option>Enter URL/Key and fetch...</option></select>
<button id="nig-openai-compat-fetch-models" class="nig-fetch-models-btn">Fetch</button>
</div>
<small class="nig-hint">If fetching fails or your model isn't listed, <a href="#" id="nig-openai-compat-switch-to-manual" class="nig-api-prompt-link">switch to manual input</a>.</small>
</div>
<div id="nig-openai-model-container-manual" style="display: none;">
<input type="text" id="nig-openai-compat-model-manual" placeholder="e.g., dall-e-3">
<small class="nig-hint">Manually enter the model name. <a href="#" id="nig-openai-compat-switch-to-select" class="nig-api-prompt-link">Switch back to fetched list</a>.</small>
</div>
</div>
`
configPanel = document.createElement('div')
configPanel.id = 'nig-config-panel'
configPanel.className = 'nig-modal-overlay'
configPanel.innerHTML = `
<div class="nig-modal-content">
<span class="nig-close-btn">×</span><h2>Image Generator Configuration</h2>
<div class="nig-tabs">
<div class="nig-tab active" data-tab="config">Configuration</div>
<div class="nig-tab" data-tab="styling">Prompt Styling</div>
<div class="nig-tab" data-tab="history">History</div>
<div class="nig-tab" data-tab="utilities">Utilities</div>
</div>
<div id="nig-config-tab" class="nig-tab-content active">
<div class="nig-config-grid">
<div class="nig-config-section">
<div class="nig-form-group">
<label for="nig-provider">Image Provider</label>
<select id="nig-provider">
<option value="Pollinations">🌱 Pollinations.ai (Free, Simple)</option>
<option value="AIHorde">🤖 AI Horde (Free, Advanced)</option>
<option value="OpenAICompat">🔌 OpenAI Compatible (Custom)</option>
<option value="Google">🖼️ Google Imagen (Requires Billed Account)</option>
</select>
</div>
</div>
<div class="nig-provider-container">
<div id="nig-provider-Pollinations" class="nig-provider-settings">
<div class="nig-provider-header">
<h3>🌱 Pollinations.ai Settings</h3>
<p>Fast, simple image generation with advanced model options</p>
</div>
${pollinationsSettingsHTML}
</div>
<div id="nig-provider-AIHorde" class="nig-provider-settings">
<div class="nig-provider-header">
<h3>🤖 AI Horde Settings</h3>
<p>Community-powered generation with extensive customization</p>
</div>
<div class="nig-form-group">
<label for="nig-horde-api-key">AI Horde API Key</label>
<input type="password" id="nig-horde-api-key" placeholder="Defaults to '0000000000'">
<small>Use anonymous key or get your own from <a href="https://aihorde.net/" target="_blank" class="nig-api-prompt-link">AI Horde</a> for higher priority.</small>
</div>
<div class="nig-provider-controls">
<div class="nig-form-group">
<label for="nig-horde-model">Model</label>
<select id="nig-horde-model"><option>Loading models...</option></select>
</div>
<div class="nig-form-group">
<label for="nig-horde-sampler">Sampler</label>
<select id="nig-horde-sampler">
<option value="k_dpmpp_2m">DPM++ 2M</option>
<option value="k_euler_a">Euler A</option>
<option value="k_euler">Euler</option>
<option value="k_lms">LMS</option>
<option value="k_heun">Heun</option>
<option value="k_dpm_2">DPM 2</option>
<option value="k_dpm_2_a">DPM 2 A</option>
<option value="k_dpmpp_2s_a">DPM++ 2S A</option>
<option value="k_dpmpp_sde">DPM++ SDE</option>
</select>
</div>
</div>
<div class="nig-form-grid">
<div class="nig-form-group">
<label for="nig-horde-steps">Steps</label>
<input type="number" id="nig-horde-steps" min="10" max="50" step="1">
<small class="nig-hint">More steps = more detail, but slower.</small>
</div>
<div class="nig-form-group">
<label for="nig-horde-cfg">CFG Scale</label>
<input type="number" id="nig-horde-cfg" min="1" max="20" step="0.5">
<small class="nig-hint">How strictly to follow the prompt.</small>
</div>
</div>
<div class="nig-form-grid">
<div class="nig-form-group">
<label for="nig-horde-width">Width</label>
<input type="number" id="nig-horde-width" min="64" max="2048" step="64">
</div>
<div class="nig-form-group">
<label for="nig-horde-height">Height</label>
<input type="number" id="nig-horde-height" min="64" max="2048" step="64">
</div>
</div>
<div class="nig-form-group">
<label for="nig-horde-seed">Seed (optional)</label>
<input type="text" id="nig-horde-seed" placeholder="Leave blank for random">
</div>
<div class="nig-form-group">
<label>Post-Processing</label>
<small class="nig-hint">Improves faces. Use only if generating people.</small>
<div class="nig-checkbox-group">
<label><input type="checkbox" name="nig-horde-post" value="GFPGAN">GFPGAN</label>
<label><input type="checkbox" name="nig-horde-post" value="CodeFormers">CodeFormers</label>
</div>
</div>
</div>
<div id="nig-provider-Google" class="nig-provider-settings">
<div class="nig-provider-header">
<h3>🖼️ Google Imagen Settings</h3>
<p>High-quality generation powered by Google's advanced AI</p>
</div>
${googleSettingsHTML}
</div>
<div id="nig-provider-OpenAICompat" class="nig-provider-settings">
<div class="nig-provider-header">
<h3>🔌 OpenAI Compatible Settings</h3>
<p>Connect to any OpenAI-compatible API endpoint</p>
</div>
${openAICompatSettingsHTML}
</div>
</div>
</div>
</div>
<div id="nig-styling-tab" class="nig-tab-content">
<div class="nig-styling-container">
<div class="nig-styling-intro">
<p>Select a style to automatically add it to the beginning of every prompt. This helps maintain a consistent look across all providers.</p>
</div>
<div class="nig-style-grid">
<div class="nig-style-section">
<div class="nig-form-group">
<label for="nig-main-style">Main Style</label>
<select id="nig-main-style"></select>
<small id="nig-main-style-desc" class="nig-hint"></small>
</div>
<div class="nig-form-group">
<label for="nig-sub-style">Sub-Style</label>
<select id="nig-sub-style"></select>
<small id="nig-sub-style-desc" class="nig-hint"></small>
</div>
</div>
<div class="nig-style-section">
<div class="nig-section-header">
<h4>Custom Style</h4>
</div>
<div class="nig-form-group">
<div class="nig-checkbox-group">
<label><input type="checkbox" id="nig-custom-style-enable">Enable Custom Style</label>
</div>
<small class="nig-hint">Overrides the Main/Sub-style dropdowns with your own text.</small>
<textarea id="nig-custom-style-text" placeholder="e.g., In the style of Van Gogh, oil painting, ..."></textarea>
</div>
</div>
<div class="nig-style-section">
<div class="nig-section-header">
<h4>Negative Prompting (Global)</h4>
</div>
<div class="nig-form-group">
<div class="nig-checkbox-group">
<label><input type="checkbox" id="nig-enable-neg-prompt">Enable Negative Prompting</label>
</div>
<small class="nig-hint">This negative prompt will be applied to all providers when enabled.</small>
<textarea id="nig-global-neg-prompt" placeholder="e.g., ugly, blurry, deformed, disfigured, poor details, bad anatomy, low quality"></textarea>
</div>
</div>
</div>
</div>
</div>
<div id="nig-history-tab" class="nig-tab-content">
<div class="nig-history-container">
<div class="nig-history-cleanup">
<div class="nig-cleanup-info">
<h4>History Management</h4>
<p>Clean up old history entries to free up space and improve performance.</p>
</div>
<div class="nig-cleanup-controls">
<label>Delete history older than</label>
<input type="number" id="nig-history-clean-days" min="1" max="365" value="30">
<label>days</label>
<button id="nig-history-clean-btn" class="nig-history-cleanup-btn">
<span class="material-symbols-outlined">cleaning_services</span>
Clean
</button>
</div>
</div>
<ul id="nig-history-list" class="nig-history-list"></ul>
</div>
</div>
<div id="nig-utilities-tab" class="nig-tab-content">
<div class="nig-utilities-grid">
<div class="nig-utility-card">
<h4>Import/Export Settings</h4>
<p>Backup and restore your configuration settings for seamless setup across different sessions or devices.</p>
<div class="nig-form-group">
<button id="nig-export-btn" class="nig-save-btn" style="background-color: var(--nig-color-accent-primary);">
<span class="material-symbols-outlined">download</span>
Download Configuration
</button>
<small class="nig-hint">Downloads the current configuration as a JSON file.</small>
</div>
<div class="nig-form-group">
<label for="nig-import-file">Import Configuration</label>
<input type="file" id="nig-import-file" accept=".json" style="border: 2px dashed var(--nig-color-border); background: var(--nig-color-bg-primary);">
<small class=" nig-hint">Uploading a JSON file will overwrite all current settings.</small>
</div>
</div>
<div class="nig-utility-card">
<h4>Cache Management</h4>
<p>Clear cached model lists and force fresh data fetching for accurate, up-to-date information.</p>
<button id="nig-clear-cache-btn" class="nig-save-btn" style="background-color: var(--nig-color-accent-error);">
<span class="material-symbols-outlined">clear_all</span>
Clear Cached Models
</button>
<small class="nig-hint">Removes all cached model lists forcing a fresh fetch.</small>
</div>
<div class="nig-utility-card">
<h4>Debug Console</h4>
<p>Enable detailed console logging to troubleshoot issues and monitor system behavior during development.</p>
<button id="nig-toggle-logging-btn" class="nig-save-btn" style="background-color: var(--nig-color-accent-warning);">
<span class="material-symbols-outlined">bug_report</span>
Toggle Console Logging
</button>
<small class="nig-hint">Toggles detailed console logging for debugging purposes.</small>
</div>
</div>
</div>
<div class="nig-button-footer">
<button id="nig-save-btn" class="nig-save-btn">Save Configuration</button>
</div>
</div>`
document.body.appendChild(configPanel)
configPanel.querySelector('.nig-close-btn').addEventListener('click', () => (configPanel.style.display = 'none'))
configPanel.querySelector('#nig-save-btn').addEventListener('click', saveConfig)
configPanel.querySelectorAll('.nig-tab').forEach(tab => {
tab.addEventListener('click', async () => {
configPanel.querySelectorAll('.nig-tab, .nig-tab-content').forEach(el => el.classList.remove('active'))
tab.classList.add('active')
configPanel.querySelector(`#nig-${tab.dataset.tab}-tab`).classList.add('active')
if (tab.dataset.tab === 'history') {
await populateHistoryTab()
configPanel.querySelector('#nig-save-btn').style.display = 'none'
} else {
configPanel.querySelector('#nig-save-btn').style.display = 'block'
}
})
})
configPanel.querySelector('#nig-provider').addEventListener('change', updateVisibleSettings)
configPanel
.querySelector('#nig-openai-compat-fetch-models')
.addEventListener('click', () => fetchOpenAICompatModels())
configPanel.querySelector('#nig-openai-compat-profile-select').addEventListener('change', loadSelectedOpenAIProfile)
configPanel.querySelector('#nig-openai-compat-delete-profile').addEventListener('click', deleteSelectedOpenAIProfile)
configPanel.querySelector('#nig-export-btn').addEventListener('click', exportConfig)
configPanel.querySelector('#nig-import-file').addEventListener('change', handleImportFile)
configPanel.querySelector('#nig-history-clean-btn').addEventListener('click', cleanHistory)
configPanel.querySelector('#nig-clear-cache-btn').addEventListener('click', () => clearCachedModels())
configPanel.querySelector('#nig-toggle-logging-btn').addEventListener('click', async () => {
const currentState = await GM_getValue('loggingEnabled', DEFAULTS.loggingEnabled)
const newState = !currentState
await GM_setValue('loggingEnabled', newState)
await updateLoggingStatus()
alert(`Image Generator logging is now ${newState ? 'ENABLED' : 'DISABLED'}.`)
})
const customStyleEnable = configPanel.querySelector('#nig-custom-style-enable')
const customStyleText = configPanel.querySelector('#nig-custom-style-text')
customStyleEnable.addEventListener('change', () => {
customStyleText.disabled = !customStyleEnable.checked
})
const switchToManual = e => {
e.preventDefault()
document.getElementById('nig-openai-model-container-select').style.display = 'none'
document.getElementById('nig-openai-model-container-manual').style.display = 'block'
}
const switchToSelect = e => {
e.preventDefault()
document.getElementById('nig-openai-model-container-select').style.display = 'block'
document.getElementById('nig-openai-model-container-manual').style.display = 'none'
}
configPanel.querySelector('#nig-openai-compat-switch-to-manual').addEventListener('click', switchToManual)
configPanel.querySelector('#nig-openai-compat-switch-to-select').addEventListener('click', switchToSelect)
}
// --- 4. CORE LOGIC ---
let generationQueue = []
let completedQueue = []
let isGenerating = false
let currentGenerationStatusText = ''
let errorQueue = []
let isErrorModalVisible = false
function createErrorModal() {
if (document.getElementById('nig-error-modal')) return
errorModal = document.createElement('div')
errorModal.id = 'nig-error-modal'
errorModal.className = 'nig-modal-overlay'
errorModal.style.display = 'none'
errorModal.innerHTML = `
<div class="nig-modal-content">
<span class="nig-close-btn">×</span>
<h2>Generation Failed</h2>
<p>The image could not be generated. Please review the reason below and adjust your prompt if necessary.</p>
<p><strong>Reason:</strong></p>
<div id="nig-error-reason"></div>
<p><strong>Your Prompt:</strong></p>
<textarea id="nig-error-prompt" class="nig-error-prompt"></textarea>
<div class="nig-form-group" style="margin-top: 15px;">
<label for="nig-retry-provider-select">Retry with Provider:</label>
<select id="nig-retry-provider-select"></select>
</div>
<div id="nig-error-actions" class="nig-error-actions"></div>
</div>`
document.body.appendChild(errorModal)
errorModal.querySelector('.nig-close-btn').addEventListener('click', closeErrorModal)
}
function closeErrorModal() {
if (errorModal) {
const promptTextarea = document.getElementById('nig-error-prompt')
if (promptTextarea && promptTextarea._nig_inputListener) {
promptTextarea.removeEventListener('input', promptTextarea._nig_inputListener)
delete promptTextarea._nig_inputListener
}
const providerSelect = document.getElementById('nig-retry-provider-select')
if (providerSelect && providerSelect._nig_changeListener) {
providerSelect.removeEventListener('change', providerSelect._nig_changeListener)
delete providerSelect._nig_changeListener
}
errorModal.style.display = 'none'
}
isErrorModalVisible = false
showNextError()
}
function showNextError() {
if (isErrorModalVisible || errorQueue.length === 0) {
return
}
const errorToShow = errorQueue.shift()
showErrorModal(errorToShow)
}
async function showErrorModal(errorDetails) {
if (!errorModal) createErrorModal()
isErrorModalVisible = true
document.getElementById('nig-error-reason').textContent = errorDetails.reason.message
const promptTextarea = document.getElementById('nig-error-prompt')
promptTextarea.value = errorDetails.prompt
// Populate provider dropdown
const providerSelect = document.getElementById('nig-retry-provider-select')
providerSelect.innerHTML = ''
const config = await getConfig()
const providers = ['Pollinations', 'AIHorde', 'Google']
providers.forEach(p => {
const option = document.createElement('option')
option.value = p
option.textContent = p
providerSelect.appendChild(option)
})
Object.keys(config.openAICompatProfiles).forEach(url => {
const option = document.createElement('option')
option.value = `OpenAICompat::${url}`
option.textContent = `OpenAI: ${url.replace('https://', '').split('/')[0]}`
providerSelect.appendChild(option)
})
// Pre-select the failed provider
let failedProviderValue = errorDetails.provider
if (errorDetails.provider === 'OpenAICompat' && errorDetails.providerProfileUrl) {
failedProviderValue = `OpenAICompat::${errorDetails.providerProfileUrl}`
}
if (Array.from(providerSelect.options).some(opt => opt.value === failedProviderValue)) {
providerSelect.value = failedProviderValue
}
const actionsContainer = document.getElementById('nig-error-actions')
actionsContainer.innerHTML = ''
const retryBtn = document.createElement('button')
retryBtn.textContent = 'Retry Generation'
retryBtn.className = 'nig-retry-btn'
retryBtn.onclick = async () => {
const editedPrompt = promptTextarea.value.trim()
const selectedProviderValue = providerSelect.value
let provider, providerProfileUrl
if (selectedProviderValue.startsWith('OpenAICompat::')) {
provider = 'OpenAICompat'
providerProfileUrl = selectedProviderValue.split('::')[1]
} else {
provider = selectedProviderValue
providerProfileUrl = null
}
if (editedPrompt) {
generationQueue.unshift({prompt: editedPrompt, provider, providerProfileUrl})
isGenerating = false
closeErrorModal()
updateSystemStatus()
processQueue()
} else {
alert('Prompt cannot be empty.')
}
}
if (errorDetails.reason.retryable) {
actionsContainer.appendChild(retryBtn)
} else {
const showRetryButton = () => {
if (!actionsContainer.contains(retryBtn)) {
actionsContainer.appendChild(retryBtn)
}
}
promptTextarea.addEventListener('input', showRetryButton)
promptTextarea._nig_inputListener = showRetryButton
providerSelect.addEventListener('change', showRetryButton)
providerSelect._nig_changeListener = showRetryButton
}
errorModal.style.display = 'flex'
}
function parseErrorMessage(errorString) {
let messageContent = String(errorString)
const lowerCaseContent = messageContent.toLowerCase()
if (
lowerCaseContent.includes('error code: 524') ||
lowerCaseContent.includes('timed out') ||
lowerCaseContent.includes('502 bad gateway') ||
lowerCaseContent.includes('unable to reach the origin service')
) {
return {
message:
'The generation service is temporarily unavailable or busy (e.g., 502 Bad Gateway). This is usually a temporary issue. Please try again in a few minutes.',
retryable: true
}
}
if (
lowerCaseContent.includes('unsafe content') ||
lowerCaseContent.includes('safety system') ||
lowerCaseContent.includes('moderation_blocked') ||
lowerCaseContent.includes('violence') ||
lowerCaseContent.includes('sexual')
) {
try {
const errorJson = JSON.parse(messageContent.substring(messageContent.indexOf('{')))
let specificMessage = errorJson.message || (errorJson.error ? errorJson.error.message : null)
if (specificMessage) {
specificMessage = specificMessage.replace(/If you believe this is an error, contact us at.*$/, '').trim()
return {message: specificMessage, retryable: false}
}
} catch (e) {
/* Fall through */
}
return {
message: 'The prompt was rejected by the safety system for containing potentially unsafe content.',
retryable: false
}
}
try {
const errorJson = JSON.parse(messageContent.substring(messageContent.indexOf('{')))
const message = errorJson.message || (errorJson.error ? errorJson.error.message : null) || JSON.stringify(errorJson)
return {message: typeof message === 'object' ? JSON.stringify(message) : message, retryable: false}
} catch (e) {
return {message: messageContent || 'An unknown error occurred.', retryable: false}
}
}
function updateStatusWidget(state, text, onClickHandler = null) {
if (!statusWidget) return
statusWidget.classList.remove('loading', 'success', 'error')
statusWidget.onclick = onClickHandler
if (state === 'hidden') {
statusWidget.style.display = 'none'
return
}
statusWidget.style.display = 'flex'
statusWidget.querySelector('.nig-status-text').textContent = text
statusWidget.classList.add(state)
const icon = statusWidget.querySelector('.nig-status-icon')
icon.innerHTML = ''
if (state === 'success') {
icon.innerHTML = '✅'
} else if (state === 'error') {
icon.innerHTML = '❌'
}
}
function updateSystemStatus() {
if (completedQueue.length > 0) {
const text =
completedQueue.length === 1
? '1 Image Ready! Click to view.'
: `${completedQueue.length} Images Ready! Click to view.`
updateStatusWidget('success', text, () => {
const result = completedQueue.shift()
if (result) {
showImageViewer(result.imageUrls, result.prompt, result.provider)
}
updateSystemStatus()
})
} else if (isGenerating || generationQueue.length > 0) {
const queueText = generationQueue.length > 0 ? ` (Queue: ${generationQueue.length})` : ''
updateStatusWidget('loading', `${currentGenerationStatusText}${queueText}`)
} else {
updateStatusWidget('hidden', '')
}
}
function handleGenerationSuccess(displayUrls, prompt, provider, model, persistentUrls = null) {
completedQueue.push({imageUrls: displayUrls, prompt, provider})
const historyUrls = persistentUrls || displayUrls
historyUrls.forEach(url => addToHistory({date: new Date().toISOString(), prompt, url, provider, model}))
isGenerating = false
updateSystemStatus()
processQueue()
}
function handleGenerationFailure(errorMessage, prompt = 'Unknown', provider, providerProfileUrl = null) {
console.error(`Generation Failed for prompt "${prompt}" with ${provider}:`, errorMessage)
const friendlyError = parseErrorMessage(errorMessage)
errorQueue.push({reason: friendlyError, prompt, provider, providerProfileUrl})
showNextError()
updateStatusWidget('error', 'Generation Failed.')
isGenerating = false
setTimeout(() => {
updateSystemStatus()
processQueue()
}, 3000)
}
async function processQueue() {
if (isGenerating || generationQueue.length === 0) {
return
}
isGenerating = true
const request = generationQueue.shift()
currentGenerationStatusText = 'Requesting...'
updateSystemStatus()
const config = await getConfig()
if (request.provider === 'Google') {
generateImageGoogle(request.prompt)
} else if (request.provider === 'AIHorde') {
generateImageAIHorde(request.prompt)
} else if (request.provider === 'Pollinations') {
generateImagePollinations(request.prompt)
} else if (request.provider === 'OpenAICompat') {
generateImageOpenAICompat(request.prompt, request.providerProfileUrl)
}
}
function handleSelection() {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
generateBtn.style.display = 'none'
return
}
const selectedText = selection.toString().trim()
if (selectedText.length > 5) {
currentSelection = selectedText
const range = selection.getRangeAt(0)
const rects = range.getClientRects()
if (rects.length === 0) {
generateBtn.style.display = 'none'
return
}
const firstRect = rects[0]
const lastRect = rects[rects.length - 1]
generateBtn.style.display = 'block'
generateBtn.style.visibility = 'hidden'
const buttonHeight = generateBtn.offsetHeight
generateBtn.style.visibility = 'visible'
let topPosition = window.scrollY + firstRect.top - buttonHeight - 5
if (topPosition < window.scrollY) {
topPosition = window.scrollY + lastRect.bottom + 5
}
generateBtn.style.top = `${topPosition}px`
generateBtn.style.left = `${window.scrollX + firstRect.left}px`
} else {
generateBtn.style.display = 'none'
}
}
async function onGenerateClick() {
generateBtn.style.display = 'none'
if (window.getSelection) {
window.getSelection().removeAllRanges()
}
if (!currentSelection) return
const config = await getConfig()
let finalPrompt = currentSelection
let prefix = ''
if (config.customStyleEnabled && config.customStyleText) {
prefix = config.customStyleText.trim()
if (prefix && !prefix.endsWith(', ')) prefix += ', '
} else if (config.mainPromptStyle !== 'None') {
if (config.subPromptStyle && config.subPromptStyle !== 'none') {
prefix = config.subPromptStyle
} else {
prefix = `${config.mainPromptStyle} style, `
}
}
finalPrompt = prefix + finalPrompt
// Append global negative prompt to the main prompt string for providers that don't support a separate field
if (config.enableNegPrompt && config.globalNegPrompt && config.selectedProvider !== 'AIHorde') {
finalPrompt += `, negative prompt: ${config.globalNegPrompt}`
}
if (config.selectedProvider === 'Google' && !config.googleApiKey) {
showGoogleApiKeyPrompt()
return
}
generationQueue.push({
prompt: finalPrompt,
provider: config.selectedProvider,
providerProfileUrl: config.openAICompatActiveProfileUrl
})
updateSystemStatus()
processQueue()
}
function createImageViewer() {
if (document.getElementById('nig-image-viewer')) return
imageViewer = document.createElement('div')
imageViewer.id = 'nig-image-viewer'
imageViewer.className = 'nig-modal-overlay'
imageViewer.style.display = 'none'
imageViewer.innerHTML = `
<div class="nig-modal-content">
<span class="nig-close-btn">×</span>
<div id="nig-prompt-container" class="nig-prompt-container">
<div class="nig-prompt-header"><span>Generated Image Prompt</span></div>
<p id="nig-prompt-text" class="nig-prompt-text"></p>
</div>
<div id="nig-image-gallery" class="nig-image-gallery"></div>
</div>`
document.body.appendChild(imageViewer)
imageViewer.querySelector('.nig-close-btn').addEventListener('click', () => {
imageViewer.style.display = 'none'
updateSystemStatus()
})
const promptContainer = imageViewer.querySelector('#nig-prompt-container')
promptContainer.addEventListener('click', () => {
promptContainer.classList.toggle('expanded')
})
}
function showGoogleApiKeyPrompt() {
if (document.getElementById('nig-google-api-prompt')) return
googleApiPrompt = document.createElement('div')
googleApiPrompt.id = 'nig-google-api-prompt'
googleApiPrompt.className = 'nig-modal-overlay'
googleApiPrompt.innerHTML = `<div class="nig-modal-content"><span class="nig-close-btn">×</span><h2>Google API Key Required</h2><p>Please provide your Google AI Gemini API key. You can get one from <a href="https://aistudio.google.com/api-keys" target="_blank" class="nig-api-prompt-link">Google AI Studio</a>.</p><div class="nig-form-group"><label for="nig-prompt-api-key">Gemini API Key</label><input type="password" id="nig-prompt-api-key"></div><button id="nig-prompt-save-btn" class="nig-save-btn">Save Key</button></div>`
document.body.appendChild(googleApiPrompt)
googleApiPrompt.querySelector('.nig-close-btn').addEventListener('click', () => googleApiPrompt.remove())
googleApiPrompt.querySelector('#nig-prompt-save-btn').addEventListener('click', async () => {
const key = googleApiPrompt.querySelector('#nig-prompt-api-key').value.trim()
if (key) {
await setConfig('googleApiKey', key)
googleApiPrompt.remove()
alert('API Key saved. You can now generate an image.')
} else {
alert('API Key cannot be empty.')
}
})
}
function showPollinationsAuthPrompt(errorMessage, failedPrompt) {
if (document.getElementById('nig-pollinations-auth-prompt')) return
pollinationsAuthPrompt = document.createElement('div')
pollinationsAuthPrompt.id = 'nig-pollinations-auth-prompt'
pollinationsAuthPrompt.className = 'nig-modal-overlay'
pollinationsAuthPrompt.innerHTML = `
<div class="nig-modal-content">
<span class="nig-close-btn">×</span>
<h2>Authentication Required</h2>
<p>The Pollinations.ai model you selected requires authentication. You can get free access by registering.</p>
<p><strong>Error Message:</strong> <em>${errorMessage}</em></p>
<p>Please visit <a href="https://auth.pollinations.ai" target="_blank" class="nig-api-prompt-link">auth.pollinations.ai</a> to continue. You can either:</p>
<ul>
<li><strong>Register the Referrer:</strong> The easiest method. Just register the domain <code>wtr-lab.com</code>. This links your usage to your account without needing a token.</li>
<li><strong>Use a Token:</strong> Get an API token and enter it below.</li>
</ul>
<div class="nig-form-group">
<label for="nig-prompt-pollinations-token">Pollinations API Token</label>
<input type="password" id="nig-prompt-pollinations-token">
</div>
<button id="nig-prompt-save-token-btn" class="nig-save-btn">Save Token & Retry</button>
</div>`
document.body.appendChild(pollinationsAuthPrompt)
pollinationsAuthPrompt
.querySelector('.nig-close-btn')
.addEventListener('click', () => pollinationsAuthPrompt.remove())
pollinationsAuthPrompt.querySelector('#nig-prompt-save-token-btn').addEventListener('click', async () => {
const token = pollinationsAuthPrompt.querySelector('#nig-prompt-pollinations-token').value.trim()
if (token) {
await setConfig('pollinationsToken', token)
pollinationsAuthPrompt.remove()
alert('Token saved. Retrying generation...')
generationQueue.unshift({prompt: failedPrompt, provider: 'Pollinations'})
isGenerating = false
processQueue()
} else {
alert('Token cannot be empty.')
}
})
}
function showImageViewer(imageUrls, prompt, provider) {
if (!imageViewer) createImageViewer()
const gallery = imageViewer.querySelector('#nig-image-gallery')
gallery.innerHTML = ''
const promptContainer = imageViewer.querySelector('#nig-prompt-container')
const promptText = imageViewer.querySelector('#nig-prompt-text')
promptText.textContent = prompt
promptContainer.classList.remove('expanded')
const extension = provider === 'Pollinations' || provider === 'OpenAICompat' ? 'jpg' : 'png'
imageUrls.forEach((url, index) => {
const container = document.createElement('div')
container.className = 'nig-image-container'
const img = document.createElement('img')
img.src = url
const actions = document.createElement('div')
actions.className = 'nig-image-actions'
const downloadBtn = document.createElement('button')
downloadBtn.innerHTML = '<span class="material-symbols-outlined">download</span>'
downloadBtn.title = 'Download'
downloadBtn.onclick = () => {
const a = document.createElement('a')
a.href = url
a.download = `${prompt.substring(0, 20).replace(/\s/g, '_')}_${index}.${extension}`
a.click()
}
const fullscreenBtn = document.createElement('button')
fullscreenBtn.innerHTML = '<span class="material-symbols-outlined">fullscreen</span>'
fullscreenBtn.title = 'Fullscreen'
fullscreenBtn.onclick = () => {
if (img.requestFullscreen) img.requestFullscreen()
}
actions.appendChild(downloadBtn)
actions.appendChild(fullscreenBtn)
container.appendChild(img)
container.appendChild(actions)
gallery.appendChild(container)
})
imageViewer.style.display = 'flex'
}
async function generateImageGoogle(prompt) {
currentGenerationStatusText = 'Generating with Google...'
updateSystemStatus()
const config = await getConfig()
const model = config.model
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict`
const parameters = {
sampleCount: parseInt(config.numberOfImages, 10),
aspectRatio: config.aspectRatio,
personGeneration: config.personGeneration
}
if (!model.includes('fast')) {
parameters.imageSize = parseInt(config.imageSize, 10)
}
GM_xmlhttpRequest({
method: 'POST',
url,
headers: {'x-goog-api-key': config.googleApiKey, 'Content-Type': 'application/json'},
data: JSON.stringify({instances: [{prompt}], parameters}),
onload: response => {
try {
const data = JSON.parse(response.responseText)
if (data.error) throw new Error(JSON.stringify(data.error))
const imageUrls = data.predictions.map(p => `data:image/png;base64,${p.bytesB64Encoded}`)
handleGenerationSuccess(imageUrls, prompt, 'Google', model)
} catch (e) {
handleGenerationFailure(e.message, prompt, 'Google')
}
},
onerror: error => {
handleGenerationFailure(JSON.stringify(error), prompt, 'Google')
}
})
}
async function generateImageAIHorde(prompt) {
const config = await getConfig()
const apiKey = config.aiHordeApiKey || '0000000000'
const model = config.aiHordeModel
const params = {
shared: true,
sampler_name: config.aiHordeSampler,
cfg_scale: parseFloat(config.aiHordeCfgScale),
steps: parseInt(config.aiHordeSteps, 10),
width: parseInt(config.aiHordeWidth, 10),
height: parseInt(config.aiHordeHeight, 10)
}
if (config.aiHordeSeed) params.seed = config.aiHordeSeed
if (config.aiHordePostProcessing.length > 0) params.post_processing = config.aiHordePostProcessing
const payload = {prompt: prompt, params: params, models: [model]}
if (config.enableNegPrompt && config.globalNegPrompt) payload.negative_prompt = config.globalNegPrompt
GM_xmlhttpRequest({
method: 'POST',
url: 'https://aihorde.net/api/v2/generate/async',
headers: {'Content-Type': 'application/json', apikey: apiKey},
data: JSON.stringify(payload),
onload: response => {
try {
const data = JSON.parse(response.responseText)
if (data.id) {
checkAIHordeStatus(data.id, prompt, Date.now(), model)
} else {
if (data.message && data.message.toLowerCase().includes('model')) {
handleGenerationFailure(`Model error: ${data.message}. Refreshing model list.`, prompt, 'AIHorde')
clearCachedModels('aiHorde')
return
}
throw new Error(data.message || 'Failed to initiate generation.')
}
} catch (e) {
handleGenerationFailure(e.message, prompt, 'AIHorde')
}
},
onerror: error => {
handleGenerationFailure(JSON.stringify(error), prompt, 'AIHorde')
}
})
}
function checkAIHordeStatus(id, prompt, startTime, model) {
if (Date.now() - startTime > 300000) {
handleGenerationFailure('Timed out after 5 minutes.', prompt, 'AIHorde')
return
}
GM_xmlhttpRequest({
method: 'GET',
url: `https://aihorde.net/api/v2/generate/status/${id}`,
onload: response => {
try {
const data = JSON.parse(response.responseText)
if (data.done) {
const imageUrls = data.generations.map(gen => gen.img)
handleGenerationSuccess(imageUrls, prompt, 'AIHorde', model)
} else {
let statusText = `Waiting for worker...`
if (data.queue_position > 0) {
statusText = `Queue: ${data.queue_position}. Est: ${data.wait_time}s.`
}
if (data.processing > 0) {
statusText = `Generating...`
}
currentGenerationStatusText = statusText
updateSystemStatus()
setTimeout(() => checkAIHordeStatus(id, prompt, startTime, model), 5000)
}
} catch (e) {
handleGenerationFailure(`Error checking status: ${e.message}`, prompt, 'AIHorde')
}
},
onerror: error => {
handleGenerationFailure('Failed to get status from AI Horde.', prompt, 'AIHorde')
}
})
}
async function generateImagePollinations(prompt) {
currentGenerationStatusText = 'Generating with Pollinations...'
updateSystemStatus()
const config = await getConfig()
const model = config.pollinationsModel
const encodedPrompt = encodeURIComponent(prompt)
const params = new URLSearchParams()
if (config.pollinationsToken) params.append('token', config.pollinationsToken)
if (model && model !== 'flux') params.append('model', model)
if (config.pollinationsWidth && config.pollinationsWidth != 1024) params.append('width', config.pollinationsWidth)
if (config.pollinationsHeight && config.pollinationsHeight != 1024) params.append('height', config.pollinationsHeight)
if (config.pollinationsSeed) params.append('seed', config.pollinationsSeed)
if (config.pollinationsEnhance) params.append('enhance', 'true')
if (config.pollinationsSafe) params.append('safe', 'true')
if (config.pollinationsNologo) params.append('nologo', 'true')
if (config.pollinationsPrivate) params.append('private', 'true')
const paramString = params.toString()
const url = `https://image.pollinations.ai/prompt/${encodedPrompt}${paramString ? '?' + paramString : ''}`
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
},
onload: response => {
if (response.status >= 200 && response.status < 300) {
const blobUrl = URL.createObjectURL(response.response)
handleGenerationSuccess([blobUrl], prompt, 'Pollinations', model, [url])
} else {
const handleErrorResponse = async () => {
try {
const text = await response.response.text()
if (text.toLowerCase().includes('model not found')) {
handleGenerationFailure(`Model error: ${text}. Refreshing model list.`, prompt, 'Pollinations')
clearCachedModels('pollinations')
return
}
if (response.status === 403) {
try {
const errorJson = JSON.parse(text)
if (errorJson.message && errorJson.message.includes('authenticate at https://auth.pollinations.ai')) {
showPollinationsAuthPrompt(errorJson.message, prompt)
isGenerating = false
updateStatusWidget('error', 'Authentication needed.')
setTimeout(() => updateSystemStatus(), 4000)
return
}
} catch (jsonError) {
/* Not a JSON error, fall through */
}
}
handleGenerationFailure(`Error ${response.status}: ${text}`, prompt, 'Pollinations')
} catch (e) {
handleGenerationFailure(`Error ${response.status}: ${response.statusText}`, prompt, 'Pollinations')
}
}
handleErrorResponse()
}
},
onerror: error => {
handleGenerationFailure(JSON.stringify(error), prompt, 'Pollinations')
}
})
}
async function generateImageOpenAICompat(prompt, providerProfileUrl = null) {
currentGenerationStatusText = 'Generating with OpenAI Compatible API...'
updateSystemStatus()
const config = await getConfig()
const profiles = config.openAICompatProfiles
const activeUrl = providerProfileUrl || config.openAICompatActiveProfileUrl
const activeProfile = profiles[activeUrl]
if (!activeProfile) {
handleGenerationFailure(
`No active or valid OpenAI Compatible profile found for URL: ${activeUrl}`,
prompt,
'OpenAICompat'
)
return
}
const url = `${activeUrl}/images/generations`
const payload = {
model: activeProfile.model,
prompt: prompt,
n: 1,
size: '1024x1024',
response_format: 'b64_json'
}
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeProfile.apiKey}`
},
data: JSON.stringify(payload),
onload: response => {
try {
const data = JSON.parse(response.responseText)
if (data && Array.isArray(data.data) && data.data.length > 0) {
const imageUrls = data.data
.map(item => {
if (item.b64_json) {
return `data:image/png;base64,${item.b64_json}`
} else if (item.url) {
return item.url
}
return null
})
.filter(url => url !== null)
if (imageUrls.length > 0) {
handleGenerationSuccess(imageUrls, prompt, 'OpenAICompat', activeProfile.model)
} else {
throw new Error('API response did not contain usable image data (b64_json or url).')
}
} else {
throw new Error(JSON.stringify(data))
}
} catch (e) {
handleGenerationFailure(e.message, prompt, 'OpenAICompat', activeUrl)
}
},
onerror: error => {
handleGenerationFailure(JSON.stringify(error), prompt, 'OpenAICompat', activeUrl)
}
})
}
function updateVisibleSettings() {
const provider = document.getElementById('nig-provider').value
document.querySelectorAll('.nig-provider-settings').forEach(el => (el.style.display = 'none'))
const settingsEl = document.getElementById(`nig-provider-${provider}`)
if (settingsEl) {
settingsEl.style.display = 'block'
}
}
function populatePollinationsSelect(select, models, selectedModel) {
select.innerHTML = ''
models.forEach(model => {
const option = document.createElement('option')
option.value = model
let textContent = model
if (model === 'gptimage') {
textContent += ' (Recommended: Quality)'
} else if (model === 'flux') {
textContent += ' (Default: Speed)'
}
option.textContent = textContent
select.appendChild(option)
})
if (models.includes(selectedModel)) {
select.value = selectedModel
}
}
async function fetchPollinationsModels(selectedModel) {
const select = document.getElementById('nig-pollinations-model')
const cache = await getCachedModels()
if (cache.pollinations && cache.pollinations.length > 0) {
log('Loading Pollinations models from cache.')
populatePollinationsSelect(select, cache.pollinations, selectedModel)
return
}
select.innerHTML = '<option>Fetching models...</option>'
GM_xmlhttpRequest({
method: 'GET',
url: 'https://image.pollinations.ai/models',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
},
onload: async response => {
try {
const models = JSON.parse(response.responseText)
await setCachedModels('pollinations', models)
log('Fetched and cached Pollinations models.')
populatePollinationsSelect(select, models, selectedModel)
} catch (e) {
select.innerHTML = '<option>Failed to load models</option>'
console.error('Failed to parse Pollinations models:', e)
}
},
onerror: () => {
select.innerHTML = '<option>Failed to load models</option>'
}
})
}
async function fetchAIHordeModels(selectedModel) {
const select = document.getElementById('nig-horde-model')
const cache = await getCachedModels()
const populateSelect = models => {
select.innerHTML = ''
const apiModelMap = new Map(models.map(m => [m.name, m]))
const topModelNames = new Set(TOP_MODELS.map(m => m.name))
const topGroup = document.createElement('optgroup')
topGroup.label = 'Top 10 Popular Models'
const otherGroup = document.createElement('optgroup')
otherGroup.label = 'Other Models'
TOP_MODELS.forEach(topModel => {
if (apiModelMap.has(topModel.name)) {
const apiData = apiModelMap.get(topModel.name)
const option = document.createElement('option')
option.value = topModel.name
option.textContent = `${topModel.name} - ${topModel.desc} (${apiData.count} workers)`
topGroup.appendChild(option)
}
})
const otherModels = models.filter(m => !topModelNames.has(m.name)).sort((a, b) => b.count - a.count)
otherModels.forEach(model => {
const option = document.createElement('option')
option.value = model.name
option.textContent = `${model.name} (${model.count} workers)`
otherGroup.appendChild(option)
})
select.appendChild(topGroup)
select.appendChild(otherGroup)
if (Array.from(select.options).some(opt => opt.value === selectedModel)) {
select.value = selectedModel
}
}
if (cache.aiHorde && cache.aiHorde.length > 0) {
log('Loading AI Horde models from cache.')
populateSelect(cache.aiHorde)
return
}
select.innerHTML = '<option>Fetching models...</option>'
GM_xmlhttpRequest({
method: 'GET',
url: 'https://aihorde.net/api/v2/status/models?type=image',
onload: async response => {
try {
const apiModels = JSON.parse(response.responseText)
await setCachedModels('aiHorde', apiModels)
log('Fetched and cached AI Horde models.')
populateSelect(apiModels)
} catch (e) {
select.innerHTML = '<option>Failed to load models</option>'
console.error('Failed to parse AI Horde models:', e)
}
},
onerror: () => {
select.innerHTML = '<option>Failed to load models</option>'
}
})
}
function isModelFree(model) {
if (typeof model.is_free === 'boolean') return model.is_free
if (typeof model.premium_model === 'boolean') return !model.premium_model
if (Array.isArray(model.tiers) && model.tiers.includes('Free')) return true
return false
}
function populateOpenAICompatSelect(select, models, selectedModel) {
select.innerHTML = ''
const freeGroup = document.createElement('optgroup')
freeGroup.label = 'Free Models'
const paidGroup = document.createElement('optgroup')
paidGroup.label = 'Paid Models'
models.forEach(model => {
const option = document.createElement('option')
option.value = model.id
option.textContent = model.id
if (isModelFree(model)) {
freeGroup.appendChild(option)
} else {
paidGroup.appendChild(option)
}
})
if (freeGroup.childElementCount > 0) select.appendChild(freeGroup)
if (paidGroup.childElementCount > 0) select.appendChild(paidGroup)
if (models.some(m => m.id === selectedModel)) {
select.value = selectedModel
}
}
async function fetchOpenAICompatModels(selectedModel) {
const select = document.getElementById('nig-openai-compat-model')
const baseUrl = document.getElementById('nig-openai-compat-base-url').value.trim()
const apiKey = document.getElementById('nig-openai-compat-api-key').value.trim()
if (!baseUrl) {
alert('Please enter a Base URL first.')
return
}
const cacheKey = `openAICompat::${baseUrl}`
const cache = await getCachedModels()
const cachedData = cache[cacheKey]
if (cachedData && cachedData.timestamp && Date.now() - cachedData.timestamp < CACHE_EXPIRATION_MS) {
log(`Loading OpenAI Compatible models for ${baseUrl} from cache.`)
populateOpenAICompatSelect(select, cachedData.models, selectedModel)
return
}
const switchToManual = () => {
document.getElementById('nig-openai-model-container-select').style.display = 'none'
document.getElementById('nig-openai-model-container-manual').style.display = 'block'
}
select.innerHTML = '<option>Fetching models...</option>'
GM_xmlhttpRequest({
method: 'GET',
url: `${baseUrl}/models`,
headers: {Authorization: `Bearer ${apiKey}`},
onload: async response => {
try {
const data = JSON.parse(response.responseText)
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid model list format received.')
}
let imageModels = []
if (data.data.some(m => m.endpoint || m.endpoints)) {
imageModels = data.data.filter(
model => model.endpoint === '/v1/images/generations' || model.endpoints?.includes('/v1/images/generations')
)
} else if (data.data.some(m => m.type === 'images.generations')) {
imageModels = data.data.filter(model => model.type === 'images.generations')
} else {
throw new Error('Could not determine image models from response.')
}
imageModels.sort((a, b) => {
const aIsFree = isModelFree(a)
const bIsFree = isModelFree(b)
if (aIsFree && !bIsFree) return -1
if (!aIsFree && bIsFree) return 1
return a.id.localeCompare(b.id)
})
// Cache the fetched models
await setCachedModels(cacheKey, {models: imageModels, timestamp: Date.now()})
log(`Fetched and cached OpenAI Compatible models for ${baseUrl}.`)
populateOpenAICompatSelect(select, imageModels, selectedModel)
} catch (e) {
select.innerHTML = '<option>Failed to load models</option>'
console.error('Failed to parse OpenAI Compatible models:', e)
alert(
`Failed to fetch models. Check the Base URL and API Key. You can enter the model name manually. Error: ${e.message}`
)
switchToManual()
}
},
onerror: error => {
select.innerHTML = '<option>Failed to load models</option>'
console.error('Error fetching OpenAI Compatible models:', error)
alert('Failed to fetch models. Check your network connection and the Base URL. Switching to manual input.')
switchToManual()
}
})
}
function updateSubStyles(mainStyleName) {
const subStyleSelect = document.getElementById('nig-sub-style')
const mainStyleDesc = document.getElementById('nig-main-style-desc')
const subStyleDesc = document.getElementById('nig-sub-style-desc')
const selectedCategory = PROMPT_CATEGORIES.find(cat => cat.name === mainStyleName)
mainStyleDesc.textContent = selectedCategory ? selectedCategory.description : ''
subStyleSelect.innerHTML = ''
if (selectedCategory && selectedCategory.subStyles.length > 0) {
subStyleSelect.disabled = false
selectedCategory.subStyles.forEach(sub => {
const option = document.createElement('option')
option.value = sub.value
option.textContent = sub.name
subStyleSelect.appendChild(option)
})
subStyleSelect.dispatchEvent(new Event('change'))
} else {
subStyleSelect.disabled = true
subStyleDesc.textContent = ''
}
}
// --- OpenAI Profile Management ---
async function loadOpenAIProfiles() {
const config = await getConfig()
const profiles = config.openAICompatProfiles
const activeUrl = config.openAICompatActiveProfileUrl
const select = document.getElementById('nig-openai-compat-profile-select')
select.innerHTML = ''
Object.keys(profiles).forEach(url => {
const option = document.createElement('option')
option.value = url
option.textContent = url
select.appendChild(option)
})
const newOption = document.createElement('option')
newOption.value = '__new__'
newOption.textContent = '— Add or Edit Profile —'
select.appendChild(newOption)
if (activeUrl && profiles[activeUrl]) {
select.value = activeUrl
} else {
select.value = '__new__'
}
loadSelectedOpenAIProfile()
}
async function loadSelectedOpenAIProfile() {
const select = document.getElementById('nig-openai-compat-profile-select')
const selectedUrl = select.value
const profiles = await GM_getValue('openAICompatProfiles', {})
const profile = profiles[selectedUrl] || {apiKey: '', model: ''}
document.getElementById('nig-openai-compat-base-url').value = selectedUrl === '__new__' ? '' : selectedUrl
document.getElementById('nig-openai-compat-api-key').value = profile.apiKey
document.getElementById('nig-openai-compat-model-manual').value = profile.model
if (selectedUrl !== '__new__') {
fetchOpenAICompatModels(profile.model)
} else {
document.getElementById('nig-openai-compat-model').innerHTML = '<option>Enter URL/Key and fetch...</option>'
}
}
async function deleteSelectedOpenAIProfile() {
const select = document.getElementById('nig-openai-compat-profile-select')
const urlToDelete = select.value
if (urlToDelete === '__new__') {
alert("You can't delete the 'Add New' option.")
return
}
if (confirm(`Are you sure you want to delete the profile for "${urlToDelete}"?`)) {
const profiles = await GM_getValue('openAICompatProfiles', {})
delete profiles[urlToDelete]
await GM_setValue('openAICompatProfiles', profiles)
await loadOpenAIProfiles()
}
}
async function populateConfigForm() {
const config = await getConfig()
document.getElementById('nig-provider').value = config.selectedProvider
// --- Populate Prompt Styling Tab ---
const mainStyleSelect = document.getElementById('nig-main-style')
const subStyleSelect = document.getElementById('nig-sub-style')
const subStyleDesc = document.getElementById('nig-sub-style-desc')
const customStyleEnable = document.getElementById('nig-custom-style-enable')
const customStyleText = document.getElementById('nig-custom-style-text')
mainStyleSelect.innerHTML = ''
PROMPT_CATEGORIES.forEach(cat => {
const option = document.createElement('option')
option.value = cat.name
option.textContent = cat.name
mainStyleSelect.appendChild(option)
})
mainStyleSelect.value = config.mainPromptStyle
updateSubStyles(config.mainPromptStyle)
subStyleSelect.value = config.subPromptStyle
mainStyleSelect.addEventListener('change', () => updateSubStyles(mainStyleSelect.value))
subStyleSelect.addEventListener('change', () => {
const category = PROMPT_CATEGORIES.find(c => c.name === mainStyleSelect.value)
if (category) {
const subStyle = category.subStyles.find(s => s.value === subStyleSelect.value)
subStyleDesc.textContent = subStyle ? subStyle.description : ''
}
})
subStyleSelect.dispatchEvent(new Event('change'))
customStyleEnable.checked = config.customStyleEnabled
customStyleText.value = config.customStyleText
customStyleText.disabled = !config.customStyleEnabled
// Global Negative Prompting
document.getElementById('nig-enable-neg-prompt').checked = config.enableNegPrompt
document.getElementById('nig-global-neg-prompt').value = config.globalNegPrompt
// Pollinations
document.getElementById('nig-pollinations-width').value = config.pollinationsWidth
document.getElementById('nig-pollinations-height').value = config.pollinationsHeight
document.getElementById('nig-pollinations-seed').value = config.pollinationsSeed
document.getElementById('nig-pollinations-enhance').checked = config.pollinationsEnhance
document.getElementById('nig-pollinations-safe').checked = config.pollinationsSafe
document.getElementById('nig-pollinations-nologo').checked = config.pollinationsNologo
document.getElementById('nig-pollinations-private').checked = config.pollinationsPrivate
document.getElementById('nig-pollinations-token').value = config.pollinationsToken
fetchPollinationsModels(config.pollinationsModel)
// AI Horde
document.getElementById('nig-horde-api-key').value = config.aiHordeApiKey
document.getElementById('nig-horde-sampler').value = config.aiHordeSampler
document.getElementById('nig-horde-steps').value = config.aiHordeSteps
document.getElementById('nig-horde-cfg').value = config.aiHordeCfgScale
document.getElementById('nig-horde-width').value = config.aiHordeWidth
document.getElementById('nig-horde-height').value = config.aiHordeHeight
document.getElementById('nig-horde-seed').value = config.aiHordeSeed
document.querySelectorAll('input[name="nig-horde-post"]').forEach(cb => {
cb.checked = config.aiHordePostProcessing.includes(cb.value)
})
fetchAIHordeModels(config.aiHordeModel)
// Google
document.getElementById('nig-google-api-key').value = config.googleApiKey
document.getElementById('nig-model').value = config.model
document.getElementById('nig-num-images').value = config.numberOfImages
document.getElementById('nig-image-size').value = config.imageSize
document.getElementById('nig-aspect-ratio').value = config.aspectRatio
document.getElementById('nig-person-gen').value = config.personGeneration
// OpenAI Compatible
await loadOpenAIProfiles()
if (config.openAICompatModelManualInput) {
document.getElementById('nig-openai-model-container-select').style.display = 'none'
document.getElementById('nig-openai-model-container-manual').style.display = 'block'
} else {
document.getElementById('nig-openai-model-container-select').style.display = 'block'
document.getElementById('nig-openai-model-container-manual').style.display = 'none'
}
updateVisibleSettings()
}
async function populateHistoryTab() {
const history = await getHistory()
const historyList = document.getElementById('nig-history-list')
historyList.innerHTML = history.length ? '' : '<li>No history yet.</li>'
history.forEach(item => {
const li = document.createElement('li')
li.className = 'nig-history-item'
const providerInfo = item.provider ? `<strong>${item.provider}</strong>` : ''
const modelInfo = item.model ? `(${item.model})` : ''
// Set the static part of the HTML
li.innerHTML = `<small>${new Date(item.date).toLocaleString()} - ${providerInfo} ${modelInfo}</small>
<small><em>${item.prompt.substring(0, 70)}...</em></small>`
// Create the link element separately to add the event listener
const viewLink = document.createElement('a')
viewLink.href = '#' // Use a non-navigating href
viewLink.textContent = 'View Generated Image'
viewLink.addEventListener('click', e => {
e.preventDefault()
if (item.url && item.url.startsWith('data:image')) {
// For base64 images, use the internal viewer to avoid browser issues
showImageViewer([item.url], item.prompt, item.provider)
} else {
// For regular URLs, open in a new tab
window.open(item.url, '_blank')
}
})
li.appendChild(viewLink)
historyList.appendChild(li)
})
}
async function cleanHistory() {
const daysInput = document.getElementById('nig-history-clean-days')
const days = parseInt(daysInput.value, 10)
if (isNaN(days) || days < 1 || days > 365) {
alert('Please enter a valid number of days (1-365).')
return
}
if (confirm(`Are you sure you want to delete all history entries older than ${days} days? This cannot be undone.`)) {
const history = await getHistory()
const cutoffDate = Date.now() - days * 24 * 60 * 60 * 1000
const newHistory = history.filter(item => new Date(item.date).getTime() >= cutoffDate)
await GM_setValue('history', JSON.stringify(newHistory))
await populateHistoryTab()
alert(`History cleaned. ${history.length - newHistory.length} entries were removed.`)
}
}
async function openConfigPanel() {
if (!configPanel) createConfigPanel()
configPanel.querySelectorAll('.nig-tab, .nig-tab-content').forEach(el => el.classList.remove('active'))
configPanel.querySelector('.nig-tab[data-tab="config"]').classList.add('active')
configPanel.querySelector('#nig-config-tab').classList.add('active')
configPanel.querySelector('#nig-save-btn').style.display = 'block'
await populateConfigForm()
configPanel.style.display = 'flex'
}
async function saveConfig() {
// Prompt Styling
await setConfig('mainPromptStyle', document.getElementById('nig-main-style').value)
await setConfig('subPromptStyle', document.getElementById('nig-sub-style').value)
await setConfig('customStyleEnabled', document.getElementById('nig-custom-style-enable').checked)
await setConfig('customStyleText', document.getElementById('nig-custom-style-text').value.trim())
// Global Negative Prompting
await setConfig('enableNegPrompt', document.getElementById('nig-enable-neg-prompt').checked)
await setConfig('globalNegPrompt', document.getElementById('nig-global-neg-prompt').value.trim())
await setConfig('selectedProvider', document.getElementById('nig-provider').value)
// Pollinations
await setConfig('pollinationsModel', document.getElementById('nig-pollinations-model').value)
await setConfig('pollinationsWidth', document.getElementById('nig-pollinations-width').value)
await setConfig('pollinationsHeight', document.getElementById('nig-pollinations-height').value)
await setConfig('pollinationsSeed', document.getElementById('nig-pollinations-seed').value.trim())
await setConfig('pollinationsEnhance', document.getElementById('nig-pollinations-enhance').checked)
await setConfig('pollinationsSafe', document.getElementById('nig-pollinations-safe').checked)
await setConfig('pollinationsNologo', document.getElementById('nig-pollinations-nologo').checked)
await setConfig('pollinationsPrivate', document.getElementById('nig-pollinations-private').checked)
await setConfig('pollinationsToken', document.getElementById('nig-pollinations-token').value.trim())
// AI Horde
await setConfig('aiHordeApiKey', document.getElementById('nig-horde-api-key').value.trim() || '0000000000')
await setConfig('aiHordeModel', document.getElementById('nig-horde-model').value)
await setConfig('aiHordeSampler', document.getElementById('nig-horde-sampler').value)
await setConfig('aiHordeSteps', document.getElementById('nig-horde-steps').value)
await setConfig('aiHordeCfgScale', document.getElementById('nig-horde-cfg').value)
await setConfig('aiHordeWidth', document.getElementById('nig-horde-width').value)
await setConfig('aiHordeHeight', document.getElementById('nig-horde-height').value)
await setConfig('aiHordeSeed', document.getElementById('nig-horde-seed').value.trim())
const postProcessing = Array.from(document.querySelectorAll('input[name="nig-horde-post"]:checked')).map(
cb => cb.value
)
await setConfig('aiHordePostProcessing', postProcessing)
// Google
await setConfig('googleApiKey', document.getElementById('nig-google-api-key').value.trim())
await setConfig('model', document.getElementById('nig-model').value)
await setConfig('numberOfImages', document.getElementById('nig-num-images').value)
await setConfig('imageSize', document.getElementById('nig-image-size').value)
await setConfig('aspectRatio', document.getElementById('nig-aspect-ratio').value)
await setConfig('personGeneration', document.getElementById('nig-person-gen').value)
// OpenAI Compatible
const baseUrl = document.getElementById('nig-openai-compat-base-url').value.trim()
if (baseUrl) {
const profiles = await GM_getValue('openAICompatProfiles', {})
const manualContainer = document.getElementById('nig-openai-model-container-manual')
const isManualMode = manualContainer.style.display !== 'none'
let model
if (isManualMode) {
model = document.getElementById('nig-openai-compat-model-manual').value.trim()
} else {
model = document.getElementById('nig-openai-compat-model').value
}
profiles[baseUrl] = {
apiKey: document.getElementById('nig-openai-compat-api-key').value.trim(),
model: model
}
await setConfig('openAICompatProfiles', profiles)
await setConfig('openAICompatActiveProfileUrl', baseUrl)
await setConfig('openAICompatModelManualInput', isManualMode)
}
alert('Configuration saved!')
// configPanel.style.display = 'none' // Panel should remain open after saving
}
// --- 5. INITIALIZATION ---
async function init() {
await updateLoggingStatus()
createUI()
createErrorModal()
document.addEventListener('mouseup', handleSelection)
document.addEventListener('selectionchange', handleSelection)
GM_registerMenuCommand('Image Generator Settings', openConfigPanel)
}
init()
})()