Blame Raw
permissionBRICK · f7fc41a9 · · 7194 lines (262.0 KB)
6 contributors
1import { Popper } from '../../../lib.js';
2import {
3 animation_duration,
4 appendMediaToMessage,
5 event_types,
6 eventSource,
7 formatCharacterAvatar,
8 generateQuietPrompt,
9 getCharacterAvatar,
10 getCurrentChatId,
11 getRequestHeaders,
12 getUserAvatar,
13 online_status,
14 saveSettingsDebounced,
15 substituteParams,
16 substituteParamsExtended,
17 systemUserName,
18 this_chid,
19 user_avatar,
20} from '../../../script.js';
21import {
22 doExtrasFetch,
23 extension_settings,
24 getApiUrl,
25 getContext,
26 modules,
27 renderExtensionTemplateAsync,
28 writeExtensionField,
29} from '../../extensions.js';
30import { selected_group } from '../../group-chats.js';
31import {
32 clamp,
33 debounce,
34 deepMerge,
35 delay,
36 getBase64Async,
37 getCharaFilename,
38 initScrollHeight,
39 isFalseBoolean,
40 isTrueBoolean,
41 resetScrollHeight,
42 saveBase64AsFile,
43 stringFormat,
44 waitUntilCondition,
45} from '../../utils.js';
46import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';
47import { SECRET_KEYS, secret_state } from '../../secrets.js';
48import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js';
49import { ConnectionManagerRequestService, getMultimodalCaption } from '../shared.js';
50import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
51import { SlashCommand } from '../../slash-commands/SlashCommand.js';
52import {
53 ARGUMENT_TYPE,
54 SlashCommandArgument,
55 SlashCommandNamedArgument,
56} from '../../slash-commands/SlashCommandArgument.js';
57import { debounce_timeout, IMAGE_OVERSWIPE, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION, VIDEO_EXTENSIONS } from '../../constants.js';
58import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
59import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
60import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
61import { ToolManager } from '../../tool-calling.js';
62import { macros, MacroCategory } from '../../macros/macro-system.js';
63import { t, translate } from '../../i18n.js';
64import { oai_settings } from '../../openai.js';
65import { power_user } from '/scripts/power-user.js';
66import { MacrosParser } from '/scripts/macros.js';
67import { ActionLoaderHandle, loader } from '/scripts/action-loader.js';
68
69export { MODULE_NAME };
70
71const MODULE_NAME = 'sd';
72// This is a 1x1 transparent PNG
73const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
74
75const sources = {
76 extras: 'extras',
77 horde: 'horde',
78 auto: 'auto',
79 sdcpp: 'sdcpp',
80 novel: 'novel',
81 vlad: 'vlad',
82 openai: 'openai',
83 aimlapi: 'aimlapi',
84 comfy: 'comfy',
85 togetherai: 'togetherai',
86 drawthings: 'drawthings',
87 pollinations: 'pollinations',
88 stability: 'stability',
89 huggingface: 'huggingface',
90 chutes: 'chutes',
91 electronhub: 'electronhub',
92 nanogpt: 'nanogpt',
93 bfl: 'bfl',
94 falai: 'falai',
95 xai: 'xai',
96 google: 'google',
97 zai: 'zai',
98 openrouter: 'openrouter',
99 workersai: 'workersai',
100};
101const comfyTypes = {
102 standard: 'standard',
103 runpod_serverless: 'runpod_serverless',
104};
105
106const initiators = {
107 command: 'command',
108 action: 'action',
109 interactive: 'interactive',
110 wand: 'wand',
111 swipe: 'swipe',
112 tool: 'tool',
113};
114
115const generationMode = {
116 TOOL: -2,
117 MESSAGE: -1,
118 CHARACTER: 0,
119 USER: 1,
120 SCENARIO: 2,
121 RAW_LAST: 3,
122 NOW: 4,
123 FACE: 5,
124 FREE: 6,
125 BACKGROUND: 7,
126 CHARACTER_MULTIMODAL: 8,
127 USER_MULTIMODAL: 9,
128 FACE_MULTIMODAL: 10,
129 FREE_EXTENDED: 11,
130 CUSTOM: 12,
131};
132
133const multimodalMap = {
134 [generationMode.CHARACTER]: generationMode.CHARACTER_MULTIMODAL,
135 [generationMode.USER]: generationMode.USER_MULTIMODAL,
136 [generationMode.FACE]: generationMode.FACE_MULTIMODAL,
137};
138
139const modeLabels = {
140 [generationMode.TOOL]: 'Function Tool Prompt Description',
141 [generationMode.MESSAGE]: 'Chat Message Template',
142 [generationMode.CHARACTER]: 'Character ("Yourself")',
143 [generationMode.FACE]: 'Portrait ("Your Face")',
144 [generationMode.USER]: 'User ("Me")',
145 [generationMode.SCENARIO]: 'Scenario ("The Whole Story")',
146 [generationMode.NOW]: 'Last Message',
147 [generationMode.RAW_LAST]: 'Raw Last Message',
148 [generationMode.BACKGROUND]: 'Background',
149 [generationMode.CHARACTER_MULTIMODAL]: 'Character (Multimodal Mode)',
150 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',
151 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',
152 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',
153 [generationMode.CUSTOM]: 'Custom',
154};
155
156const triggerWords = {
157 [generationMode.CHARACTER]: ['you'],
158 [generationMode.USER]: ['me'],
159 [generationMode.SCENARIO]: ['scene'],
160 [generationMode.RAW_LAST]: ['raw_last'],
161 [generationMode.NOW]: ['last'],
162 [generationMode.FACE]: ['face'],
163 [generationMode.BACKGROUND]: ['background'],
164};
165
166const messageTrigger = {
167 activationRegex: /\b(send|mail|imagine|generate|make|create|draw|paint|render|show)\b.{0,10}\b(pic|picture|image|drawing|painting|photo|photograph)\b(?:\s+of)?(?:\s+(?:a|an|the|this|that|those|your)?\s+)?(.+)/i,
168 specialCases: {
169 [generationMode.CHARACTER]: ['you', 'yourself'],
170 [generationMode.USER]: ['me', 'myself'],
171 [generationMode.SCENARIO]: ['story', 'scenario', 'whole story'],
172 [generationMode.NOW]: ['last message'],
173 [generationMode.FACE]: ['face', 'portrait', 'selfie'],
174 [generationMode.BACKGROUND]: ['background', 'scene background', 'scene', 'scenery', 'surroundings', 'environment'],
175 },
176};
177
178const promptTemplates = {
179 // Not really a prompt template, rather an outcome message template and function tool prompt
180 [generationMode.MESSAGE]: '[{{char}} sends a picture that contains: {{prompt}}].',
181 [generationMode.TOOL]: [
182 'The text prompt used to generate the image.',
183 'Must represent an exhaustive description of the desired image that will allow an artist or a photographer to perfectly recreate it.',
184 ].join(' '),
185 [generationMode.CHARACTER]: 'In the next response I want you to provide only a detailed comma-delimited list of keywords and phrases which describe {{char}}. The list must include all of the following items in this order: name, species and race, gender, age, clothing, occupation, physical features and appearances. Do not include descriptions of non-visual qualities such as personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'full body portrait,\'',
186 //face-specific prompt
187 [generationMode.FACE]: 'In the next response I want you to provide only a detailed comma-delimited list of keywords and phrases which describe {{char}}. The list must include all of the following items in this order: name, species and race, gender, age, facial features and expressions, occupation, hair and hair accessories (if any), what they are wearing on their upper body (if anything). Do not describe anything below their neck. Do not include descriptions of non-visual qualities such as personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'close up facial portrait,\'',
188 //prompt for only the last message
189 [generationMode.USER]: 'Ignore previous instructions and provide a detailed description of {{user}}\'s physical appearance from the perspective of {{char}} in the form of a comma-delimited list of keywords and phrases. The list must include all of the following items in this order: name, species and race, gender, age, clothing, occupation, physical features and appearances. Do not include descriptions of non-visual qualities such as personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'full body portrait,\'. Ignore the rest of the story when crafting this description. Do not reply as {{char}} when writing this description, and do not attempt to continue the story.',
190 [generationMode.SCENARIO]: 'Ignore previous instructions and provide a detailed description for all of the following: a brief recap of recent events in the story, {{char}}\'s appearance, and {{char}}\'s surroundings. Do not reply as {{char}} while writing this description.',
191
192 [generationMode.NOW]: `Ignore previous instructions. Your next response must be formatted as a single comma-delimited list of concise keywords. The list will describe of the visual details included in the last chat message.
193
194 Only mention characters by using pronouns ('he','his','she','her','it','its') or neutral nouns ('male', 'the man', 'female', 'the woman').
195
196 Ignore non-visible things such as feelings, personality traits, thoughts, and spoken dialog.
197
198 Add keywords in this precise order:
199 a keyword to describe the location of the scene,
200 a keyword to mention how many characters of each gender or type are present in the scene (minimum of two characters:
201 {{user}} and {{char}}, example: '2 men ' or '1 man 1 woman ', '1 man 3 robots'),
202
203 keywords to describe the relative physical positioning of the characters to each other (if a commonly known term for the positioning is known use it instead of describing the positioning in detail) + 'POV',
204
205 a single keyword or phrase to describe the primary act taking place in the last chat message,
206
207 keywords to describe {{char}}'s physical appearance and facial expression,
208 keywords to describe {{char}}'s actions,
209 keywords to describe {{user}}'s physical appearance and actions.
210
211 If character actions involve direct physical interaction with another character, mention specifically which body parts interacting and how.
212
213 A correctly formatted example response would be:
214 '(location),(character list by gender),(primary action), (relative character position) POV, (character 1's description and actions), (character 2's description and actions)'`,
215
216 [generationMode.RAW_LAST]: 'Ignore previous instructions and provide ONLY the last chat message string back to me verbatim. Do not write anything after the string. Do not reply as {{char}} when writing this description, and do not attempt to continue the story.',
217 [generationMode.BACKGROUND]: 'Ignore previous instructions and provide a detailed description of {{char}}\'s surroundings in the form of a comma-delimited list of keywords and phrases. The list must include all of the following items in this order: location, time of day, weather, lighting, and any other relevant details. Do not include descriptions of characters and non-visual qualities such as names, personality, movements, scents, mental traits, or anything which could not be seen in a still photograph. Do not write in full sentences. Prefix your description with the phrase \'background,\'. Ignore the rest of the story when crafting this description. Do not reply as {{char}} when writing this description, and do not attempt to continue the story.',
218 [generationMode.FACE_MULTIMODAL]: 'Provide an exhaustive comma-separated list of tags describing the appearance of the character on this image in great detail. Start with "close-up portrait".',
219 [generationMode.CHARACTER_MULTIMODAL]: 'Provide an exhaustive comma-separated list of tags describing the appearance of the character on this image in great detail. Start with "full body portrait".',
220 [generationMode.USER_MULTIMODAL]: 'Provide an exhaustive comma-separated list of tags describing the appearance of the character on this image in great detail. Start with "full body portrait".',
221 [generationMode.FREE_EXTENDED]: 'Ignore previous instructions and provide an exhaustive comma-separated list of tags describing the appearance of "{0}" in great detail. Start with {{charPrefix}} (sic) if the subject is associated with {{char}}.',
222};
223
224const defaultPrefix = 'best quality, absurdres, aesthetic,';
225const defaultNegative = 'lowres, bad anatomy, bad hands, text, error, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry';
226
227const defaultStyles = [
228 {
229 name: 'Default',
230 negative: defaultNegative,
231 prefix: defaultPrefix,
232 },
233];
234
235const placeholderVae = 'Automatic';
236
237const defaultSettings = {
238 source: sources.extras,
239
240 // CFG Scale
241 scale_min: 1,
242 scale_max: 30,
243 scale_step: 0.1,
244 scale: 7,
245
246 // Sampler steps
247 steps_min: 1,
248 steps_max: 150,
249 steps_step: 1,
250 steps: 20,
251
252 // Scheduler
253 scheduler: 'normal',
254
255 // Image dimensions (Width & Height)
256 dimension_min: 64,
257 dimension_max: 2048,
258 dimension_step: 64,
259 width: 512,
260 height: 512,
261
262 prompt_prefix: defaultPrefix,
263 negative_prompt: defaultNegative,
264 sampler: 'DDIM',
265 model: '',
266 vae: '',
267 seed: -1,
268
269 // Automatic1111/Horde exclusives
270 restore_faces: false,
271 enable_hr: false,
272 adetailer_face: false,
273
274 // Horde settings
275 horde: false,
276 horde_nsfw: false,
277 horde_karras: true,
278 horde_sanitize: true,
279
280 // Refine mode
281 refine_mode: false,
282 interactive_mode: false,
283 multimodal_captioning: false,
284 snap: false,
285 free_extend: false,
286 function_tool: false,
287 minimal_prompt_processing: false,
288
289 prompts: promptTemplates,
290
291 // AUTOMATIC1111 settings
292 auto_url: 'http://localhost:7860',
293 auto_auth: '',
294
295 // stable-diffusion.cpp settings
296 sdcpp_url: 'http://127.0.0.1:1234',
297
298 vlad_url: 'http://localhost:7860',
299 vlad_auth: '',
300
301 drawthings_url: 'http://localhost:7860',
302 drawthings_auth: '',
303
304 hr_upscaler: 'Latent',
305 hr_scale: 1.0,
306 hr_scale_min: 1.0,
307 hr_scale_max: 4.0,
308 hr_scale_step: 0.1,
309 denoising_strength: 0.7,
310 denoising_strength_min: 0.0,
311 denoising_strength_max: 1.0,
312 denoising_strength_step: 0.01,
313 hr_second_pass_steps: 0,
314 hr_second_pass_steps_min: 0,
315 hr_second_pass_steps_max: 150,
316 hr_second_pass_steps_step: 1,
317
318 // CLIP skip
319 clip_skip_min: 1,
320 clip_skip_max: 12,
321 clip_skip_step: 1,
322 clip_skip: 1,
323
324 // NovelAI settings
325 novel_anlas_guard: false,
326 novel_sm: false,
327 novel_sm_dyn: false,
328 novel_decrisper: false,
329 novel_variety_boost: false,
330
331 // OpenAI settings
332 openai_style: 'vivid',
333 openai_quality: 'standard',
334 openai_quality_gpt: 'auto',
335 openai_duration: '8',
336
337 style: 'Default',
338 styles: defaultStyles,
339
340 // ComyUI settings
341 comfy_type: 'standard',
342
343 comfy_url: 'http://127.0.0.1:8188',
344 comfy_workflow: 'Default_Comfy_Workflow.json',
345
346 comfy_runpod_url: '',
347
348 // Pollinations settings
349 pollinations_enhance: false,
350
351 // Visibility toggles
352 wand_visible: false,
353 command_visible: false,
354 interactive_visible: false,
355 tool_visible: false,
356
357 // Stability AI settings
358 stability_style_preset: 'anime',
359
360 // BFL API settings
361 bfl_upsampling: false,
362
363 // Google settings
364 google_api: 'makersuite',
365 google_enhance: true,
366 google_duration: 6,
367
368 // Settings presets & auto-fallback ({ name, preset } entries, tried in order)
369 settings_preset_chain: [],
370 settings_fallback_enabled: false,
371
372 // Reference image library ({ tag, description, path } entries)
373 ref_images_enabled: false,
374 ref_images: [],
375
376 // RunPod lazy-pod proxy base URL ('' = feature off)
377 runpod_lazy_url: '',
378
379 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
380 prompt_generation_profile: '',
381
382 // User-defined custom wand dropdown entries ({ id, title, prompt })
383 custom_entries: [],
384};
385
386/**
387 * Keys that are NOT part of a settings preset snapshot.
388 * A preset captures backend/connection params (source, model, sampler, dimensions, etc.)
389 * but not the prompt library, styles, custom entries, or UI prefs.
390 * @type {string[]}
391 */
392const PRESET_EXCLUDE_KEYS = [
393 'settings_preset_chain',
394 'settings_fallback_enabled',
395 // The reference image library is global, not a per-backend setting.
396 'ref_images_enabled',
397 'ref_images',
398 'runpod_lazy_url',
399 // The image-prompt LLM profile is independent of the image backend, so it must
400 // never be captured/swapped by image-generation presets or the fallback retry.
401 'prompt_generation_profile',
402 'prompts',
403 'character_prompts',
404 'character_negative_prompts',
405 'styles',
406 'custom_entries',
407 'interactive_visible',
408 'wand_visible',
409 'command_visible',
410 'tool_visible',
411 'expand',
412];
413
414/**
415 * Creates a deep clone snapshot of the settings keys that belong to a preset.
416 * @returns {object} Snapshot of the included settings keys.
417 */
418function snapshotSdSettings() {
419 const snapshot = {};
420 for (const key of Object.keys(extension_settings.sd)) {
421 if (PRESET_EXCLUDE_KEYS.includes(key)) {
422 continue;
423 }
424 snapshot[key] = structuredClone(extension_settings.sd[key]);
425 }
426 return snapshot;
427}
428
429/**
430 * Applies a settings snapshot to the live settings object (in-memory only; does NOT touch the DOM).
431 * @param {object} snapshot Snapshot previously created by snapshotSdSettings().
432 */
433function applySdSettingsSnapshot(snapshot) {
434 if (!snapshot || typeof snapshot !== 'object') {
435 return;
436 }
437 for (const key of Object.keys(snapshot)) {
438 // Skip excluded keys so older presets that were saved before a key was
439 // excluded (e.g. prompt_generation_profile) can't clobber it on load.
440 if (PRESET_EXCLUDE_KEYS.includes(key)) {
441 continue;
442 }
443 extension_settings.sd[key] = structuredClone(snapshot[key]);
444 }
445}
446
447/**
448 * Checks whether a preset is configured (a non-null object with at least one key).
449 * @param {object} preset Preset to check.
450 * @returns {boolean} True if the preset is configured.
451 */
452function isPresetConfigured(preset) {
453 return !!preset && typeof preset === 'object' && Object.keys(preset).length > 0;
454}
455
456/**
457 * Returns the fallback chain entries that hold a usable preset snapshot, in order.
458 * @returns {{name: string, preset: object}[]} Ordered list of configured chain entries.
459 */
460function getConfiguredPresetChain() {
461 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
462 return chain.filter(entry => entry && isPresetConfigured(entry.preset));
463}
464
465/**
466 * How long to wait for a locally-hosted backend's status endpoint before treating
467 * the server as down and moving on to the next entry in the fallback chain.
468 */
469const SOURCE_PROBE_TIMEOUT_MS = 1500;
470
471/**
472 * Quickly checks whether the currently configured source is up. Only locally-hosted
473 * backends with a status endpoint are probed (ComfyUI, A1111, SD.Next, DrawThings,
474 * stable-diffusion.cpp); sources without a probe are assumed reachable.
475 * @returns {Promise<boolean>} False when the backend has a probe and it failed.
476 */
477async function isCurrentSourceReachable() {
478 /**
479 * @param {string} endpoint ST server ping route for the backend.
480 * @param {object} body Request body identifying the backend server.
481 * @returns {Promise<boolean>} Whether the ping succeeded within the timeout.
482 */
483 const probe = async (endpoint, body) => {
484 try {
485 const result = await fetch(endpoint, {
486 method: 'POST',
487 headers: getRequestHeaders(),
488 signal: AbortSignal.timeout(SOURCE_PROBE_TIMEOUT_MS),
489 body: JSON.stringify(body),
490 });
491 return result.ok;
492 } catch {
493 return false;
494 }
495 };
496
497 switch (extension_settings.sd.source) {
498 case sources.comfy:
499 return extension_settings.sd.comfy_type === comfyTypes.standard
500 ? probe('/api/sd/comfy/ping', { url: extension_settings.sd.comfy_url })
501 : true;
502 case sources.auto:
503 case sources.vlad:
504 case sources.drawthings:
505 return probe(extension_settings.sd.source === sources.drawthings ? '/api/sd/drawthings/ping' : '/api/sd/ping', getSdRequestBody());
506 case sources.sdcpp:
507 return probe('/api/sd/sdcpp/ping', { url: extension_settings.sd.sdcpp_url });
508 default:
509 return true;
510 }
511}
512
513/**
514 * Refreshes all settings UI controls to reflect the current extension_settings.sd values.
515 * Used after loading a settings preset.
516 * @returns {Promise<void>}
517 */
518async function refreshSettingsUi() {
519 // loadSettings() appends to #sd_style without clearing it, so empty it first.
520 $('#sd_style').empty();
521 await loadSettings();
522}
523
524const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
525const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase());
526
527/**
528 * Generate interceptor for interactive mode triggers.
529 * @param {any[]} chat Chat messages
530 * @param {number} _ Context size (unused)
531 * @param {function(boolean): void} abort Abort generation function
532 * @param {string} type Type of the generation
533 */
534function processTriggers(chat, _, abort, type) {
535 if (type === 'quiet') {
536 return;
537 }
538
539 if (extension_settings.sd.function_tool && ToolManager.isToolCallingSupported()) {
540 return;
541 }
542
543 if (!extension_settings.sd.interactive_mode) {
544 return;
545 }
546
547 const lastMessage = chat[chat.length - 1];
548
549 if (!lastMessage) {
550 return;
551 }
552
553 const message = lastMessage.mes;
554 const isUser = lastMessage.is_user;
555
556 if (!message || !isUser) {
557 return;
558 }
559
560 const messageLower = message.toLowerCase();
561
562 try {
563 const activationRegex = new RegExp(messageTrigger.activationRegex, 'i');
564 const activationMatch = messageLower.match(activationRegex);
565
566 if (!activationMatch) {
567 return;
568 }
569
570 let subject = activationMatch[3].trim();
571
572 if (!subject) {
573 return;
574 }
575
576 console.log(`SD: Triggered by "${message}", detected subject: "${subject}"`);
577
578 outer: for (const [specialMode, triggers] of Object.entries(messageTrigger.specialCases)) {
579 for (const trigger of triggers) {
580 if (subject === trigger) {
581 subject = triggerWords[specialMode][0];
582 console.log(`SD: Detected special case "${trigger}", switching to mode ${specialMode}`);
583 break outer;
584 }
585 }
586 }
587
588 abort(true);
589 setTimeout(() => generatePicture(initiators.interactive, {}, subject, message), 1);
590 } catch {
591 console.log('SD: Failed to process triggers.');
592 }
593}
594
595globalThis.SD_ProcessTriggers = processTriggers;
596
597function getSdRequestBody() {
598 switch (extension_settings.sd.source) {
599 case sources.vlad:
600 return { url: extension_settings.sd.vlad_url, auth: extension_settings.sd.vlad_auth };
601 case sources.auto:
602 return { url: extension_settings.sd.auto_url, auth: extension_settings.sd.auto_auth };
603 case sources.drawthings:
604 return { url: extension_settings.sd.drawthings_url, auth: extension_settings.sd.drawthings_auth };
605 default:
606 throw new Error('Invalid SD source.');
607 }
608}
609
610function toggleSourceControls() {
611 $('.sd_settings [data-sd-source]').each(function () {
612 const source = $(this).data('sd-source').split(',');
613 $(this).toggle(source.includes(extension_settings.sd.source));
614 });
615 $('.sd_settings [data-sd-comfy-type]').each(function () {
616 const source = $(this).data('sd-comfy-type').split(',');
617 $(this).toggle(source.includes(extension_settings.sd.comfy_type));
618 });
619}
620
621let promptGenerationProfileDropdownInitialized = false;
622// Warn at most once per session if a prompt-gen profile is selected but cannot be
623// applied because there is no active connection profile to restore afterward.
624let promptProfileWarnedNoBaseProfile = false;
625
626/**
627 * Populates the dedicated prompt-generation connection profile dropdown.
628 * Only initializes once to avoid attaching duplicate Connection Manager event listeners
629 * when loadSettings() is called again (e.g. after loading a settings preset).
630 */
631function initPromptGenerationProfileDropdown() {
632 if (promptGenerationProfileDropdownInitialized) {
633 return;
634 }
635
636 try {
637 ConnectionManagerRequestService.handleDropdown(
638 '#sd_prompt_generation_profile',
639 extension_settings.sd.prompt_generation_profile,
640 (profile) => {
641 extension_settings.sd.prompt_generation_profile = profile?.id ?? '';
642 saveSettingsDebounced();
643 },
644 );
645 promptGenerationProfileDropdownInitialized = true;
646 } catch (error) {
647 // Connection Manager may be unavailable/disabled; leave the dropdown empty in that case.
648 console.warn('SD: could not populate prompt-generation profile dropdown', error);
649 }
650}
651
652async function loadSettings() {
653 // Initialize settings
654 if (Object.keys(extension_settings.sd).length === 0) {
655 Object.assign(extension_settings.sd, defaultSettings);
656 }
657
658 // Insert missing settings
659 for (const [key, value] of Object.entries(defaultSettings)) {
660 if (extension_settings.sd[key] === undefined) {
661 extension_settings.sd[key] = value;
662 }
663 }
664
665 if (extension_settings.sd.prompts === undefined) {
666 extension_settings.sd.prompts = promptTemplates;
667 }
668
669 // Insert missing templates
670 for (const [key, value] of Object.entries(promptTemplates)) {
671 if (extension_settings.sd.prompts[key] === undefined) {
672 extension_settings.sd.prompts[key] = value;
673 }
674 }
675
676 if (extension_settings.sd.character_prompts === undefined) {
677 extension_settings.sd.character_prompts = {};
678 }
679
680 if (extension_settings.sd.character_negative_prompts === undefined) {
681 extension_settings.sd.character_negative_prompts = {};
682 }
683
684 if (!Array.isArray(extension_settings.sd.styles)) {
685 extension_settings.sd.styles = defaultStyles;
686 }
687
688 // Settings presets & auto-fallback
689 if (!Array.isArray(extension_settings.sd.settings_preset_chain)) {
690 // Migrate the old two-slot primary/secondary presets into a chain.
691 const chain = [];
692 if (isPresetConfigured(extension_settings.sd.settings_preset_primary)) {
693 chain.push({ name: 'Primary', preset: extension_settings.sd.settings_preset_primary });
694 }
695 if (isPresetConfigured(extension_settings.sd.settings_preset_secondary)) {
696 chain.push({ name: 'Secondary', preset: extension_settings.sd.settings_preset_secondary });
697 }
698 extension_settings.sd.settings_preset_chain = chain;
699 delete extension_settings.sd.settings_preset_primary;
700 delete extension_settings.sd.settings_preset_secondary;
701 }
702
703 if (extension_settings.sd.settings_fallback_enabled === undefined) {
704 extension_settings.sd.settings_fallback_enabled = false;
705 }
706
707 // Reference image library
708 if (extension_settings.sd.ref_images_enabled === undefined) {
709 extension_settings.sd.ref_images_enabled = false;
710 }
711
712 if (!Array.isArray(extension_settings.sd.ref_images)) {
713 extension_settings.sd.ref_images = [];
714 }
715
716 if (!Array.isArray(extension_settings.sd.custom_entries)) {
717 extension_settings.sd.custom_entries = [];
718 }
719
720 // Preserve an original seed if exists
721 if (extension_settings.sd.original_seed >= 0) {
722 extension_settings.sd.seed = extension_settings.sd.original_seed;
723 delete extension_settings.sd.original_seed;
724 }
725
726 $('#sd_source').val(extension_settings.sd.source);
727 $('#sd_scale').val(extension_settings.sd.scale).trigger('input');
728 $('#sd_steps').val(extension_settings.sd.steps).trigger('input');
729 $('#sd_prompt_prefix').val(extension_settings.sd.prompt_prefix).trigger('input');
730 $('#sd_negative_prompt').val(extension_settings.sd.negative_prompt).trigger('input');
731 $('#sd_width').val(extension_settings.sd.width).trigger('input');
732 $('#sd_height').val(extension_settings.sd.height).trigger('input');
733 $('#sd_hr_scale').val(extension_settings.sd.hr_scale).trigger('input');
734 $('#sd_denoising_strength').val(extension_settings.sd.denoising_strength).trigger('input');
735 $('#sd_hr_second_pass_steps').val(extension_settings.sd.hr_second_pass_steps).trigger('input');
736 $('#sd_novel_anlas_guard').prop('checked', extension_settings.sd.novel_anlas_guard);
737 $('#sd_novel_sm').prop('checked', extension_settings.sd.novel_sm);
738 $('#sd_novel_sm_dyn').prop('checked', extension_settings.sd.novel_sm_dyn);
739 $('#sd_novel_sm_dyn').prop('disabled', !extension_settings.sd.novel_sm);
740 $('#sd_novel_decrisper').prop('checked', extension_settings.sd.novel_decrisper);
741 $('#sd_novel_variety_boost').prop('checked', extension_settings.sd.novel_variety_boost);
742 $('#sd_pollinations_enhance').prop('checked', extension_settings.sd.pollinations_enhance);
743 $('#sd_horde').prop('checked', extension_settings.sd.horde);
744 $('#sd_horde_nsfw').prop('checked', extension_settings.sd.horde_nsfw);
745 $('#sd_horde_karras').prop('checked', extension_settings.sd.horde_karras);
746 $('#sd_horde_sanitize').prop('checked', extension_settings.sd.horde_sanitize);
747 $('#sd_restore_faces').prop('checked', extension_settings.sd.restore_faces);
748 $('#sd_enable_hr').prop('checked', extension_settings.sd.enable_hr);
749 $('#sd_adetailer_face').prop('checked', extension_settings.sd.adetailer_face);
750 $('#sd_refine_mode').prop('checked', extension_settings.sd.refine_mode);
751 $('#sd_multimodal_captioning').prop('checked', extension_settings.sd.multimodal_captioning);
752 $('#sd_auto_url').val(extension_settings.sd.auto_url);
753 $('#sd_auto_auth').val(extension_settings.sd.auto_auth);
754 $('#sd_sdcpp_url').val(extension_settings.sd.sdcpp_url);
755 $('#sd_vlad_url').val(extension_settings.sd.vlad_url);
756 $('#sd_vlad_auth').val(extension_settings.sd.vlad_auth);
757 $('#sd_drawthings_url').val(extension_settings.sd.drawthings_url);
758 $('#sd_drawthings_auth').val(extension_settings.sd.drawthings_auth);
759 $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode);
760 $('#sd_openai_style').val(extension_settings.sd.openai_style);
761 $('#sd_openai_quality').val(extension_settings.sd.openai_quality);
762 $('#sd_openai_quality_gpt').val(extension_settings.sd.openai_quality_gpt);
763 $('#sd_openai_duration').val(extension_settings.sd.openai_duration);
764 $('#sd_comfy_type').val(extension_settings.sd.comfy_type);
765 $('#sd_comfy_url').val(extension_settings.sd.comfy_url);
766 $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt);
767 $('#sd_comfy_runpod_url').val(extension_settings.sd.comfy_runpod_url);
768 $('#sd_snap').prop('checked', extension_settings.sd.snap);
769 $('#sd_minimal_prompt_processing').prop('checked', extension_settings.sd.minimal_prompt_processing);
770 $('#sd_clip_skip').val(extension_settings.sd.clip_skip);
771 $('#sd_clip_skip_value').val(extension_settings.sd.clip_skip);
772 $('#sd_seed').val(extension_settings.sd.seed);
773 $('#sd_free_extend').prop('checked', extension_settings.sd.free_extend);
774 $('#sd_wand_visible').prop('checked', extension_settings.sd.wand_visible);
775 $('#sd_command_visible').prop('checked', extension_settings.sd.command_visible);
776 $('#sd_interactive_visible').prop('checked', extension_settings.sd.interactive_visible);
777 $('#sd_tool_visible').prop('checked', extension_settings.sd.tool_visible);
778 $('#sd_stability_style_preset').val(extension_settings.sd.stability_style_preset);
779 $('#sd_huggingface_model_id').val(extension_settings.sd.huggingface_model_id);
780 $('#sd_function_tool').prop('checked', extension_settings.sd.function_tool);
781 $('#sd_bfl_upsampling').prop('checked', extension_settings.sd.bfl_upsampling);
782 $('#sd_google_api').val(extension_settings.sd.google_api);
783 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
784 $('#sd_google_duration').val(extension_settings.sd.google_duration);
785 $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);
786 $('#sd_ref_images_enabled').prop('checked', extension_settings.sd.ref_images_enabled);
787 $('#sd_runpod_lazy_url').val(extension_settings.sd.runpod_lazy_url ?? '');
788 renderPresetChain();
789 renderRefImages();
790 setupRunpodLoops();
791
792 for (const style of extension_settings.sd.styles) {
793 const option = document.createElement('option');
794 option.value = style.name;
795 option.text = style.name;
796 option.selected = style.name === extension_settings.sd.style;
797 $('#sd_style').append(option);
798 }
799
800 const resolutionId = getClosestKnownResolution();
801 $('#sd_resolution').val(resolutionId);
802
803 initPromptGenerationProfileDropdown();
804
805 toggleSourceControls();
806 addPromptTemplates();
807 renderCustomEntriesList();
808 renderCustomDropdownEntries();
809 registerFunctionTool();
810
811 await loadSettingOptions();
812}
813
814/**
815 * Find a closest resolution option match for the current width and height.
816 */
817function getClosestKnownResolution() {
818 let resolutionId = null;
819 let minTotalDiff = Infinity;
820
821 const targetAspect = extension_settings.sd.width / extension_settings.sd.height;
822 const targetResolution = extension_settings.sd.width * extension_settings.sd.height;
823
824 const diffs = Object.entries(resolutionOptions).map(([id, resolution]) => {
825 const aspectDiff = Math.abs((resolution.width / resolution.height) - targetAspect) / targetAspect;
826 const resolutionDiff = Math.abs(resolution.width * resolution.height - targetResolution) / targetResolution;
827 return { id, totalDiff: aspectDiff + resolutionDiff };
828 });
829
830 for (const { id, totalDiff } of diffs) {
831 if (totalDiff < minTotalDiff) {
832 minTotalDiff = totalDiff;
833 resolutionId = id;
834 }
835 }
836
837 return resolutionId;
838}
839
840async function loadSettingOptions() {
841 return Promise.all([
842 loadSamplers(),
843 loadModels(),
844 loadSchedulers(),
845 loadVaes(),
846 loadComfyWorkflows(),
847 ]);
848}
849
850function addPromptTemplates() {
851 $('#sd_prompt_templates').empty();
852
853 for (const [name, prompt] of Object.entries(extension_settings.sd.prompts).sort((a, b) => Number(a[0]) - Number(b[0]))) {
854 const label = $('<label></label>')
855 .text(modeLabels[name])
856 .attr('for', `sd_prompt_${name}`)
857 .attr('data-i18n', `sd_prompt_${name}`);
858 const textarea = $('<textarea></textarea>')
859 .addClass('textarea_compact text_pole')
860 .attr('id', `sd_prompt_${name}`)
861 .attr('rows', 3)
862 .val(prompt).on('input', () => {
863 extension_settings.sd.prompts[name] = textarea.val();
864 saveSettingsDebounced();
865 });
866 const button = $('<button></button>')
867 .addClass('menu_button fa-solid fa-undo')
868 .attr('title', 'Restore default')
869 .attr('data-i18n', 'Restore default')
870 .on('click', () => {
871 textarea.val(promptTemplates[name]);
872 extension_settings.sd.prompts[name] = promptTemplates[name];
873 if (String(name) === String(generationMode.TOOL)) {
874 registerFunctionTool();
875 }
876 saveSettingsDebounced();
877 });
878 const container = $('<div></div>')
879 .addClass('title_restorable')
880 .append(label)
881 .append(button);
882 $('#sd_prompt_templates').append(container);
883 $('#sd_prompt_templates').append(textarea);
884 }
885}
886
887function onInteractiveModeInput() {
888 extension_settings.sd.interactive_mode = !!$(this).prop('checked');
889 saveSettingsDebounced();
890}
891
892function onMultimodalCaptioningInput() {
893 extension_settings.sd.multimodal_captioning = !!$(this).prop('checked');
894 saveSettingsDebounced();
895}
896
897function onSnapInput() {
898 extension_settings.sd.snap = !!$(this).prop('checked');
899 saveSettingsDebounced();
900}
901
902function onMinimalPromptProcessing() {
903 extension_settings.sd.minimal_prompt_processing = !!$(this).prop('checked');
904 saveSettingsDebounced();
905}
906
907function onStyleSelect() {
908 const selectedStyle = String($('#sd_style').find(':selected').val());
909 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
910
911 if (!styleObject) {
912 console.warn(`Could not find style object for ${selectedStyle}`);
913 return;
914 }
915
916 $('#sd_prompt_prefix').val(styleObject.prefix).trigger('input');
917 $('#sd_negative_prompt').val(styleObject.negative).trigger('input');
918 extension_settings.sd.style = selectedStyle;
919 saveSettingsDebounced();
920}
921
922async function onDeleteStyleClick() {
923 const selectedStyle = String($('#sd_style').find(':selected').val());
924 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
925
926 if (!styleObject) {
927 return;
928 }
929
930 const confirmed = await callGenericPopup(t`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
931
932 if (!confirmed) {
933 return;
934 }
935
936 const index = extension_settings.sd.styles.indexOf(styleObject);
937
938 if (index === -1) {
939 return;
940 }
941
942 extension_settings.sd.styles.splice(index, 1);
943 $('#sd_style').find(`option[value="${selectedStyle}"]`).remove();
944
945 if (extension_settings.sd.styles.length > 0) {
946 extension_settings.sd.style = extension_settings.sd.styles[0].name;
947 $('#sd_style').val(extension_settings.sd.style).trigger('change');
948 } else {
949 extension_settings.sd.style = '';
950 $('#sd_prompt_prefix').val('').trigger('input');
951 $('#sd_negative_prompt').val('').trigger('input');
952 $('#sd_style').val('');
953 }
954
955 saveSettingsDebounced();
956}
957
958async function onSaveStyleClick() {
959 const selectedStyle = extension_settings.sd.style || '';
960 const userInput = await callGenericPopup(t`Enter style name:`, POPUP_TYPE.INPUT, selectedStyle);
961
962 if (!userInput) {
963 return;
964 }
965
966 const name = String(userInput).trim();
967 const prefix = String($('#sd_prompt_prefix').val());
968 const negative = String($('#sd_negative_prompt').val());
969
970 const existingStyle = extension_settings.sd.styles.find(x => x.name === name);
971
972 if (existingStyle) {
973 existingStyle.prefix = prefix;
974 existingStyle.negative = negative;
975 $('#sd_style').val(name);
976 saveSettingsDebounced();
977 return;
978 }
979
980 const styleObject = {
981 name: name,
982 prefix: prefix,
983 negative: negative,
984 };
985
986 extension_settings.sd.styles.push(styleObject);
987 const option = document.createElement('option');
988 option.value = styleObject.name;
989 option.text = styleObject.name;
990 option.selected = true;
991 $('#sd_style').append(option);
992 $('#sd_style').val(styleObject.name);
993 saveSettingsDebounced();
994}
995
996async function onRenameStyleClick() {
997 const selectedStyle = extension_settings.sd.style;
998 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
999
1000 if (!styleObject) {
1001 return;
1002 }
1003
1004 const newName = await callGenericPopup(t`Enter new style name:`, POPUP_TYPE.INPUT, selectedStyle);
1005
1006 if (!newName) {
1007 return;
1008 }
1009
1010 const name = String(newName).trim();
1011
1012 if (name === selectedStyle) {
1013 return;
1014 }
1015
1016 const existingStyle = extension_settings.sd.styles.find(x => x.name === name);
1017
1018 if (existingStyle) {
1019 toastr.error(t`A style with that name already exists`);
1020 return;
1021 }
1022
1023 styleObject.name = name;
1024 extension_settings.sd.style = name;
1025
1026 $('#sd_style').empty();
1027 for (const style of extension_settings.sd.styles) {
1028 const option = document.createElement('option');
1029 option.value = style.name;
1030 option.text = style.name;
1031 option.selected = style.name === extension_settings.sd.style;
1032 $('#sd_style').append(option);
1033 }
1034
1035 saveSettingsDebounced();
1036}
1037
1038/**
1039 * Rebuilds the provider fallback chain list in the settings UI.
1040 */
1041function renderPresetChain() {
1042 const container = $('#sd_preset_chain_list');
1043 if (!container.length) {
1044 return;
1045 }
1046
1047 container.empty();
1048
1049 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
1050
1051 if (chain.length === 0) {
1052 const empty = $('<small></small>')
1053 .attr('data-i18n', 'No presets in the chain yet.')
1054 .text('No presets in the chain yet.');
1055 container.append(empty);
1056 return;
1057 }
1058
1059 chain.forEach((entry, index) => {
1060 const orderEl = $('<div></div>').addClass('sd_preset_chain_order').text(`${index + 1}.`);
1061 const sourceHint = String(entry.preset?.source ?? '');
1062 const nameInput = $('<input>')
1063 .addClass('text_pole flex1')
1064 .attr('type', 'text')
1065 .attr('title', sourceHint ? `Source: ${sourceHint}` : '')
1066 .val(entry.name || '')
1067 .on('change', function () {
1068 entry.name = String($(this).val() ?? '').trim() || `Preset ${index + 1}`;
1069 saveSettingsDebounced();
1070 });
1071
1072 const makeButton = (icon, title, handler) => $('<div></div>')
1073 .addClass(`menu_button menu_button_icon fa-solid ${icon}`)
1074 .attr('title', title)
1075 .attr('data-i18n', `[title]${title}`)
1076 .on('click', handler);
1077
1078 const upButton = makeButton('fa-chevron-up', 'Move up', () => {
1079 if (index === 0) return;
1080 [chain[index - 1], chain[index]] = [chain[index], chain[index - 1]];
1081 saveSettingsDebounced();
1082 renderPresetChain();
1083 });
1084 const downButton = makeButton('fa-chevron-down', 'Move down', () => {
1085 if (index === chain.length - 1) return;
1086 [chain[index + 1], chain[index]] = [chain[index], chain[index + 1]];
1087 saveSettingsDebounced();
1088 renderPresetChain();
1089 });
1090 const loadButton = makeButton('fa-file-import', 'Load this preset into the current settings', async () => {
1091 applySdSettingsSnapshot(entry.preset);
1092 saveSettingsDebounced();
1093 await refreshSettingsUi();
1094 toastr.success(t`Settings preset loaded.`, t`Image Generation`);
1095 });
1096 const saveButton = makeButton('fa-floppy-disk', 'Overwrite this preset with the current settings', () => {
1097 entry.preset = snapshotSdSettings();
1098 saveSettingsDebounced();
1099 renderPresetChain();
1100 toastr.success(t`Settings preset updated.`, t`Image Generation`);
1101 });
1102 const deleteButton = makeButton('fa-trash-can', 'Remove from the chain', () => {
1103 chain.splice(index, 1);
1104 saveSettingsDebounced();
1105 renderPresetChain();
1106 });
1107
1108 const row = $('<div></div>')
1109 .addClass('flex-container alignItemsCenter marginTopBot5')
1110 .append(orderEl)
1111 .append(nameInput)
1112 .append(upButton)
1113 .append(downButton)
1114 .append(loadButton)
1115 .append(saveButton)
1116 .append(deleteButton);
1117
1118 container.append(row);
1119 });
1120}
1121
1122function onPresetChainAddClick() {
1123 const nameInput = $('#sd_preset_chain_name');
1124 const name = String(nameInput.val() ?? '').trim();
1125 const chain = extension_settings.sd.settings_preset_chain;
1126 chain.push({ name: name || `Preset ${chain.length + 1}`, preset: snapshotSdSettings() });
1127 nameInput.val('');
1128 saveSettingsDebounced();
1129 renderPresetChain();
1130 toastr.success(t`Current settings added to the fallback chain.`, t`Image Generation`);
1131}
1132
1133function onFallbackEnabledChange() {
1134 extension_settings.sd.settings_fallback_enabled = !!$(this).prop('checked');
1135 saveSettingsDebounced();
1136}
1137
1138// #region Reference image library
1139
1140/**
1141 * Matches the reference image placeholder in a raw ComfyUI workflow ("%reference_image%" or "%reference-image%").
1142 */
1143const REFERENCE_IMAGE_PLACEHOLDER = /"%reference[-_]image%"/i;
1144
1145/**
1146 * Reference image chosen for the in-flight generation. Set at prompt-generation time
1147 * (or lazily by the workflow builder) and read when the ComfyUI workflow is assembled.
1148 * Deliberately kept across swipe regenerations so a swipe reuses the same reference.
1149 * @type {{tag: string, description: string, path: string} | null}
1150 */
1151let pendingReferenceImage = null;
1152
1153/**
1154 * Returns library entries that have an uploaded image.
1155 * @returns {{tag: string, description: string, path: string}[]} Valid reference images.
1156 */
1157function getValidRefImages() {
1158 const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : [];
1159 return images.filter(image => image && typeof image.path === 'string' && image.path.length > 0);
1160}
1161
1162/**
1163 * Rebuilds the reference image library list in the settings UI.
1164 */
1165function renderRefImages() {
1166 const container = $('#sd_ref_images_list');
1167 if (!container.length) {
1168 return;
1169 }
1170
1171 container.empty();
1172
1173 const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : [];
1174
1175 if (images.length === 0) {
1176 const empty = $('<small></small>')
1177 .attr('data-i18n', 'No reference images yet.')
1178 .text('No reference images yet.');
1179 container.append(empty);
1180 return;
1181 }
1182
1183 images.forEach((image, index) => {
1184 const thumb = $('<img>')
1185 .addClass('sd_ref_image_thumb')
1186 .attr('src', image.path)
1187 .attr('alt', image.tag || '');
1188 const tagInput = $('<input>')
1189 .addClass('text_pole')
1190 .attr('type', 'text')
1191 .attr('placeholder', 'Tag')
1192 .attr('data-i18n', '[placeholder]Tag')
1193 .val(image.tag || '')
1194 .on('change', function () {
1195 image.tag = String($(this).val() ?? '').trim();
1196 saveSettingsDebounced();
1197 });
1198 const descriptionInput = $('<input>')
1199 .addClass('text_pole flex1')
1200 .attr('type', 'text')
1201 .attr('placeholder', 'Description (used to pick the best fit)')
1202 .attr('data-i18n', '[placeholder]Description (used to pick the best fit)')
1203 .val(image.description || '')
1204 .on('change', function () {
1205 image.description = String($(this).val() ?? '').trim();
1206 saveSettingsDebounced();
1207 });
1208 const deleteButton = $('<div></div>')
1209 .addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1210 .attr('title', 'Remove reference image')
1211 .attr('data-i18n', '[title]Remove reference image')
1212 .on('click', () => {
1213 images.splice(index, 1);
1214 saveSettingsDebounced();
1215 renderRefImages();
1216 });
1217
1218 const row = $('<div></div>')
1219 .addClass('flex-container alignItemsCenter marginTopBot5')
1220 .append(thumb)
1221 .append(tagInput)
1222 .append(descriptionInput)
1223 .append(deleteButton);
1224
1225 container.append(row);
1226 });
1227}
1228
1229function onRefImagesEnabledChange() {
1230 extension_settings.sd.ref_images_enabled = !!$(this).prop('checked');
1231 saveSettingsDebounced();
1232}
1233
1234async function onRefImagesFileChange() {
1235 const files = Array.from(this.files ?? []);
1236 this.value = '';
1237
1238 for (const file of files) {
1239 try {
1240 const dataUrl = await getBase64Async(file);
1241 const base64 = String(dataUrl).split(',')[1];
1242 const extension = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
1243 const baseName = file.name.replace(/\.[^/.]+$/, '');
1244 const path = await saveBase64AsFile(base64, 'reference-images', baseName, extension);
1245 extension_settings.sd.ref_images.push({ tag: baseName, description: '', path });
1246 } catch (error) {
1247 console.error('SD: failed to add reference image', error);
1248 toastr.error(String(error), t`Image Generation`);
1249 }
1250 }
1251
1252 saveSettingsDebounced();
1253 renderRefImages();
1254}
1255
1256/**
1257 * Collects the ComfyUI workflow file names that could be used by this generation:
1258 * the live settings' workflow plus, when the fallback chain is enabled, the workflow
1259 * of every comfy preset in the chain.
1260 * @returns {string[]} Unique workflow file names.
1261 */
1262function collectCandidateComfyWorkflows() {
1263 const names = new Set();
1264 /** @param {object} config A settings-shaped object (live settings or a preset snapshot). */
1265 const consider = (config) => {
1266 if (config && config.source === sources.comfy && config.comfy_type === comfyTypes.standard && config.comfy_workflow) {
1267 names.add(config.comfy_workflow);
1268 }
1269 };
1270 consider(extension_settings.sd);
1271 if (extension_settings.sd.settings_fallback_enabled) {
1272 for (const entry of getConfiguredPresetChain()) {
1273 consider(entry.preset);
1274 }
1275 }
1276 return [...names];
1277}
1278
1279/**
1280 * Checks whether any workflow this generation could run contains the reference image placeholder.
1281 * @returns {Promise<boolean>} True when a candidate workflow uses the placeholder.
1282 */
1283async function anyCandidateWorkflowUsesReferenceImage() {
1284 for (const fileName of collectCandidateComfyWorkflows()) {
1285 try {
1286 const result = await fetch('/api/sd/comfy/workflow', {
1287 method: 'POST',
1288 headers: getRequestHeaders(),
1289 body: JSON.stringify({ file_name: fileName }),
1290 });
1291 if (!result.ok) {
1292 continue;
1293 }
1294 const workflow = await result.json();
1295 if (REFERENCE_IMAGE_PLACEHOLDER.test(String(workflow))) {
1296 return true;
1297 }
1298 } catch (error) {
1299 console.warn('SD: could not inspect workflow for reference image placeholder', fileName, error);
1300 }
1301 }
1302 return false;
1303}
1304
1305/**
1306 * Returns the reference images to choose from for this generation, or an empty array
1307 * when the feature is disabled, the library is empty, or no candidate workflow uses
1308 * the placeholder.
1309 * @returns {Promise<{tag: string, description: string, path: string}[]>} Selectable reference images.
1310 */
1311async function getEligibleReferenceImages() {
1312 if (!extension_settings.sd.ref_images_enabled) {
1313 return [];
1314 }
1315 const images = getValidRefImages();
1316 if (images.length === 0) {
1317 return [];
1318 }
1319 if (!(await anyCandidateWorkflowUsesReferenceImage())) {
1320 return [];
1321 }
1322 return images;
1323}
1324
1325/**
1326 * Builds the instruction appended to the image-prompt request that makes the LLM
1327 * also pick a reference image, as a machine-readable JSON line.
1328 * @param {{tag: string, description: string}[]} candidates Reference images to choose from.
1329 * @returns {string} Instruction text.
1330 */
1331function buildReferenceSelectionAddendum(candidates) {
1332 const list = candidates.map(x => `- "${x.tag}": ${x.description || 'no description'}`).join('\n');
1333 return [
1334 '',
1335 'After the image prompt, append one final line containing exactly this JSON and nothing else:',
1336 '{"reference_image": "<tag>"}',
1337 'where <tag> is the tag of the reference image whose description best fits the requested scene. Available reference images:',
1338 list,
1339 ].join('\n');
1340}
1341
1342/**
1343 * Finds the library entry whose tag matches the given text.
1344 * @param {string} text Tag text returned by the LLM.
1345 * @param {{tag: string}[]} candidates Reference images to match against.
1346 * @returns {object|null} The matching entry, or null.
1347 */
1348function matchReferenceTag(text, candidates) {
1349 const needle = String(text ?? '').trim().toLowerCase();
1350 if (!needle) {
1351 return null;
1352 }
1353 const tagged = candidates.filter(x => String(x.tag ?? '').trim().length > 0);
1354 return tagged.find(x => x.tag.trim().toLowerCase() === needle)
1355 ?? tagged.find(x => needle.includes(x.tag.trim().toLowerCase()))
1356 ?? null;
1357}
1358
1359/**
1360 * Extracts the {"reference_image": "..."} selection from a combined prompt+selection reply.
1361 * @param {string} reply Raw LLM reply.
1362 * @param {{tag: string}[]} candidates Reference images to match against.
1363 * @returns {{cleaned: string, selected: object|null}} Reply without the JSON line, and the matched entry.
1364 */
1365function extractReferenceSelection(reply, candidates) {
1366 const pattern = /\{\s*"?reference_image"?\s*:\s*"([^"]*)"\s*\}/gi;
1367 let match;
1368 let lastTag = null;
1369 while ((match = pattern.exec(reply)) !== null) {
1370 lastTag = match[1];
1371 }
1372 const cleaned = reply.replace(/\{\s*"?reference_image"?\s*:\s*"[^"]*"\s*\}/gi, ' ');
1373 return { cleaned, selected: lastTag ? matchReferenceTag(lastTag, candidates) : null };
1374}
1375
1376/**
1377 * Asks the image-prompt LLM to pick the best-fitting reference image for a scene
1378 * in a dedicated (second) request.
1379 * @param {string} prompt The final image prompt describing the scene.
1380 * @param {{tag: string, description: string}[]} candidates Reference images to choose from.
1381 * @returns {Promise<object|null>} The matched entry, or null.
1382 */
1383async function selectReferenceImageWithLlm(prompt, candidates) {
1384 const list = candidates.map(x => `- "${x.tag}": ${x.description || 'no description'}`).join('\n');
1385 const quietPrompt = [
1386 'Pause your roleplay. An image is being generated for the current scene from this prompt:',
1387 prompt,
1388 '',
1389 'Pick the reference image whose description best fits that scene:',
1390 list,
1391 '',
1392 'Reply with ONLY the tag of the chosen reference image and nothing else.',
1393 ].join('\n');
1394 const profileId = extension_settings.sd.prompt_generation_profile;
1395 const reply = profileId
1396 ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt }))
1397 : await generateQuietPrompt({ quietPrompt });
1398 return matchReferenceTag(String(reply ?? '').trim(), candidates);
1399}
1400
1401/**
1402 * Resolves which reference image the current ComfyUI generation should use.
1403 * Prefers the selection made together with the image prompt; falls back to a
1404 * dedicated LLM call, and finally to the first library image.
1405 * @param {string} prompt The image prompt describing the scene.
1406 * @returns {Promise<object|null>} The reference image to use, or null when the feature is off/empty.
1407 */
1408async function resolveReferenceImageForGeneration(prompt) {
1409 if (!extension_settings.sd.ref_images_enabled) {
1410 return null;
1411 }
1412 const images = getValidRefImages();
1413 if (images.length === 0) {
1414 return null;
1415 }
1416 if (pendingReferenceImage && images.some(x => x.path === pendingReferenceImage.path)) {
1417 return pendingReferenceImage;
1418 }
1419 if (images.length === 1) {
1420 pendingReferenceImage = images[0];
1421 return pendingReferenceImage;
1422 }
1423 try {
1424 pendingReferenceImage = await selectReferenceImageWithLlm(prompt, images) ?? images[0];
1425 } catch (error) {
1426 console.error('SD: reference image selection failed, using the first library image', error);
1427 pendingReferenceImage = images[0];
1428 }
1429 return pendingReferenceImage;
1430}
1431
1432/**
1433 * Loads a reference image and returns it as a raw base64 string (no data URL header).
1434 * @param {{path: string}} refImage Reference image entry.
1435 * @returns {Promise<string|null>} Base64 image data, or null on failure.
1436 */
1437async function fetchReferenceImageBase64(refImage) {
1438 try {
1439 const response = await fetch(refImage.path);
1440 if (!response.ok) {
1441 throw new Error(`HTTP ${response.status}`);
1442 }
1443 const blob = await response.blob();
1444 const dataUrl = await getBase64Async(blob);
1445 return String(dataUrl).split(',')[1] ?? null;
1446 } catch (error) {
1447 console.error('SD: could not load reference image', refImage.path, error);
1448 return null;
1449 }
1450}
1451
1452// #endregion
1453
1454// #region RunPod lazy pod
1455
1456/** Poll cadence for the pod status indicator (faster while it is starting). */
1457const RUNPOD_POLL_IDLE_MS = 30000;
1458const RUNPOD_POLL_BUSY_MS = 5000;
1459/** Keepalive cadence: signals "a SillyTavern tab is open" to the proxy. */
1460const RUNPOD_PING_MS = 60000;
1461
1462let runpodStatusTimer = null;
1463let runpodPingTimer = null;
1464let runpodLastPhase = 'red';
1465
1466function getRunpodLazyUrl() {
1467 return String(extension_settings.sd.runpod_lazy_url ?? '').trim().replace(/\/$/, '');
1468}
1469
1470/**
1471 * Updates the status dot + text from a /lazy/status response (or an error).
1472 * @param {object|null} status Parsed status JSON, or null when unreachable.
1473 */
1474function renderRunpodStatus(status) {
1475 const dot = document.getElementById('sd_runpod_dot');
1476 const text = document.getElementById('sd_runpod_status_text');
1477 if (!dot || !text) {
1478 return;
1479 }
1480 const phase = status?.state ?? 'red';
1481 runpodLastPhase = phase;
1482 const phaseClass = `sd_runpod_${['red', 'orange', 'green'].includes(phase) ? phase : 'red'}`;
1483 for (const el of [dot, document.getElementById('sd_runpod_bar_dot')].filter(Boolean)) {
1484 el.classList.remove('sd_runpod_red', 'sd_runpod_orange', 'sd_runpod_green');
1485 el.classList.add(phaseClass);
1486 }
1487 const openLink = document.getElementById('sd_runpod_open');
1488 if (openLink) {
1489 const showLink = phase === 'green' && status?.url;
1490 openLink.style.display = showLink ? '' : 'none';
1491 if (showLink) {
1492 openLink.href = status.url;
1493 }
1494 }
1495 if (!status) {
1496 text.textContent = 'proxy unreachable';
1497 } else if (phase === 'green') {
1498 const left = status.idle_seconds_left ? ` (idle stop in ${Math.round(status.idle_seconds_left / 60)}m)` : '';
1499 text.textContent = `ready on ${status.gpu ?? 'GPU'}${left}`;
1500 } else if (phase === 'orange') {
1501 text.textContent = `starting (models: ${status.models ?? '?'})…`;
1502 } else {
1503 text.textContent = 'off';
1504 }
1505 const barDot = document.getElementById('sd_runpod_bar_dot');
1506 if (barDot) {
1507 barDot.title = `RunPod pod: ${text.textContent} — click to ${phase === 'red' ? 'warm up' : 'shut down'}`;
1508 }
1509 return phase;
1510}
1511
1512/** Adds the clickable pod-status dot to the chat bar (next to other extension icons). */
1513function ensureRunpodBarDot() {
1514 if (document.getElementById('sd_runpod_bar_dot')) {
1515 return;
1516 }
1517 const anchor = document.getElementById('leftSendForm');
1518 if (!anchor) {
1519 return;
1520 }
1521 const dot = document.createElement('div');
1522 dot.id = 'sd_runpod_bar_dot';
1523 dot.classList.add('sd_runpod_bar_dot', 'sd_runpod_red', 'interactable');
1524 dot.title = 'RunPod pod';
1525 dot.tabIndex = 0;
1526 dot.addEventListener('click', () => runpodControl(runpodLastPhase === 'red' ? 'warmup' : 'shutdown'));
1527 anchor.appendChild(dot);
1528 dot.style.display = getRunpodLazyUrl() ? '' : 'none';
1529}
1530
1531async function pollRunpodStatus() {
1532 const url = getRunpodLazyUrl();
1533 if (!url) {
1534 return;
1535 }
1536 let phase = 'red';
1537 try {
1538 const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(5000) });
1539 phase = renderRunpodStatus(result.ok ? await result.json() : null);
1540 } catch {
1541 renderRunpodStatus(null);
1542 }
1543 clearTimeout(runpodStatusTimer);
1544 runpodStatusTimer = setTimeout(pollRunpodStatus, phase === 'orange' ? RUNPOD_POLL_BUSY_MS : RUNPOD_POLL_IDLE_MS);
1545}
1546
1547async function runpodControl(action) {
1548 const url = getRunpodLazyUrl();
1549 if (!url) {
1550 return;
1551 }
1552 try {
1553 const result = await fetch(`${url}/lazy/${action}`, { method: 'POST', signal: AbortSignal.timeout(10000) });
1554 renderRunpodStatus(result.ok ? await result.json() : null);
1555 if (action === 'warmup') {
1556 toastr.info(t`Pod warmup requested. Models will pre-download; the dot turns green when ready.`, t`Image Generation`);
1557 }
1558 } catch (error) {
1559 toastr.error(String(error), t`Image Generation`);
1560 }
1561 clearTimeout(runpodStatusTimer);
1562 runpodStatusTimer = setTimeout(pollRunpodStatus, RUNPOD_POLL_BUSY_MS);
1563}
1564
1565function runpodKeepalive() {
1566 const url = getRunpodLazyUrl();
1567 if (!url) {
1568 return;
1569 }
1570 // Only extends the pod's idle timer; the proxy never starts a pod for a ping.
1571 fetch(`${url}/lazy/ping`, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(() => { });
1572}
1573
1574/** (Re)starts the polling/keepalive loops based on the configured URL. */
1575function setupRunpodLoops() {
1576 clearTimeout(runpodStatusTimer);
1577 clearInterval(runpodPingTimer);
1578 ensureRunpodBarDot();
1579 const barDot = document.getElementById('sd_runpod_bar_dot');
1580 if (barDot) {
1581 barDot.style.display = getRunpodLazyUrl() ? '' : 'none';
1582 }
1583 if (!getRunpodLazyUrl()) {
1584 renderRunpodStatus(null);
1585 return;
1586 }
1587 pollRunpodStatus();
1588 runpodPingTimer = setInterval(runpodKeepalive, RUNPOD_PING_MS);
1589}
1590
1591function onRunpodLazyUrlInput() {
1592 extension_settings.sd.runpod_lazy_url = String($(this).val() ?? '').trim();
1593 saveSettingsDebounced();
1594 setupRunpodLoops();
1595}
1596
1597// #endregion
1598
1599/**
1600 * Rebuilds the custom wand entries list in the settings UI.
1601 */
1602function renderCustomEntriesList() {
1603 const container = $('#sd_custom_entries_list');
1604 if (!container.length) {
1605 return;
1606 }
1607
1608 container.empty();
1609
1610 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1611
1612 if (entries.length === 0) {
1613 const empty = $('<small></small>')
1614 .attr('data-i18n', 'No custom entries yet.')
1615 .text('No custom entries yet.');
1616 container.append(empty);
1617 return;
1618 }
1619
1620 for (const entry of entries) {
1621 const preview = String(entry.prompt || '').replace(/\s+/g, ' ').trim();
1622 const truncated = preview.length > 80 ? preview.slice(0, 80) + '…' : preview;
1623
1624 const titleEl = $('<div></div>').addClass('sd_custom_entry_title').text(entry.title);
1625 const previewEl = $('<small></small>').addClass('sd_custom_entry_preview').text(truncated);
1626 const textBlock = $('<div></div>').addClass('flex1 flexFlowColumn').append(titleEl).append(previewEl);
1627
1628 const editButton = $('<div></div>')
1629 .addClass('menu_button menu_button_icon fa-solid fa-pencil')
1630 .attr('data-entry-id', entry.id)
1631 .attr('data-action', 'edit')
1632 .attr('title', 'Edit')
1633 .attr('data-i18n', '[title]Edit');
1634 const deleteButton = $('<div></div>')
1635 .addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1636 .attr('data-entry-id', entry.id)
1637 .attr('data-action', 'delete')
1638 .attr('title', 'Delete')
1639 .attr('data-i18n', '[title]Delete');
1640
1641 const row = $('<div></div>')
1642 .addClass('flex-container alignItemsCenter marginTopBot5 sd_custom_entry_row')
1643 .append(textBlock)
1644 .append(editButton)
1645 .append(deleteButton);
1646
1647 container.append(row);
1648 }
1649}
1650
1651/**
1652 * Generates a unique id for a custom entry.
1653 * @returns {string} A unique identifier.
1654 */
1655function getUniqueCustomEntryId() {
1656 return crypto.randomUUID?.() ?? ('ce_' + Date.now() + '_' + Math.floor(Math.random() * 1e6));
1657}
1658
1659/**
1660 * Opens a popup to create/edit a custom entry and returns the entered values.
1661 * @param {string} title Initial title value.
1662 * @param {string} prompt Initial prompt value.
1663 * @returns {Promise<{title: string, prompt: string} | null>} Entered values, or null if cancelled.
1664 */
1665async function showCustomEntryPopup(title, prompt) {
1666 const form = $('<div></div>').addClass('flex-container flexFlowColumn');
1667 const titleLabel = $('<label></label>').attr('data-i18n', 'Title').text('Title');
1668 const titleInput = $('<input>')
1669 .addClass('text_pole')
1670 .attr('type', 'text')
1671 .attr('id', 'sd_custom_entry_title_input')
1672 .val(title || '');
1673 const promptLabel = $('<label></label>').attr('data-i18n', 'Prompt').text('Prompt');
1674 const promptInput = $('<textarea></textarea>')
1675 .addClass('text_pole textarea_compact')
1676 .attr('id', 'sd_custom_entry_prompt_input')
1677 .attr('rows', 5)
1678 .val(prompt || '');
1679
1680 form.append(titleLabel).append(titleInput).append(promptLabel).append(promptInput);
1681
1682 const popup = new Popup(form, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1683 const result = await popup.show();
1684
1685 if (!result) {
1686 return null;
1687 }
1688
1689 return {
1690 title: String(titleInput.val() ?? '').trim(),
1691 prompt: String(promptInput.val() ?? '').trim(),
1692 };
1693}
1694
1695async function onAddCustomEntryClick() {
1696 const values = await showCustomEntryPopup('', '');
1697
1698 if (!values) {
1699 return;
1700 }
1701
1702 if (!values.title || !values.prompt) {
1703 toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1704 return;
1705 }
1706
1707 if (!Array.isArray(extension_settings.sd.custom_entries)) {
1708 extension_settings.sd.custom_entries = [];
1709 }
1710
1711 extension_settings.sd.custom_entries.push({
1712 id: getUniqueCustomEntryId(),
1713 title: values.title,
1714 prompt: values.prompt,
1715 });
1716
1717 saveSettingsDebounced();
1718 renderCustomEntriesList();
1719 renderCustomDropdownEntries();
1720}
1721
1722async function onEditCustomEntryClick(id) {
1723 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1724 const entry = entries.find(e => e.id === id);
1725
1726 if (!entry) {
1727 return;
1728 }
1729
1730 const values = await showCustomEntryPopup(entry.title, entry.prompt);
1731
1732 if (!values) {
1733 return;
1734 }
1735
1736 if (!values.title || !values.prompt) {
1737 toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1738 return;
1739 }
1740
1741 entry.title = values.title;
1742 entry.prompt = values.prompt;
1743
1744 saveSettingsDebounced();
1745 renderCustomEntriesList();
1746 renderCustomDropdownEntries();
1747}
1748
1749async function onDeleteCustomEntryClick(id) {
1750 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1751 const index = entries.findIndex(e => e.id === id);
1752
1753 if (index === -1) {
1754 return;
1755 }
1756
1757 const confirmed = await callGenericPopup(t`Are you sure you want to delete the entry "${entries[index].title}"?`, POPUP_TYPE.CONFIRM, '', { okButton: t`Delete`, cancelButton: t`Cancel` });
1758
1759 if (!confirmed) {
1760 return;
1761 }
1762
1763 entries.splice(index, 1);
1764 saveSettingsDebounced();
1765 renderCustomEntriesList();
1766 renderCustomDropdownEntries();
1767}
1768
1769/**
1770 * Modifies prompt based on user inputs.
1771 * @param {string} prompt Prompt to refine
1772 * @param {object} [args] Additional arguments for refinement
1773 * @param {string} [args.negative] Negative prompt to prefill
1774 * @param {string} [args.resolution] Saved resolution to offer as a checkbox option
1775 * @returns {Promise<string>} Refined prompt
1776 */
1777async function refinePrompt(prompt, args = null) {
1778 if (extension_settings.sd.refine_mode) {
1779 /** @type {import('../../popup.js').CustomPopupInput[]} */
1780 const customInputs = [];
1781
1782 if (args?.negative) {
1783 customInputs.push({
1784 id: 'sd_refine_negative',
1785 label: t`Negative prompt (optional)`,
1786 type: 'textarea',
1787 rows: 4,
1788 defaultState: String(args.negative || ''),
1789 });
1790 }
1791
1792 if (args?.resolution) {
1793 customInputs.push({
1794 id: 'sd_use_saved_resolution',
1795 label: t`Use saved resolution (${args.resolution})`,
1796 type: 'checkbox',
1797 defaultState: true,
1798 });
1799 }
1800
1801 const refinedPrompt = await Popup.show.input(
1802 t`Review and edit the prompt:`,
1803 t`Press "Cancel" to abort the image generation.`,
1804 prompt.trim(),
1805 {
1806 rows: 8,
1807 okButton: t`Continue`,
1808 cancelButton: t`Cancel`,
1809 customInputs,
1810 onClose: (popup) => {
1811 if (!popup.result || !(popup.inputResults instanceof Map) || !args) {
1812 return;
1813 }
1814
1815 const negativeInput = popup.inputResults.get('sd_refine_negative');
1816 const useSavedResolution = popup.inputResults.get('sd_use_saved_resolution');
1817
1818 if (negativeInput) {
1819 args.negative = negativeInput.toString().trim();
1820 }
1821 if (!useSavedResolution) {
1822 args.resolution = null;
1823 }
1824 },
1825 });
1826
1827 if (refinedPrompt) {
1828 return String(refinedPrompt);
1829 } else {
1830 throw new Error('Generation aborted by user.');
1831 }
1832 }
1833
1834 return prompt;
1835}
1836
1837async function onChatChanged() {
1838 if (this_chid === undefined || selected_group) {
1839 $('#sd_character_prompt_block').hide();
1840 return;
1841 }
1842
1843 $('#sd_character_prompt_block').show();
1844
1845 const key = getCharaFilename(this_chid);
1846 let characterPrompt = key ? (extension_settings.sd.character_prompts[key] || '') : '';
1847 let negativePrompt = key ? (extension_settings.sd.character_negative_prompts[key] || '') : '';
1848
1849 const context = getContext();
1850 const sharedPromptData = context?.characters[this_chid]?.data?.extensions?.sd_character_prompt;
1851 const hasSharedData = sharedPromptData && typeof sharedPromptData === 'object';
1852
1853 if (typeof sharedPromptData?.positive === 'string' && !characterPrompt && sharedPromptData.positive) {
1854 characterPrompt = sharedPromptData.positive;
1855 extension_settings.sd.character_prompts[key] = characterPrompt;
1856 }
1857 if (typeof sharedPromptData?.negative === 'string' && !negativePrompt && sharedPromptData.negative) {
1858 negativePrompt = sharedPromptData.negative;
1859 extension_settings.sd.character_negative_prompts[key] = negativePrompt;
1860 }
1861
1862 $('#sd_character_prompt').val(characterPrompt);
1863 $('#sd_character_negative_prompt').val(negativePrompt);
1864 $('#sd_character_prompt_share').prop('checked', hasSharedData);
1865 await adjustElementScrollHeight();
1866}
1867
1868async function adjustElementScrollHeight() {
1869 if (CSS.supports('field-sizing', 'content') || !$('.sd_settings').is(':visible')) {
1870 return;
1871 }
1872
1873 await resetScrollHeight($('#sd_prompt_prefix'));
1874 await resetScrollHeight($('#sd_negative_prompt'));
1875 await resetScrollHeight($('#sd_character_prompt'));
1876 await resetScrollHeight($('#sd_character_negative_prompt'));
1877}
1878
1879async function onCharacterPromptInput() {
1880 const key = getCharaFilename(this_chid);
1881 extension_settings.sd.character_prompts[key] = $('#sd_character_prompt').val();
1882 saveSettingsDebounced();
1883 writePromptFieldsDebounced(this_chid);
1884 if (CSS.supports('field-sizing', 'content')) return;
1885 await resetScrollHeight($(this));
1886}
1887
1888async function onCharacterNegativePromptInput() {
1889 const key = getCharaFilename(this_chid);
1890 extension_settings.sd.character_negative_prompts[key] = $('#sd_character_negative_prompt').val();
1891 saveSettingsDebounced();
1892 writePromptFieldsDebounced(this_chid);
1893 if (CSS.supports('field-sizing', 'content')) return;
1894 await resetScrollHeight($(this));
1895}
1896
1897function getCharacterPrefix() {
1898 if (this_chid === undefined || selected_group) {
1899 return '';
1900 }
1901
1902 const key = getCharaFilename(this_chid);
1903
1904 if (key) {
1905 return extension_settings.sd.character_prompts[key] || '';
1906 }
1907
1908 return '';
1909}
1910
1911function getCharacterNegativePrefix() {
1912 if (this_chid === undefined || selected_group) {
1913 return '';
1914 }
1915
1916 const key = getCharaFilename(this_chid);
1917
1918 if (key) {
1919 return extension_settings.sd.character_negative_prompts[key] || '';
1920 }
1921
1922 return '';
1923}
1924
1925/**
1926 * Combines two prompt prefixes into one.
1927 * @param {string} str1 Base string
1928 * @param {string} str2 Secondary string
1929 * @param {string} macro Macro to replace with the secondary string
1930 * @returns {string} Combined string with a comma between them
1931 */
1932function combinePrefixes(str1, str2, macro = '') {
1933 // Remove leading/trailing white spaces and commas from the strings
1934 const process = (s) => s.trim().replace(/^,|,$/g, '').trim();
1935
1936 if (!str2) {
1937 return str1;
1938 }
1939
1940 str1 = process(str1);
1941 str2 = process(str2);
1942
1943 // Combine the strings with a comma between them)
1944 const result = macro && str1.includes(macro) ? str1.replace(macro, str2) : `${str1}, ${str2},`;
1945 return process(result);
1946}
1947
1948function onRefineModeInput() {
1949 extension_settings.sd.refine_mode = !!$('#sd_refine_mode').prop('checked');
1950 saveSettingsDebounced();
1951}
1952
1953function onFreeExtendInput() {
1954 extension_settings.sd.free_extend = !!$('#sd_free_extend').prop('checked');
1955 saveSettingsDebounced();
1956}
1957
1958function onWandVisibleInput() {
1959 extension_settings.sd.wand_visible = !!$('#sd_wand_visible').prop('checked');
1960 saveSettingsDebounced();
1961}
1962
1963function onCommandVisibleInput() {
1964 extension_settings.sd.command_visible = !!$('#sd_command_visible').prop('checked');
1965 saveSettingsDebounced();
1966}
1967
1968function onInteractiveVisibleInput() {
1969 extension_settings.sd.interactive_visible = !!$('#sd_interactive_visible').prop('checked');
1970 saveSettingsDebounced();
1971}
1972
1973function onToolVisibleInput() {
1974 extension_settings.sd.tool_visible = !!$('#sd_tool_visible').prop('checked');
1975 saveSettingsDebounced();
1976}
1977
1978function onClipSkipInput() {
1979 extension_settings.sd.clip_skip = Number($('#sd_clip_skip').val());
1980 $('#sd_clip_skip_value').val(extension_settings.sd.clip_skip);
1981 saveSettingsDebounced();
1982}
1983
1984function onSeedInput() {
1985 extension_settings.sd.seed = Number($('#sd_seed').val());
1986 saveSettingsDebounced();
1987}
1988
1989function onScaleInput() {
1990 extension_settings.sd.scale = Number($('#sd_scale').val());
1991 $('#sd_scale_value').val(extension_settings.sd.scale.toFixed(1));
1992 saveSettingsDebounced();
1993}
1994
1995function onStepsInput() {
1996 extension_settings.sd.steps = Number($('#sd_steps').val());
1997 $('#sd_steps_value').val(extension_settings.sd.steps);
1998 saveSettingsDebounced();
1999}
2000
2001async function onPromptPrefixInput() {
2002 extension_settings.sd.prompt_prefix = $('#sd_prompt_prefix').val();
2003 saveSettingsDebounced();
2004 if (CSS.supports('field-sizing', 'content')) return;
2005 await resetScrollHeight($(this));
2006}
2007
2008async function onNegativePromptInput() {
2009 extension_settings.sd.negative_prompt = $('#sd_negative_prompt').val();
2010 saveSettingsDebounced();
2011 if (CSS.supports('field-sizing', 'content')) return;
2012 await resetScrollHeight($(this));
2013}
2014
2015function onSamplerChange() {
2016 extension_settings.sd.sampler = $('#sd_sampler').find(':selected').val();
2017 saveSettingsDebounced();
2018}
2019
2020function onADetailerFaceChange() {
2021 extension_settings.sd.adetailer_face = !!$('#sd_adetailer_face').prop('checked');
2022 saveSettingsDebounced();
2023}
2024
2025const resolutionOptions = {
2026 sd_res_512x512: { width: 512, height: 512, name: translate('512x512 (1:1, icons, profile pictures)', 'sd_res_512x512') },
2027 sd_res_600x600: { width: 600, height: 600, name: translate('600x600 (1:1, icons, profile pictures)', 'sd_res_600x600') },
2028 sd_res_512x768: { width: 512, height: 768, name: translate('512x768 (2:3, vertical character card)', 'sd_res_512x768') },
2029 sd_res_768x512: { width: 768, height: 512, name: translate('768x512 (3:2, horizontal 35-mm movie film)', 'sd_res_768x512') },
2030 sd_res_960x540: { width: 960, height: 540, name: translate('960x540 (16:9, horizontal wallpaper)', 'sd_res_960x540') },
2031 sd_res_540x960: { width: 540, height: 960, name: translate('540x960 (9:16, vertical wallpaper)', 'sd_res_540x960') },
2032 sd_res_1920x1088: { width: 1920, height: 1088, name: translate('1920x1088 (16:9, 1080p, horizontal wallpaper)', 'sd_res_1920x1088') },
2033 sd_res_1088x1920: { width: 1088, height: 1920, name: translate('1088x1920 (9:16, 1080p, vertical wallpaper)', 'sd_res_1088x1920') },
2034 sd_res_1280x720: { width: 1280, height: 720, name: translate('1280x720 (16:9, 720p, horizontal wallpaper)', 'sd_res_1280x720') },
2035 sd_res_720x1280: { width: 720, height: 1280, name: translate('720x1280 (9:16, 720p, vertical wallpaper)', 'sd_res_720x1280') },
2036 sd_res_1024x1024: { width: 1024, height: 1024, name: '1024x1024 (1:1, SDXL)' },
2037 sd_res_1152x896: { width: 1152, height: 896, name: '1152x896 (9:7, SDXL)' },
2038 sd_res_896x1152: { width: 896, height: 1152, name: '896x1152 (7:9, SDXL)' },
2039 sd_res_1216x832: { width: 1216, height: 832, name: '1216x832 (19:13, SDXL)' },
2040 sd_res_832x1216: { width: 832, height: 1216, name: '832x1216 (13:19, SDXL)' },
2041 sd_res_1344x768: { width: 1344, height: 768, name: '1344x768 (4:3, SDXL)' },
2042 sd_res_768x1344: { width: 768, height: 1344, name: '768x1344 (3:4, SDXL)' },
2043 sd_res_1536x640: { width: 1536, height: 640, name: '1536x640 (24:10, SDXL)' },
2044 sd_res_640x1536: { width: 640, height: 1536, name: '640x1536 (10:24, SDXL)' },
2045 sd_res_1536x1024: { width: 1536, height: 1024, name: '1536x1024 (3:2, ChatGPT)' },
2046 sd_res_1024x1536: { width: 1024, height: 1536, name: '1024x1536 (2:3, ChatGPT)' },
2047 sd_res_1024x1792: { width: 1024, height: 1792, name: '1024x1792 (4:7, DALL-E)' },
2048 sd_res_1792x1024: { width: 1792, height: 1024, name: '1792x1024 (7:4, DALL-E)' },
2049 sd_res_1280x1280: { width: 1280, height: 1280, name: '1280x1280 (1:1, Z.AI)' },
2050 sd_res_1568x1056: { width: 1568, height: 1056, name: '1568x1056 (3:2, Z.AI)' },
2051 sd_res_1056x1568: { width: 1056, height: 1568, name: '1056x1568 (2:3, Z.AI)' },
2052 sd_res_1472x1088: { width: 1472, height: 1088, name: '1472x1088 (4:3, Z.AI)' },
2053 sd_res_1088x1472: { width: 1088, height: 1472, name: '1088x1472 (3:4, Z.AI)' },
2054 sd_res_1728x960: { width: 1728, height: 960, name: '1728x960 (16:9, Z.AI)' },
2055 sd_res_960x1728: { width: 960, height: 1728, name: '960x1728 (9:16, Z.AI)' },
2056};
2057
2058function onResolutionChange() {
2059 const selectedOption = $('#sd_resolution').val();
2060 const selectedResolution = resolutionOptions[selectedOption];
2061
2062 if (!selectedResolution) {
2063 console.warn(`Could not find resolution option for ${selectedOption}`);
2064 return;
2065 }
2066
2067 $('#sd_height').val(selectedResolution.height).trigger('input');
2068 $('#sd_width').val(selectedResolution.width).trigger('input');
2069}
2070
2071function onSchedulerChange() {
2072 extension_settings.sd.scheduler = $('#sd_scheduler').find(':selected').val();
2073 saveSettingsDebounced();
2074}
2075
2076function onWidthInput() {
2077 extension_settings.sd.width = Number($('#sd_width').val());
2078 $('#sd_width_value').val(extension_settings.sd.width);
2079 saveSettingsDebounced();
2080}
2081
2082function onHeightInput() {
2083 extension_settings.sd.height = Number($('#sd_height').val());
2084 $('#sd_height_value').val(extension_settings.sd.height);
2085 saveSettingsDebounced();
2086}
2087
2088function onSwapDimensionsClick() {
2089 const w = extension_settings.sd.height;
2090 const h = extension_settings.sd.width;
2091 extension_settings.sd.width = w;
2092 extension_settings.sd.height = h;
2093 $('#sd_width').val(w).trigger('input');
2094 $('#sd_height').val(h).trigger('input');
2095 saveSettingsDebounced();
2096}
2097
2098async function onSourceChange() {
2099 extension_settings.sd.source = $('#sd_source').find(':selected').val();
2100 extension_settings.sd.model = null;
2101 extension_settings.sd.sampler = null;
2102 extension_settings.sd.scheduler = null;
2103 extension_settings.sd.vae = null;
2104 toggleSourceControls();
2105 saveSettingsDebounced();
2106 await loadSettingOptions();
2107}
2108
2109async function onComfyTypeChange() {
2110 extension_settings.sd.comfy_type = $('#sd_comfy_type').find(':selected').val();
2111 await onSourceChange();
2112}
2113
2114function onFunctionToolInput() {
2115 extension_settings.sd.function_tool = !!$(this).prop('checked');
2116 saveSettingsDebounced();
2117 registerFunctionTool();
2118}
2119
2120async function onOpenAiStyleSelect() {
2121 extension_settings.sd.openai_style = String($('#sd_openai_style').find(':selected').val());
2122 saveSettingsDebounced();
2123}
2124
2125async function onOpenAiQualitySelect() {
2126 extension_settings.sd.openai_quality = String($('#sd_openai_quality').find(':selected').val());
2127 saveSettingsDebounced();
2128}
2129
2130async function onOpenAiDurationSelect() {
2131 extension_settings.sd.openai_duration = String($('#sd_openai_duration').find(':selected').val());
2132 saveSettingsDebounced();
2133}
2134
2135async function onViewAnlasClick() {
2136 const result = await loadNovelSubscriptionData();
2137
2138 if (!result) {
2139 toastr.warning('Are you subscribed?', 'Could not load NovelAI subscription data');
2140 return;
2141 }
2142
2143 const anlas = getNovelAnlas();
2144 const unlimitedGeneration = getNovelUnlimitedImageGeneration();
2145
2146 toastr.info(`Free image generation: ${unlimitedGeneration ? 'Yes' : 'No'}`, `Anlas: ${anlas}`);
2147}
2148
2149function onNovelAnlasGuardInput() {
2150 extension_settings.sd.novel_anlas_guard = !!$('#sd_novel_anlas_guard').prop('checked');
2151 saveSettingsDebounced();
2152}
2153
2154function onNovelSmInput() {
2155 extension_settings.sd.novel_sm = !!$('#sd_novel_sm').prop('checked');
2156 saveSettingsDebounced();
2157
2158 if (!extension_settings.sd.novel_sm) {
2159 $('#sd_novel_sm_dyn').prop('checked', false).prop('disabled', true).trigger('input');
2160 } else {
2161 $('#sd_novel_sm_dyn').prop('disabled', false);
2162 }
2163}
2164
2165function onNovelSmDynInput() {
2166 extension_settings.sd.novel_sm_dyn = !!$('#sd_novel_sm_dyn').prop('checked');
2167 saveSettingsDebounced();
2168}
2169
2170function onNovelDecrisperInput() {
2171 extension_settings.sd.novel_decrisper = !!$('#sd_novel_decrisper').prop('checked');
2172 saveSettingsDebounced();
2173}
2174
2175function onNovelVarietyBoostInput() {
2176 extension_settings.sd.novel_variety_boost = !!$('#sd_novel_variety_boost').prop('checked');
2177 saveSettingsDebounced();
2178}
2179
2180function onPollinationsEnhanceInput() {
2181 extension_settings.sd.pollinations_enhance = !!$('#sd_pollinations_enhance').prop('checked');
2182 saveSettingsDebounced();
2183}
2184
2185function onHordeNsfwInput() {
2186 extension_settings.sd.horde_nsfw = !!$(this).prop('checked');
2187 saveSettingsDebounced();
2188}
2189
2190function onHordeKarrasInput() {
2191 extension_settings.sd.horde_karras = !!$(this).prop('checked');
2192 saveSettingsDebounced();
2193}
2194
2195function onHordeSanitizeInput() {
2196 extension_settings.sd.horde_sanitize = !!$(this).prop('checked');
2197 saveSettingsDebounced();
2198}
2199
2200function onRestoreFacesInput() {
2201 extension_settings.sd.restore_faces = !!$(this).prop('checked');
2202 saveSettingsDebounced();
2203}
2204
2205function onHighResFixInput() {
2206 extension_settings.sd.enable_hr = !!$(this).prop('checked');
2207 saveSettingsDebounced();
2208}
2209
2210function onAutoUrlInput() {
2211 extension_settings.sd.auto_url = $('#sd_auto_url').val();
2212 saveSettingsDebounced();
2213}
2214
2215function onAutoAuthInput() {
2216 extension_settings.sd.auto_auth = $('#sd_auto_auth').val();
2217 saveSettingsDebounced();
2218}
2219
2220function onSdcppUrlInput() {
2221 extension_settings.sd.sdcpp_url = $('#sd_sdcpp_url').val();
2222 saveSettingsDebounced();
2223}
2224
2225function onVladUrlInput() {
2226 extension_settings.sd.vlad_url = $('#sd_vlad_url').val();
2227 saveSettingsDebounced();
2228}
2229
2230function onVladAuthInput() {
2231 extension_settings.sd.vlad_auth = $('#sd_vlad_auth').val();
2232 saveSettingsDebounced();
2233}
2234
2235function onDrawthingsUrlInput() {
2236 extension_settings.sd.drawthings_url = $('#sd_drawthings_url').val();
2237 saveSettingsDebounced();
2238}
2239
2240function onDrawthingsAuthInput() {
2241 extension_settings.sd.drawthings_auth = $('#sd_drawthings_auth').val();
2242 saveSettingsDebounced();
2243}
2244
2245function onHrUpscalerChange() {
2246 extension_settings.sd.hr_upscaler = $('#sd_hr_upscaler').find(':selected').val();
2247 saveSettingsDebounced();
2248}
2249
2250function onHrScaleInput() {
2251 extension_settings.sd.hr_scale = Number($('#sd_hr_scale').val());
2252 $('#sd_hr_scale_value').val(extension_settings.sd.hr_scale.toFixed(1));
2253 saveSettingsDebounced();
2254}
2255
2256function onDenoisingStrengthInput() {
2257 extension_settings.sd.denoising_strength = Number($('#sd_denoising_strength').val());
2258 $('#sd_denoising_strength_value').val(extension_settings.sd.denoising_strength.toFixed(2));
2259 saveSettingsDebounced();
2260}
2261
2262function onHrSecondPassStepsInput() {
2263 extension_settings.sd.hr_second_pass_steps = Number($('#sd_hr_second_pass_steps').val());
2264 $('#sd_hr_second_pass_steps_value').val(extension_settings.sd.hr_second_pass_steps);
2265 saveSettingsDebounced();
2266}
2267
2268function onComfyUrlInput() {
2269 extension_settings.sd.comfy_url = String($('#sd_comfy_url').val());
2270 saveSettingsDebounced();
2271}
2272
2273function onComfyRunPodUrlInput() {
2274 extension_settings.sd.comfy_runpod_url = String($('#sd_comfy_runpod_url').val());
2275 saveSettingsDebounced();
2276}
2277
2278function onHFModelInput() {
2279 extension_settings.sd.huggingface_model_id = $('#sd_huggingface_model_id').val();
2280 saveSettingsDebounced();
2281}
2282
2283function onComfyWorkflowChange() {
2284 extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val();
2285 saveSettingsDebounced();
2286}
2287
2288function onBflUpsamplingInput() {
2289 extension_settings.sd.bfl_upsampling = !!$('#sd_bfl_upsampling').prop('checked');
2290 saveSettingsDebounced();
2291}
2292
2293function onStabilityStylePresetChange() {
2294 extension_settings.sd.stability_style_preset = String($('#sd_stability_style_preset').val());
2295 saveSettingsDebounced();
2296}
2297
2298async function changeComfyWorkflow(_, name) {
2299 name = name.replace(/(\.json)?$/i, '.json');
2300 if ($(`#sd_comfy_workflow > [value="${name}"]`).length > 0) {
2301 extension_settings.sd.comfy_workflow = name;
2302 $('#sd_comfy_workflow').val(extension_settings.sd.comfy_workflow);
2303 saveSettingsDebounced();
2304 } else {
2305 toastr.error(`ComfyUI Workflow "${name}" does not exist.`);
2306 }
2307 return '';
2308}
2309
2310async function validateAutoUrl() {
2311 try {
2312 if (!extension_settings.sd.auto_url) {
2313 throw new Error('URL is not set.');
2314 }
2315
2316 const result = await fetch('/api/sd/ping', {
2317 method: 'POST',
2318 headers: getRequestHeaders(),
2319 body: JSON.stringify(getSdRequestBody()),
2320 });
2321
2322 if (!result.ok) {
2323 throw new Error('SD WebUI returned an error.');
2324 }
2325
2326 await loadSettingOptions();
2327 toastr.success('SD WebUI API connected.');
2328 } catch (error) {
2329 toastr.error(`Could not validate SD WebUI API: ${error.message}`);
2330 }
2331}
2332
2333async function validateSdcppUrl() {
2334 try {
2335 if (!extension_settings.sd.sdcpp_url) {
2336 throw new Error('URL is not set.');
2337 }
2338
2339 const result = await fetch('/api/sd/sdcpp/ping', {
2340 method: 'POST',
2341 headers: getRequestHeaders(),
2342 body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }),
2343 });
2344
2345 if (!result.ok) {
2346 throw new Error('stable-diffusion.cpp server returned an error.');
2347 }
2348
2349 await loadSettingOptions();
2350 toastr.success('stable-diffusion.cpp server connected.');
2351 } catch (error) {
2352 toastr.error(`Could not validate stable-diffusion.cpp server: ${error.message}`);
2353 }
2354}
2355
2356async function validateDrawthingsUrl() {
2357 try {
2358 if (!extension_settings.sd.drawthings_url) {
2359 throw new Error('URL is not set.');
2360 }
2361
2362 const result = await fetch('/api/sd/drawthings/ping', {
2363 method: 'POST',
2364 headers: getRequestHeaders(),
2365 body: JSON.stringify(getSdRequestBody()),
2366 });
2367
2368 if (!result.ok) {
2369 throw new Error('SD Drawthings returned an error.');
2370 }
2371
2372 await loadSettingOptions();
2373 toastr.success('SD Drawthings API connected.');
2374 } catch (error) {
2375 toastr.error(`Could not validate SD Drawthings API: ${error.message}`);
2376 }
2377}
2378
2379async function validateVladUrl() {
2380 try {
2381 if (!extension_settings.sd.vlad_url) {
2382 throw new Error('URL is not set.');
2383 }
2384
2385 const result = await fetch('/api/sd/ping', {
2386 method: 'POST',
2387 headers: getRequestHeaders(),
2388 body: JSON.stringify(getSdRequestBody()),
2389 });
2390
2391 if (!result.ok) {
2392 throw new Error('SD.Next returned an error.');
2393 }
2394
2395 await loadSettingOptions();
2396 toastr.success('SD.Next API connected.');
2397 } catch (error) {
2398 toastr.error(`Could not validate SD.Next API: ${error.message}`);
2399 }
2400}
2401
2402async function validateComfyUrl() {
2403 try {
2404 if (!extension_settings.sd.comfy_url) {
2405 throw new Error('URL is not set.');
2406 }
2407
2408 const result = await fetch('/api/sd/comfy/ping', {
2409 method: 'POST',
2410 headers: getRequestHeaders(),
2411 body: JSON.stringify({
2412 url: extension_settings.sd.comfy_url,
2413 }),
2414 });
2415 if (!result.ok) {
2416 throw new Error('ComfyUI returned an error.');
2417 }
2418
2419 await loadSettingOptions();
2420 toastr.success('ComfyUI API connected.');
2421 } catch (error) {
2422 toastr.error(`Could not validate ComfyUI API: ${error.message}`);
2423 }
2424}
2425
2426async function validateComfyRunPodUrl() {
2427 try {
2428 if (!extension_settings.sd.comfy_runpod_url) {
2429 throw new Error('URL is not set.');
2430 }
2431
2432 const result = await fetch('/api/sd/comfyrunpod/ping', {
2433 method: 'POST',
2434 headers: getRequestHeaders(),
2435 body: JSON.stringify({
2436 url: extension_settings.sd.comfy_runpod_url,
2437 }),
2438 });
2439 if (!result.ok) {
2440 throw new Error('ComfyUI RunPod returned an error.');
2441 }
2442
2443 await loadSettingOptions();
2444 toastr.success('ComfyUI RunPod API connected.');
2445 } catch (error) {
2446 toastr.error(`Could not validate ComfyUI RunPod API: ${error.message}`);
2447 }
2448}
2449
2450async function onModelChange() {
2451 const selectedModel = $('#sd_model').find(':selected');
2452 extension_settings.sd.model = selectedModel.val();
2453 saveSettingsDebounced();
2454
2455 if (extension_settings.sd.model && extension_settings.sd.source === sources.electronhub) {
2456 const cachedModel = selectedModel.data('model');
2457 const models = cachedModel ? [cachedModel] : await loadElectronHubModels();
2458 ensureElectronHubQualitySelect(models);
2459 }
2460
2461 switchModelSpecificControls(extension_settings.sd.model);
2462
2463 const updateRemoteModelSources = [
2464 sources.auto,
2465 sources.vlad,
2466 sources.extras,
2467 ];
2468
2469 if (!updateRemoteModelSources.includes(extension_settings.sd.source)) {
2470 return;
2471 }
2472
2473 toastr.info('Updating remote model...', 'Please wait');
2474 if (extension_settings.sd.source === sources.extras) {
2475 await updateExtrasRemoteModel();
2476 }
2477 if (extension_settings.sd.source === sources.auto || extension_settings.sd.source === sources.vlad) {
2478 await updateAutoRemoteModel();
2479 }
2480 toastr.success('Model successfully loaded!', 'Image Generation');
2481}
2482
2483async function getAutoRemoteModel() {
2484 try {
2485 const result = await fetch('/api/sd/get-model', {
2486 method: 'POST',
2487 headers: getRequestHeaders(),
2488 body: JSON.stringify(getSdRequestBody()),
2489 });
2490
2491 if (!result.ok) {
2492 throw new Error('SD WebUI returned an error.');
2493 }
2494
2495 return await result.text();
2496 } catch (error) {
2497 console.error(error);
2498 return null;
2499 }
2500}
2501
2502async function getDrawthingsRemoteModel() {
2503 try {
2504 const result = await fetch('/api/sd/drawthings/get-model', {
2505 method: 'POST',
2506 headers: getRequestHeaders(),
2507 body: JSON.stringify(getSdRequestBody()),
2508 });
2509
2510 if (!result.ok) {
2511 throw new Error('SD DrawThings API returned an error.');
2512 }
2513
2514 return await result.text();
2515 } catch (error) {
2516 console.error(error);
2517 return null;
2518 }
2519}
2520
2521async function onVaeChange() {
2522 extension_settings.sd.vae = $('#sd_vae').find(':selected').val();
2523}
2524
2525async function getAutoRemoteUpscalers() {
2526 try {
2527 const result = await fetch('/api/sd/upscalers', {
2528 method: 'POST',
2529 headers: getRequestHeaders(),
2530 body: JSON.stringify(getSdRequestBody()),
2531 });
2532
2533 if (!result.ok) {
2534 throw new Error('SD WebUI returned an error.');
2535 }
2536
2537 return await result.json();
2538 } catch (error) {
2539 console.error(error);
2540 return [extension_settings.sd.hr_upscaler];
2541 }
2542}
2543
2544async function getAutoRemoteSchedulers() {
2545 try {
2546 const result = await fetch('/api/sd/schedulers', {
2547 method: 'POST',
2548 headers: getRequestHeaders(),
2549 body: JSON.stringify(getSdRequestBody()),
2550 });
2551
2552 if (!result.ok) {
2553 throw new Error('SD WebUI returned an error.');
2554 }
2555
2556 return await result.json();
2557 } catch (error) {
2558 console.error(error);
2559 return ['N/A'];
2560 }
2561}
2562
2563async function getVladRemoteUpscalers() {
2564 try {
2565 const result = await fetch('/api/sd/sd-next/upscalers', {
2566 method: 'POST',
2567 headers: getRequestHeaders(),
2568 body: JSON.stringify(getSdRequestBody()),
2569 });
2570
2571 if (!result.ok) {
2572 throw new Error('SD.Next returned an error.');
2573 }
2574
2575 return await result.json();
2576 } catch (error) {
2577 console.error(error);
2578 return [extension_settings.sd.hr_upscaler];
2579 }
2580}
2581
2582async function getDrawthingsRemoteUpscalers() {
2583 try {
2584 const result = await fetch('/api/sd/drawthings/get-upscaler', {
2585 method: 'POST',
2586 headers: getRequestHeaders(),
2587 body: JSON.stringify(getSdRequestBody()),
2588 });
2589
2590 if (!result.ok) {
2591 throw new Error('SD DrawThings API returned an error.');
2592 }
2593
2594 const data = await result.text();
2595
2596 return data ? [data] : ['N/A'];
2597 } catch (error) {
2598 console.error(error);
2599 return ['N/A'];
2600 }
2601}
2602
2603async function updateAutoRemoteModel() {
2604 try {
2605 const result = await fetch('/api/sd/set-model', {
2606 method: 'POST',
2607 headers: getRequestHeaders(),
2608 body: JSON.stringify({ ...getSdRequestBody(), model: extension_settings.sd.model }),
2609 });
2610
2611 if (!result.ok) {
2612 throw new Error('SD WebUI returned an error.');
2613 }
2614
2615 console.log('Model successfully updated on SD WebUI remote.');
2616 } catch (error) {
2617 console.error(error);
2618 toastr.error(`Could not update SD WebUI model: ${error.message}`);
2619 }
2620}
2621
2622async function updateExtrasRemoteModel() {
2623 const url = new URL(getApiUrl());
2624 url.pathname = '/api/image/model';
2625 const getCurrentModelResult = await doExtrasFetch(url, {
2626 method: 'POST',
2627 body: JSON.stringify({ model: extension_settings.sd.model }),
2628 });
2629
2630 if (getCurrentModelResult.ok) {
2631 console.log('Model successfully updated on SD remote.');
2632 }
2633}
2634
2635async function loadSamplers() {
2636 $('#sd_sampler').empty();
2637 let samplers = [];
2638
2639 switch (extension_settings.sd.source) {
2640 case sources.extras:
2641 samplers = await loadExtrasSamplers();
2642 break;
2643 case sources.horde:
2644 samplers = await loadHordeSamplers();
2645 break;
2646 case sources.auto:
2647 samplers = await loadAutoSamplers();
2648 break;
2649 case sources.sdcpp:
2650 samplers = await loadSdcppSamplers();
2651 break;
2652 case sources.drawthings:
2653 samplers = await loadDrawthingsSamplers();
2654 break;
2655 case sources.novel:
2656 samplers = await loadNovelSamplers();
2657 break;
2658 case sources.vlad:
2659 samplers = await loadVladSamplers();
2660 break;
2661 case sources.openai:
2662 samplers = ['N/A'];
2663 break;
2664 case sources.aimlapi:
2665 samplers = ['N/A'];
2666 break;
2667 case sources.comfy:
2668 samplers = await loadComfySamplers();
2669 break;
2670 case sources.togetherai:
2671 samplers = ['N/A'];
2672 break;
2673 case sources.pollinations:
2674 samplers = ['N/A'];
2675 break;
2676 case sources.stability:
2677 samplers = ['N/A'];
2678 break;
2679 case sources.huggingface:
2680 samplers = ['N/A'];
2681 break;
2682 case sources.chutes:
2683 samplers = ['N/A'];
2684 break;
2685 case sources.electronhub:
2686 samplers = ['N/A'];
2687 break;
2688 case sources.nanogpt:
2689 samplers = ['N/A'];
2690 break;
2691 case sources.bfl:
2692 samplers = ['N/A'];
2693 break;
2694 case sources.falai:
2695 samplers = ['N/A'];
2696 break;
2697 case sources.xai:
2698 samplers = ['N/A'];
2699 break;
2700 case sources.google:
2701 samplers = ['N/A'];
2702 break;
2703 case sources.zai:
2704 samplers = ['N/A'];
2705 break;
2706 case sources.openrouter:
2707 samplers = ['N/A'];
2708 break;
2709 case sources.workersai:
2710 samplers = ['N/A'];
2711 break;
2712 }
2713
2714 for (const sampler of samplers) {
2715 const option = document.createElement('option');
2716 option.innerText = sampler;
2717 option.value = sampler;
2718 option.selected = sampler === extension_settings.sd.sampler;
2719 $('#sd_sampler').append(option);
2720 }
2721
2722 if (!extension_settings.sd.sampler && samplers.length > 0) {
2723 extension_settings.sd.sampler = samplers[0];
2724 $('#sd_sampler').val(extension_settings.sd.sampler).trigger('change');
2725 }
2726}
2727
2728async function loadHordeSamplers() {
2729 const result = await fetch('/api/horde/sd-samplers', {
2730 method: 'POST',
2731 headers: getRequestHeaders({ omitContentType: true }),
2732 });
2733
2734 if (result.ok) {
2735 return await result.json();
2736 }
2737
2738 return [];
2739}
2740
2741async function loadExtrasSamplers() {
2742 if (!modules.includes('sd')) {
2743 return [];
2744 }
2745
2746 const url = new URL(getApiUrl());
2747 url.pathname = '/api/image/samplers';
2748 const result = await doExtrasFetch(url);
2749
2750 if (result.ok) {
2751 const data = await result.json();
2752 return data.samplers;
2753 }
2754
2755 return [];
2756}
2757
2758async function loadAutoSamplers() {
2759 if (!extension_settings.sd.auto_url) {
2760 return [];
2761 }
2762
2763 try {
2764 const result = await fetch('/api/sd/samplers', {
2765 method: 'POST',
2766 headers: getRequestHeaders(),
2767 body: JSON.stringify(getSdRequestBody()),
2768 });
2769
2770 if (!result.ok) {
2771 throw new Error('SD WebUI returned an error.');
2772 }
2773
2774 return await result.json();
2775 } catch (error) {
2776 return [];
2777 }
2778}
2779
2780async function loadSdcppModels() {
2781 if (!extension_settings.sd.sdcpp_url) {
2782 return [{ value: '', text: 'N/A' }];
2783 }
2784
2785 try {
2786 const result = await fetch('/api/sd/sdcpp/models', {
2787 method: 'POST',
2788 headers: getRequestHeaders(),
2789 body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }),
2790 });
2791
2792 if (!result.ok) {
2793 return [{ value: '', text: 'N/A' }];
2794 }
2795
2796 const data = await result.json();
2797
2798 if (data?.data?.length > 0) {
2799 return data.data.map(model => ({ value: model.id, text: model.name || model.id }));
2800 }
2801 } catch (error) {
2802 console.error('Failed to load sd.cpp models:', error);
2803 }
2804
2805 return [{ value: '', text: 'N/A' }];
2806}
2807
2808async function loadSdcppSamplers() {
2809 // The sdcpp server does not provide an API for samplers, so we return the known list.
2810 return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd'];
2811}
2812
2813async function loadDrawthingsSamplers() {
2814 // The app developer doesn't provide an API to get these yet
2815 return [
2816 'UniPC',
2817 'DPM++ 2M Karras',
2818 'Euler a',
2819 'DPM++ SDE Karras',
2820 'PLMS',
2821 'DDIM',
2822 'LCM',
2823 'Euler A Substep',
2824 'DPM++ SDE Substep',
2825 'TCD',
2826 ];
2827}
2828
2829async function loadVladSamplers() {
2830 if (!extension_settings.sd.vlad_url) {
2831 return [];
2832 }
2833
2834 try {
2835 const result = await fetch('/api/sd/samplers', {
2836 method: 'POST',
2837 headers: getRequestHeaders(),
2838 body: JSON.stringify(getSdRequestBody()),
2839 });
2840
2841 if (!result.ok) {
2842 throw new Error('SD.Next returned an error.');
2843 }
2844
2845 return await result.json();
2846 } catch (error) {
2847 return [];
2848 }
2849}
2850
2851async function loadNovelSamplers() {
2852 return [
2853 'k_euler_ancestral',
2854 'k_euler',
2855 'k_dpmpp_2m',
2856 'k_dpmpp_sde',
2857 'k_dpmpp_2s_ancestral',
2858 'k_dpm_fast',
2859 'ddim',
2860 ];
2861}
2862
2863async function loadComfySamplers() {
2864 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
2865 return ['N/A'];
2866 }
2867 if (!extension_settings.sd.comfy_url) {
2868 return [];
2869 }
2870
2871 try {
2872 const result = await fetch('/api/sd/comfy/samplers', {
2873 method: 'POST',
2874 headers: getRequestHeaders(),
2875 body: JSON.stringify({
2876 url: extension_settings.sd.comfy_url,
2877 }),
2878 });
2879 if (!result.ok) {
2880 throw new Error('ComfyUI returned an error.');
2881 }
2882 return await result.json();
2883 } catch (error) {
2884 return [];
2885 }
2886}
2887
2888async function loadModels() {
2889 $('#sd_model').empty();
2890 let models = [];
2891
2892 switch (extension_settings.sd.source) {
2893 case sources.extras:
2894 models = await loadExtrasModels();
2895 break;
2896 case sources.horde:
2897 models = await loadHordeModels();
2898 break;
2899 case sources.auto:
2900 models = await loadAutoModels();
2901 break;
2902 case sources.sdcpp:
2903 models = await loadSdcppModels();
2904 break;
2905 case sources.drawthings:
2906 models = await loadDrawthingsModels();
2907 break;
2908 case sources.novel:
2909 models = await loadNovelModels();
2910 break;
2911 case sources.vlad:
2912 models = await loadVladModels();
2913 break;
2914 case sources.openai:
2915 models = await loadOpenAiModels();
2916 break;
2917 case sources.aimlapi:
2918 models = await loadAimlapiModels();
2919 break;
2920 case sources.comfy:
2921 models = await loadComfyModels();
2922 break;
2923 case sources.togetherai:
2924 models = await loadTogetherAIModels();
2925 break;
2926 case sources.pollinations:
2927 models = await loadPollinationsModels();
2928 break;
2929 case sources.stability:
2930 models = await loadStabilityModels();
2931 break;
2932 case sources.huggingface:
2933 models = [{ value: '', text: t`<Enter Model ID above>` }];
2934 break;
2935 case sources.chutes:
2936 models = await loadChutesModels();
2937 break;
2938 case sources.electronhub:
2939 models = await loadElectronHubModels();
2940 break;
2941 case sources.nanogpt:
2942 models = await loadNanoGPTModels();
2943 break;
2944 case sources.bfl:
2945 models = await loadBflModels();
2946 break;
2947 case sources.falai:
2948 models = await loadFalaiModels();
2949 break;
2950 case sources.xai:
2951 models = await loadXAIModels();
2952 break;
2953 case sources.google:
2954 models = await loadGoogleModels();
2955 break;
2956 case sources.zai:
2957 models = await loadZaiModels();
2958 break;
2959 case sources.openrouter:
2960 models = await loadOpenRouterModels();
2961 break;
2962 case sources.workersai:
2963 models = await loadWorkersAIImageModels();
2964 break;
2965 }
2966
2967 if (extension_settings.sd.source === sources.electronhub) {
2968 ensureElectronHubQualitySelect(models);
2969 }
2970
2971 switchModelSpecificControls(extension_settings.sd.model);
2972
2973 for (const model of models) {
2974 const option = document.createElement('option');
2975 option.innerText = model.text;
2976 option.value = model.value;
2977 option.selected = model.value === extension_settings.sd.model;
2978 $(option).data('model', model);
2979 $('#sd_model').append(option);
2980 }
2981
2982 if (!extension_settings.sd.model && models.length > 0) {
2983 extension_settings.sd.model = models[0].value;
2984 $('#sd_model').val(extension_settings.sd.model).trigger('change');
2985 }
2986}
2987
2988/**
2989 * Show or hide model-specific controls based on the selected model.
2990 * @param {string} modelId Model ID
2991 */
2992function switchModelSpecificControls(modelId) {
2993 const modelControls = $('.sd_settings [data-sd-model]');
2994 modelControls.hide();
2995
2996 if (!modelId) {
2997 return;
2998 }
2999
3000 modelControls.each(function () {
3001 const models = String($(this).attr('data-sd-model') || '').split(',').map(m => m.trim());
3002 $(this).toggle(models.some(m => modelId.includes(m)));
3003 });
3004}
3005
3006/**
3007 * Ensure the Electron Hub quality select is populated based on the selected model.
3008 * @param {any[]} models Array of models
3009 */
3010function ensureElectronHubQualitySelect(models) {
3011 try {
3012 const modelId = String(extension_settings.sd.model || '');
3013 if (!modelId) return;
3014
3015 const model = Array.isArray(models) ? models.find(m => String(m?.id) === modelId) : undefined;
3016 const qualities = Array.isArray(model?.qualities) ? model.qualities : undefined;
3017
3018 const $qualityRow = $('#sd_electronhub_quality_row');
3019 const $select = $('#sd_electronhub_quality');
3020
3021 $qualityRow.toggle(!!qualities && qualities.length > 0);
3022 $select.empty();
3023
3024 if (!qualities || qualities.length === 0) {
3025 extension_settings.sd.electronhub_quality = undefined;
3026 saveSettingsDebounced();
3027 return;
3028 }
3029
3030 for (const q of qualities) {
3031 const opt = document.createElement('option');
3032 opt.value = String(q);
3033 opt.textContent = String(q);
3034 opt.selected = String(q) === String(extension_settings.sd.electronhub_quality || '');
3035 $select.append(opt);
3036 }
3037
3038 if (!$select.val()) {
3039 const first = String(qualities[0]);
3040 extension_settings.sd.electronhub_quality = first;
3041 $select.val(first);
3042 saveSettingsDebounced();
3043 }
3044 } catch (e) {
3045 console.error(e);
3046 }
3047}
3048
3049async function loadStabilityModels() {
3050 $('#sd_stability_key').toggleClass('success', !!secret_state[SECRET_KEYS.STABILITY]);
3051
3052 return [
3053 { value: 'stable-image-ultra', text: 'Stable Image Ultra' },
3054 { value: 'stable-image-core', text: 'Stable Image Core' },
3055 { value: 'stable-diffusion-3', text: 'Stable Diffusion 3' },
3056 ];
3057}
3058
3059async function loadBflModels() {
3060 $('#sd_bfl_key').toggleClass('success', !!secret_state[SECRET_KEYS.BFL]);
3061
3062 return [
3063 { value: 'flux-pro-1.1-ultra', text: 'flux-pro-1.1-ultra' },
3064 { value: 'flux-pro-1.1', text: 'flux-pro-1.1' },
3065 { value: 'flux-pro', text: 'flux-pro' },
3066 { value: 'flux-dev', text: 'flux-dev' },
3067 ];
3068}
3069
3070async function loadFalaiModels() {
3071 $('#sd_falai_key').toggleClass('success', !!secret_state[SECRET_KEYS.FALAI]);
3072
3073 const result = await fetch('/api/sd/falai/models', {
3074 method: 'POST',
3075 headers: getRequestHeaders({ omitContentType: true }),
3076 });
3077
3078 if (result.ok) {
3079 return await result.json();
3080 }
3081
3082 return [];
3083}
3084
3085async function loadXAIModels() {
3086 return [
3087 { value: 'grok-imagine-image', text: 'grok-imagine-image' },
3088 { value: 'grok-imagine-image-pro', text: 'grok-imagine-image-pro' },
3089 ];
3090}
3091
3092async function loadWorkersAIImageModels() {
3093 $('#sd_cf_workers_key').toggleClass('success', !!secret_state[SECRET_KEYS.WORKERS_AI]);
3094
3095 if (!secret_state[SECRET_KEYS.WORKERS_AI]) {
3096 return [];
3097 }
3098
3099 if (!oai_settings.workers_ai_account_id) {
3100 toastr.warning('Workers AI account ID is required. Save it in the "API Connections" panel.', 'Image Generation');
3101 return [];
3102 }
3103
3104 const result = await fetch('/api/sd/workersai/models', {
3105 method: 'POST',
3106 headers: getRequestHeaders(),
3107 body: JSON.stringify({
3108 account_id: oai_settings.workers_ai_account_id,
3109 }),
3110 });
3111
3112 if (result.ok) {
3113 return await result.json();
3114 }
3115
3116 return [];
3117}
3118
3119async function loadPollinationsModels() {
3120 $('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]);
3121
3122 const result = await fetch('/api/sd/pollinations/models', {
3123 method: 'POST',
3124 headers: getRequestHeaders({ omitContentType: true }),
3125 });
3126
3127 if (result.ok) {
3128 return await result.json();
3129 }
3130
3131 return [];
3132}
3133
3134async function loadTogetherAIModels() {
3135 if (!secret_state[SECRET_KEYS.TOGETHERAI]) {
3136 console.debug('TogetherAI API key is not set.');
3137 return [];
3138 }
3139
3140 const result = await fetch('/api/sd/together/models', {
3141 method: 'POST',
3142 headers: getRequestHeaders({ omitContentType: true }),
3143 });
3144
3145 if (result.ok) {
3146 return await result.json();
3147 }
3148
3149 return [];
3150}
3151
3152async function loadChutesModels() {
3153 if (!secret_state[SECRET_KEYS.CHUTES]) {
3154 console.debug('Chutes API key is not set.');
3155 return [];
3156 }
3157
3158 const result = await fetch('/api/sd/chutes/models', {
3159 method: 'POST',
3160 headers: getRequestHeaders({ omitContentType: true }),
3161 });
3162
3163 if (result.ok) {
3164 const models = await result.json();
3165 console.debug('Loaded Chutes image models:', models);
3166 return models;
3167 }
3168
3169 console.warn('Failed to load Chutes models:', result.status);
3170 return [];
3171}
3172
3173async function loadElectronHubModels() {
3174 if (!secret_state[SECRET_KEYS.ELECTRONHUB]) {
3175 console.debug('Electron Hub API key is not set.');
3176 return [];
3177 }
3178
3179 const result = await fetch('/api/sd/electronhub/models', {
3180 method: 'POST',
3181 headers: getRequestHeaders({ omitContentType: true }),
3182 });
3183
3184 function getModelName(model) {
3185 const name = String(model?.name || model?.id || '');
3186 const premium = model?.premium_model ? ' | Premium' : '';
3187 let price = 'Unknown';
3188 if (model?.pricing?.type === 'per_image') {
3189 const coeff = Number(model.pricing.coefficient);
3190 if (!isNaN(coeff)) {
3191 price = `$${coeff}/image`;
3192 }
3193 }
3194 return `${name} | ${price}${premium}`;
3195 }
3196
3197 if (result.ok) {
3198 /** @type {any[]} */
3199 const data = await result.json();
3200 return Array.isArray(data) ? data.map(m => ({ ...m, text: getModelName(m) })) : [];
3201 }
3202
3203 return [];
3204}
3205
3206async function loadNanoGPTModels() {
3207 if (!secret_state[SECRET_KEYS.NANOGPT]) {
3208 console.debug('NanoGPT API key is not set.');
3209 return [];
3210 }
3211
3212 const result = await fetch('/api/sd/nanogpt/models', {
3213 method: 'POST',
3214 headers: getRequestHeaders({ omitContentType: true }),
3215 });
3216
3217 if (result.ok) {
3218 return await result.json();
3219 }
3220
3221 return [];
3222}
3223
3224async function loadHordeModels() {
3225 const result = await fetch('/api/horde/sd-models', {
3226 method: 'POST',
3227 headers: getRequestHeaders({ omitContentType: true }),
3228 });
3229
3230
3231 if (result.ok) {
3232 const data = await result.json();
3233 data.sort((a, b) => b.count - a.count);
3234 return data.map(x => ({
3235 value: x.name,
3236 text: `${x.name} (ETA: ${x.eta}s, Queue: ${x.queued}, Workers: ${x.count})`,
3237 }));
3238 }
3239
3240 return [];
3241}
3242
3243async function loadExtrasModels() {
3244 if (!modules.includes('sd')) {
3245 return [];
3246 }
3247
3248 const url = new URL(getApiUrl());
3249 url.pathname = '/api/image/model';
3250 const getCurrentModelResult = await doExtrasFetch(url);
3251
3252 if (getCurrentModelResult.ok) {
3253 const data = await getCurrentModelResult.json();
3254 extension_settings.sd.model = data.model;
3255 }
3256
3257 url.pathname = '/api/image/models';
3258 const getModelsResult = await doExtrasFetch(url);
3259
3260 if (getModelsResult.ok) {
3261 const data = await getModelsResult.json();
3262 return data.models.map(x => ({ value: x, text: x }));
3263 }
3264
3265 return [];
3266}
3267
3268async function loadAutoModels() {
3269 if (!extension_settings.sd.auto_url) {
3270 return [];
3271 }
3272
3273 try {
3274 const currentModel = await getAutoRemoteModel();
3275
3276 if (currentModel) {
3277 extension_settings.sd.model = currentModel;
3278 }
3279
3280 const result = await fetch('/api/sd/models', {
3281 method: 'POST',
3282 headers: getRequestHeaders(),
3283 body: JSON.stringify(getSdRequestBody()),
3284 });
3285
3286 if (!result.ok) {
3287 throw new Error('SD WebUI returned an error.');
3288 }
3289
3290 const upscalers = await getAutoRemoteUpscalers();
3291
3292 if (Array.isArray(upscalers) && upscalers.length > 0) {
3293 $('#sd_hr_upscaler').empty();
3294
3295 for (const upscaler of upscalers) {
3296 const option = document.createElement('option');
3297 option.innerText = upscaler;
3298 option.value = upscaler;
3299 option.selected = upscaler === extension_settings.sd.hr_upscaler;
3300 $('#sd_hr_upscaler').append(option);
3301 }
3302 }
3303
3304 return await result.json();
3305 } catch (error) {
3306 return [];
3307 }
3308}
3309
3310async function loadDrawthingsModels() {
3311 if (!extension_settings.sd.drawthings_url) {
3312 return [];
3313 }
3314
3315 try {
3316 const currentModel = await getDrawthingsRemoteModel();
3317
3318 if (currentModel) {
3319 extension_settings.sd.model = currentModel;
3320 }
3321
3322 const data = [{ value: currentModel, text: currentModel }];
3323
3324
3325 const upscalers = await getDrawthingsRemoteUpscalers();
3326
3327 if (Array.isArray(upscalers) && upscalers.length > 0) {
3328 $('#sd_hr_upscaler').empty();
3329
3330 for (const upscaler of upscalers) {
3331 const option = document.createElement('option');
3332 option.innerText = upscaler;
3333 option.value = upscaler;
3334 option.selected = upscaler === extension_settings.sd.hr_upscaler;
3335 $('#sd_hr_upscaler').append(option);
3336 }
3337 }
3338
3339 return data;
3340 } catch (error) {
3341 console.log('Error loading DrawThings API models:', error);
3342 return [];
3343 }
3344}
3345
3346async function loadOpenAiModels() {
3347 return [
3348 { value: 'gpt-image-2', text: 'gpt-image-2' },
3349 { value: 'gpt-image-2-2026-04-21', text: 'gpt-image-2-2026-04-21' },
3350 { value: 'gpt-image-1.5', text: 'gpt-image-1.5' },
3351 { value: 'gpt-image-1-mini', text: 'gpt-image-1-mini' },
3352 { value: 'gpt-image-1', text: 'gpt-image-1' },
3353 { value: 'chatgpt-image-latest', text: 'chatgpt-image-latest' },
3354 { value: 'dall-e-3', text: 'dall-e-3' },
3355 { value: 'dall-e-2', text: 'dall-e-2' },
3356 { value: 'sora-2', text: 'sora-2' },
3357 { value: 'sora-2-pro', text: 'sora-2-pro' },
3358 ];
3359}
3360
3361async function loadAimlapiModels() {
3362 $('#sd_aimlapi_key').toggleClass('success', !!secret_state[SECRET_KEYS.AIMLAPI]);
3363
3364 const result = await fetch('/api/sd/aimlapi/models', {
3365 method: 'POST',
3366 headers: getRequestHeaders({ omitContentType: true }),
3367 });
3368
3369 if (!result.ok) {
3370 return [];
3371 }
3372
3373 const json = await result.json();
3374
3375 return (json.data || []);
3376}
3377
3378async function loadVladModels() {
3379 if (!extension_settings.sd.vlad_url) {
3380 return [];
3381 }
3382
3383 try {
3384 const currentModel = await getAutoRemoteModel();
3385
3386 if (currentModel) {
3387 extension_settings.sd.model = currentModel;
3388 }
3389
3390 const result = await fetch('/api/sd/models', {
3391 method: 'POST',
3392 headers: getRequestHeaders(),
3393 body: JSON.stringify(getSdRequestBody()),
3394 });
3395
3396 if (!result.ok) {
3397 throw new Error('SD WebUI returned an error.');
3398 }
3399
3400 const upscalers = await getVladRemoteUpscalers();
3401
3402 if (Array.isArray(upscalers) && upscalers.length > 0) {
3403 $('#sd_hr_upscaler').empty();
3404
3405 for (const upscaler of upscalers) {
3406 const option = document.createElement('option');
3407 option.innerText = upscaler;
3408 option.value = upscaler;
3409 option.selected = upscaler === extension_settings.sd.hr_upscaler;
3410 $('#sd_hr_upscaler').append(option);
3411 }
3412 }
3413
3414 return await result.json();
3415 } catch (error) {
3416 return [];
3417 }
3418}
3419
3420async function loadNovelModels() {
3421 return [
3422 {
3423 value: 'nai-diffusion-4-5-full',
3424 text: 'NAI Diffusion Anime V4.5 (Full)',
3425 },
3426 {
3427 value: 'nai-diffusion-4-5-curated',
3428 text: 'NAI Diffusion Anime V4.5 (Curated)',
3429 },
3430 {
3431 value: 'nai-diffusion-4-full',
3432 text: 'NAI Diffusion Anime V4 (Full)',
3433 },
3434 {
3435 value: 'nai-diffusion-4-curated-preview',
3436 text: 'NAI Diffusion Anime V4 (Curated)',
3437 },
3438 {
3439 value: 'nai-diffusion-3',
3440 text: 'NAI Diffusion Anime V3',
3441 },
3442 {
3443 value: 'nai-diffusion-2',
3444 text: 'NAI Diffusion Anime V2',
3445 },
3446 {
3447 value: 'nai-diffusion-furry-3',
3448 text: 'NAI Diffusion Furry V3',
3449 },
3450 ];
3451}
3452
3453async function loadGoogleModels() {
3454 return [
3455 'imagen-4.0-generate-001',
3456 'imagen-4.0-ultra-generate-001',
3457 'imagen-4.0-fast-generate-001',
3458 'imagen-4.0-generate-preview-06-06',
3459 'imagen-4.0-fast-generate-preview-06-06',
3460 'imagen-4.0-ultra-generate-preview-06-06',
3461 'imagen-3.0-generate-002',
3462 'imagen-3.0-generate-001',
3463 'imagen-3.0-fast-generate-001',
3464 'imagen-3.0-capability-001',
3465 'imagegeneration@006',
3466 'imagegeneration@005',
3467 'imagegeneration@002',
3468 'veo-3.1-generate-preview',
3469 'veo-3.1-fast-generate-preview',
3470 'veo-3.0-generate-001',
3471 'veo-3.0-fast-generate-001',
3472 'veo-2.0-generate-001',
3473 'veo-2.0-generate-exp',
3474 'veo-2.0-generate-preview',
3475 ].map(name => ({ value: name, text: name }));
3476}
3477
3478async function loadZaiModels() {
3479 return [
3480 { value: 'glm-image', text: 'GLM-Image' },
3481 { value: 'cogview-4-250304', text: 'CogView-4' },
3482 { value: 'cogvideox-3', text: 'CogVideoX-3' },
3483 { value: 'viduq1-text', text: 'Viduq1-Text' },
3484 ];
3485}
3486
3487async function loadOpenRouterModels() {
3488 const result = await fetch('/api/openrouter/models/image', {
3489 method: 'POST',
3490 headers: getRequestHeaders({ omitContentType: true }),
3491 });
3492
3493 if (result.ok) {
3494 return await result.json();
3495 }
3496
3497 return [];
3498}
3499
3500function loadNovelSchedulers() {
3501 return ['karras', 'native', 'exponential', 'polyexponential'];
3502}
3503
3504async function loadComfyModels() {
3505 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3506 $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]);
3507 return [
3508 { value: '', text: 'N/A' },
3509 ];
3510 }
3511 if (!extension_settings.sd.comfy_url) {
3512 return [];
3513 }
3514
3515 try {
3516 const result = await fetch('/api/sd/comfy/models', {
3517 method: 'POST',
3518 headers: getRequestHeaders(),
3519 body: JSON.stringify({
3520 url: extension_settings.sd.comfy_url,
3521 }),
3522 });
3523 if (!result.ok) {
3524 throw new Error('ComfyUI returned an error.');
3525 }
3526 return await result.json();
3527 } catch (error) {
3528 return [];
3529 }
3530}
3531
3532async function loadSchedulers() {
3533 $('#sd_scheduler').empty();
3534 let schedulers = [];
3535
3536 switch (extension_settings.sd.source) {
3537 case sources.extras:
3538 schedulers = ['N/A'];
3539 break;
3540 case sources.horde:
3541 schedulers = ['N/A'];
3542 break;
3543 case sources.auto:
3544 schedulers = await getAutoRemoteSchedulers();
3545 break;
3546 case sources.sdcpp:
3547 schedulers = await loadSdcppSchedulers();
3548 break;
3549 case sources.novel:
3550 schedulers = loadNovelSchedulers();
3551 break;
3552 case sources.vlad:
3553 schedulers = ['N/A'];
3554 break;
3555 case sources.drawthings:
3556 schedulers = ['N/A'];
3557 break;
3558 case sources.openai:
3559 schedulers = ['N/A'];
3560 break;
3561 case sources.aimlapi:
3562 schedulers = ['N/A'];
3563 break;
3564 case sources.togetherai:
3565 schedulers = ['N/A'];
3566 break;
3567 case sources.pollinations:
3568 schedulers = ['N/A'];
3569 break;
3570 case sources.comfy:
3571 schedulers = await loadComfySchedulers();
3572 break;
3573 case sources.stability:
3574 schedulers = ['N/A'];
3575 break;
3576 case sources.huggingface:
3577 schedulers = ['N/A'];
3578 break;
3579 case sources.chutes:
3580 schedulers = ['N/A'];
3581 break;
3582 case sources.electronhub:
3583 schedulers = ['N/A'];
3584 break;
3585 case sources.nanogpt:
3586 schedulers = ['N/A'];
3587 break;
3588 case sources.bfl:
3589 schedulers = ['N/A'];
3590 break;
3591 case sources.falai:
3592 schedulers = ['N/A'];
3593 break;
3594 case sources.xai:
3595 schedulers = ['N/A'];
3596 break;
3597 case sources.google:
3598 schedulers = ['N/A'];
3599 break;
3600 case sources.zai:
3601 schedulers = ['N/A'];
3602 break;
3603 case sources.openrouter:
3604 schedulers = ['N/A'];
3605 break;
3606 case sources.workersai:
3607 schedulers = ['N/A'];
3608 break;
3609 }
3610
3611 for (const scheduler of schedulers) {
3612 const option = document.createElement('option');
3613 option.innerText = scheduler;
3614 option.value = scheduler;
3615 option.selected = scheduler === extension_settings.sd.scheduler;
3616 $('#sd_scheduler').append(option);
3617 }
3618
3619 if (!extension_settings.sd.scheduler && schedulers.length > 0 && schedulers[0] !== 'N/A') {
3620 extension_settings.sd.scheduler = schedulers[0];
3621 $('#sd_scheduler').val(extension_settings.sd.scheduler).trigger('change');
3622 }
3623}
3624
3625async function loadComfySchedulers() {
3626 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3627 return ['N/A'];
3628 }
3629 if (!extension_settings.sd.comfy_url) {
3630 return [];
3631 }
3632
3633 try {
3634 const result = await fetch('/api/sd/comfy/schedulers', {
3635 method: 'POST',
3636 headers: getRequestHeaders(),
3637 body: JSON.stringify({
3638 url: extension_settings.sd.comfy_url,
3639 }),
3640 });
3641 if (!result.ok) {
3642 throw new Error('ComfyUI returned an error.');
3643 }
3644 return await result.json();
3645 } catch (error) {
3646 return [];
3647 }
3648}
3649
3650async function loadSdcppSchedulers() {
3651 // The sdcpp server does not provide an API for schedulers, so we return the known list.
3652 return ['discrete', 'karras', 'exponential', 'ays', 'gits', 'smoothstep', 'sgm_uniform', 'simple', 'kl_optimal', 'lcm'];
3653}
3654
3655async function loadVaes() {
3656 $('#sd_vae').empty();
3657 let vaes = [];
3658
3659 switch (extension_settings.sd.source) {
3660 case sources.extras:
3661 vaes = ['N/A'];
3662 break;
3663 case sources.horde:
3664 vaes = ['N/A'];
3665 break;
3666 case sources.auto:
3667 vaes = await loadAutoVaes();
3668 break;
3669 case sources.sdcpp:
3670 vaes = ['N/A'];
3671 break;
3672 case sources.novel:
3673 vaes = ['N/A'];
3674 break;
3675 case sources.vlad:
3676 vaes = ['N/A'];
3677 break;
3678 case sources.drawthings:
3679 vaes = ['N/A'];
3680 break;
3681 case sources.openai:
3682 vaes = ['N/A'];
3683 break;
3684 case sources.aimlapi:
3685 vaes = ['N/A'];
3686 break;
3687 case sources.togetherai:
3688 vaes = ['N/A'];
3689 break;
3690 case sources.pollinations:
3691 vaes = ['N/A'];
3692 break;
3693 case sources.comfy:
3694 vaes = await loadComfyVaes();
3695 break;
3696 case sources.stability:
3697 vaes = ['N/A'];
3698 break;
3699 case sources.huggingface:
3700 vaes = ['N/A'];
3701 break;
3702 case sources.chutes:
3703 vaes = ['N/A'];
3704 break;
3705 case sources.electronhub:
3706 vaes = ['N/A'];
3707 break;
3708 case sources.nanogpt:
3709 vaes = ['N/A'];
3710 break;
3711 case sources.bfl:
3712 vaes = ['N/A'];
3713 break;
3714 case sources.falai:
3715 vaes = ['N/A'];
3716 break;
3717 case sources.xai:
3718 vaes = ['N/A'];
3719 break;
3720 case sources.google:
3721 vaes = ['N/A'];
3722 break;
3723 case sources.zai:
3724 vaes = ['N/A'];
3725 break;
3726 case sources.openrouter:
3727 vaes = ['N/A'];
3728 break;
3729 case sources.workersai:
3730 vaes = ['N/A'];
3731 break;
3732 }
3733
3734 for (const vae of vaes) {
3735 const option = document.createElement('option');
3736 option.innerText = vae;
3737 option.value = vae;
3738 option.selected = vae === extension_settings.sd.vae;
3739 $('#sd_vae').append(option);
3740 }
3741
3742 if (!extension_settings.sd.vae && vaes.length > 0 && vaes[0] !== 'N/A') {
3743 extension_settings.sd.vae = vaes[0];
3744 $('#sd_vae').val(extension_settings.sd.vae).trigger('change');
3745 }
3746}
3747
3748async function loadAutoVaes() {
3749 if (!extension_settings.sd.auto_url) {
3750 return ['N/A'];
3751 }
3752
3753 try {
3754 const result = await fetch('/api/sd/vaes', {
3755 method: 'POST',
3756 headers: getRequestHeaders(),
3757 body: JSON.stringify(getSdRequestBody()),
3758 });
3759
3760 if (!result.ok) {
3761 throw new Error('SD WebUI returned an error.');
3762 }
3763
3764 const data = await result.json();
3765 Array.isArray(data) && data.unshift(placeholderVae);
3766 return data;
3767 } catch (error) {
3768 return ['N/A'];
3769 }
3770}
3771
3772async function loadComfyVaes() {
3773 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3774 return ['N/A'];
3775 }
3776 if (!extension_settings.sd.comfy_url) {
3777 return [];
3778 }
3779
3780 try {
3781 const result = await fetch('/api/sd/comfy/vaes', {
3782 method: 'POST',
3783 headers: getRequestHeaders(),
3784 body: JSON.stringify({
3785 url: extension_settings.sd.comfy_url,
3786 }),
3787 });
3788 if (!result.ok) {
3789 throw new Error('ComfyUI returned an error.');
3790 }
3791 return await result.json();
3792 } catch (error) {
3793 return [];
3794 }
3795}
3796
3797async function loadComfyWorkflows() {
3798 try {
3799 $('#sd_comfy_workflow').empty();
3800 const result = await fetch('/api/sd/comfy/workflows', {
3801 method: 'POST',
3802 headers: getRequestHeaders(),
3803 body: JSON.stringify({
3804 url: extension_settings.sd.comfy_url,
3805 }),
3806 });
3807 if (!result.ok) {
3808 throw new Error('ComfyUI returned an error.');
3809 }
3810 const workflows = await result.json();
3811 for (const workflow of workflows) {
3812 const option = document.createElement('option');
3813 option.innerText = workflow;
3814 option.value = workflow;
3815 option.selected = workflow === extension_settings.sd.comfy_workflow;
3816 $('#sd_comfy_workflow').append(option);
3817 }
3818 } catch (error) {
3819 console.error(`Could not load ComfyUI workflows: ${error.message}`);
3820 }
3821}
3822
3823function getGenerationType(prompt) {
3824 // Custom wand entries use the trigger convention 'custom_<id>' and bypass
3825 // the multimodal/free_extend transforms applied to the built-in triggers.
3826 const trimmedPrompt = String(prompt).trim();
3827 if (Array.isArray(extension_settings.sd.custom_entries)) {
3828 const customEntry = extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === trimmedPrompt);
3829 if (customEntry) {
3830 return generationMode.CUSTOM;
3831 }
3832 }
3833
3834 let mode = generationMode.FREE;
3835
3836 for (const [key, values] of Object.entries(triggerWords)) {
3837 for (const value of values) {
3838 if (value.toLowerCase() === prompt.toLowerCase().trim()) {
3839 mode = Number(key);
3840 break;
3841 }
3842 }
3843 }
3844
3845 if (extension_settings.sd.multimodal_captioning && multimodalMap[mode] !== undefined) {
3846 mode = multimodalMap[mode];
3847 }
3848
3849 if (mode === generationMode.FREE && extension_settings.sd.free_extend) {
3850 mode = generationMode.FREE_EXTENDED;
3851 }
3852
3853 return mode;
3854}
3855
3856function getQuietPrompt(mode, trigger) {
3857 if (mode === generationMode.CUSTOM) {
3858 const entry = Array.isArray(extension_settings.sd.custom_entries)
3859 ? extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === String(trigger).trim())
3860 : undefined;
3861 return entry ? entry.prompt : trigger;
3862 }
3863
3864 if (mode === generationMode.FREE) {
3865 return trigger;
3866 }
3867
3868 return stringFormat(extension_settings.sd.prompts[mode], trigger);
3869}
3870
3871/**
3872 * Sanitizes generated prompt for image generation.
3873 * @param {string} str String to process
3874 * @returns {string} Processed reply
3875 */
3876function processReply(str) {
3877 if (!str) {
3878 return '';
3879 }
3880
3881 if (extension_settings.sd.minimal_prompt_processing) {
3882 // Minimal prompt processing
3883 // JSON and similar should be preserved
3884 str = str.normalize('NFD');
3885 str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
3886 str = str.trim();
3887 return str;
3888 }
3889
3890 str = str.replaceAll('"', '');
3891 str = str.replaceAll('“', '');
3892 str = str.replaceAll('\n', ', ');
3893 str = str.normalize('NFD');
3894
3895 // Strip out non-alphanumeric characters barring model syntax exceptions
3896 str = str.replace(/[^a-zA-Z0-9.,:_(){}<>[\]/\-'|#]+/g, ' ');
3897
3898 str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
3899 str = str.trim();
3900
3901 str = str
3902 .split(',') // list split by commas
3903 .map(x => x.trim()) // trim each entry
3904 .filter(x => x) // remove empty entries
3905 .join(', '); // join it back with proper spacing
3906
3907 return str;
3908}
3909
3910function getRawLastMessage() {
3911 const getLastUsableMessage = () => {
3912 for (const message of context.chat.slice().reverse()) {
3913 if (message.is_system) {
3914 continue;
3915 }
3916
3917 return {
3918 mes: message.mes,
3919 original_avatar: message.original_avatar,
3920 };
3921 }
3922
3923 toastr.warning('No usable messages found.', 'Image Generation');
3924 throw new Error('No usable messages found.');
3925 };
3926
3927 const context = getContext();
3928 const lastMessage = getLastUsableMessage();
3929 const character = context.groupId
3930 ? context.characters.find(c => c.avatar === lastMessage.original_avatar)
3931 : context.characters[context.characterId];
3932
3933 if (!character) {
3934 console.debug('Character not found, using raw message.');
3935 return processReply(lastMessage.mes);
3936 }
3937
3938 return `((${processReply(lastMessage.mes)})), (${processReply(character.scenario)}:0.7), (${processReply(character.description)}:0.5)`;
3939}
3940
3941/**
3942 * Ensure that the selected option exists in the dropdown.
3943 * @param {string} setting Setting key
3944 * @param {string} selector Dropdown selector
3945 * @returns {void}
3946 */
3947function ensureSelectionExists(setting, selector) {
3948 /** @type {HTMLSelectElement} */
3949 const selectElement = document.querySelector(selector);
3950 if (!selectElement) {
3951 return;
3952 }
3953 const options = Array.from(selectElement.options);
3954 const value = extension_settings.sd[setting];
3955 if (selectElement.selectedOptions.length && !options.some(option => option.value === value)) {
3956 extension_settings.sd[setting] = selectElement.selectedOptions[0].value;
3957 }
3958}
3959
3960/**
3961 * Generates an image based on the given trigger word.
3962 * @param {string} initiator The initiator of the image generation
3963 * @param {Record<string, object>} args Command arguments
3964 * @param {string} trigger Subject trigger word
3965 * @param {string} [message] Chat message
3966 * @param {function} [callback] Callback function
3967 * @returns {Promise<string|undefined>} Image path
3968 * @throws {Error} If the prompt or image generation fails
3969 */
3970async function generatePicture(initiator, args, trigger, message, callback) {
3971 if (!trigger || trigger.trim().length === 0) {
3972 console.log('Trigger word empty, aborting');
3973 return;
3974 }
3975
3976 if (!isValidState()) {
3977 toastr.warning('Image generation is not available. Check your settings and try again.');
3978 return;
3979 }
3980
3981 ensureSelectionExists('sampler', '#sd_sampler');
3982 ensureSelectionExists('model', '#sd_model');
3983
3984 trigger = trigger.trim();
3985 const generationType = getGenerationType(trigger);
3986 const generationTypeKey = Object.keys(generationMode).find(key => generationMode[key] === generationType);
3987 console.log(`Image generation mode ${generationTypeKey} triggered with "${trigger}"`);
3988
3989 const quietPrompt = getQuietPrompt(generationType, trigger);
3990 const context = getContext();
3991
3992 let characterName = context.groupId
3993 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
3994 : context.characters[context.characterId]?.name;
3995
3996 if (generationType === generationMode.BACKGROUND) {
3997 const callbackOriginal = callback;
3998 callback = async function (prompt, imagePath, generationType, _negativePromptPrefix, _initiator, prefixedPrompt, format) {
3999 const imgUrl = `url("${encodeURI(imagePath)}")`;
4000 await eventSource.emit(event_types.FORCE_SET_BACKGROUND, { url: imgUrl, path: imagePath });
4001
4002 if (typeof callbackOriginal === 'function') {
4003 await callbackOriginal(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format);
4004 } else {
4005 await sendMessage(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format);
4006 }
4007 };
4008 }
4009
4010 if (isTrueBoolean(args?.quiet)) {
4011 callback = () => { };
4012 }
4013
4014 if (isFalseBoolean(args?.gallery)) {
4015 characterName = '';
4016 }
4017
4018 const dimensions = setTypeSpecificDimensions(generationType);
4019 const abortController = new AbortController();
4020 let negativePromptPrefix = args?.negative || '';
4021 let imagePath = '';
4022
4023 const stopListener = () => abortController.abort('Aborted by user');
4024
4025 let loaderHandle = ActionLoaderHandle.EMPTY;
4026
4027 try {
4028 const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); };
4029
4030 // Each new generation picks its own reference image (swipes reuse the last one).
4031 pendingReferenceImage = null;
4032
4033 // generate the text prompt for the image
4034 let prompt = await getPrompt(generationType, message, trigger, quietPrompt, combineNegatives);
4035 console.log('Processed image prompt:', prompt);
4036
4037 // Extension hook for prompt processing
4038 const eventData = { prompt, generationType, message, trigger };
4039 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
4040 prompt = eventData.prompt; // Allow extensions to modify the prompt
4041
4042 if (typeof args?._abortController?.addEventListener === 'function') {
4043 args._abortController.addEventListener('abort', stopListener);
4044 }
4045
4046 // Show non-blocking stoppable toast for this generation
4047 loaderHandle = loader.show({
4048 blocking: false,
4049 slug: `${MODULE_NAME}-image-generation`,
4050 title: t`Image Generation`,
4051 message: t`Generating an image...`,
4052 onStop: stopListener,
4053 });
4054
4055 // generate the image
4056 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
4057 } catch (err) {
4058 // Check if this was an intentional abort by user
4059 if (abortController.signal.aborted) {
4060 console.log('SD: Image generation aborted by user');
4061 toastr.info('Image generation stopped.', 'Image Generation');
4062 return;
4063 }
4064
4065 console.trace(err);
4066 // errors here are most likely due to text generation failure
4067 // sendGenerationRequest mostly deals with its own errors
4068 const reason = err.error?.message || err.message || 'Unknown error';
4069 const errorText = 'SD prompt text generation failed. ' + reason;
4070 toastr.error(errorText, 'Image Generation');
4071 throw new Error(errorText);
4072 } finally {
4073 restoreOriginalDimensions(dimensions);
4074 await loaderHandle.hide();
4075 }
4076
4077 return imagePath;
4078}
4079
4080/**
4081 * Adjusts image generation dimensions based on the generation type and/or previous media attachment.
4082 * @param {number} generationType The type of image generation to perform, used to determine dimension adjustments
4083 * @param {MediaAttachment} [mediaAttachment] Media attachment to base dimension adjustments on
4084 * @returns {{height: number, width: number}} Previous dimensions before modification
4085 */
4086function setTypeSpecificDimensions(generationType, mediaAttachment = null) {
4087 const prevSDHeight = extension_settings.sd.height;
4088 const prevSDWidth = extension_settings.sd.width;
4089 const aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
4090
4091 // 1. If there's a media attachment, match its previous dimensions
4092 // 2. Face images are always portrait (pun intended) - increase height if needed
4093 // 3. Background images are always landscape - increase width if needed
4094 if (Number.isInteger(mediaAttachment?.width) && Number.isInteger(mediaAttachment?.height)) {
4095 extension_settings.sd.width = mediaAttachment.width;
4096 extension_settings.sd.height = mediaAttachment.height;
4097 } else if ((generationType === generationMode.FACE || generationType === generationMode.FACE_MULTIMODAL) && aspectRatio >= 1) {
4098 // Round to nearest multiple of 64
4099 extension_settings.sd.height = Math.round(extension_settings.sd.width * 1.5 / 64) * 64;
4100 } else if (generationType === generationMode.BACKGROUND && aspectRatio <= 1) {
4101 // Round to nearest multiple of 64
4102 extension_settings.sd.width = Math.round(extension_settings.sd.height * 1.8 / 64) * 64;
4103 }
4104
4105 if (extension_settings.sd.snap) {
4106 // Force to use roughly the same pixel count as before rescaling
4107 const prevPixelCount = prevSDHeight * prevSDWidth;
4108 const newPixelCount = extension_settings.sd.height * extension_settings.sd.width;
4109
4110 if (prevPixelCount !== newPixelCount) {
4111 const ratio = Math.sqrt(prevPixelCount / newPixelCount);
4112 extension_settings.sd.height = Math.round(extension_settings.sd.height * ratio / 64) * 64;
4113 extension_settings.sd.width = Math.round(extension_settings.sd.width * ratio / 64) * 64;
4114 console.log(`Pixel counts after rescaling: ${prevPixelCount} -> ${newPixelCount} (ratio: ${ratio})`);
4115
4116 const resolution = resolutionOptions[getClosestKnownResolution()];
4117 if (resolution) {
4118 extension_settings.sd.height = resolution.height;
4119 extension_settings.sd.width = resolution.width;
4120 console.log('Snap to resolution', JSON.stringify(resolution));
4121 } else {
4122 console.warn('Snap to resolution failed, using custom dimensions');
4123 }
4124 }
4125 }
4126
4127 return { height: prevSDHeight, width: prevSDWidth };
4128}
4129
4130/**
4131 * Restores the original image generation dimensions after generation is complete.
4132 * @param {{height: number, width: number}} savedParams The original dimensions to restore
4133 */
4134function restoreOriginalDimensions(savedParams) {
4135 extension_settings.sd.height = savedParams.height;
4136 extension_settings.sd.width = savedParams.width;
4137}
4138
4139/**
4140 * Generates a prompt for image generation.
4141 * @param {number} generationType The type of image generation to perform.
4142 * @param {string} message A message text to use for the image generation.
4143 * @param {string} trigger A trigger string to use for the image generation.
4144 * @param {string} quietPrompt A quiet prompt to use for the image generation.
4145 * @param {function} combineNegatives A function that combines the negative prompt with other prompts.
4146 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
4147 */
4148async function getPrompt(generationType, message, trigger, quietPrompt, combineNegatives) {
4149 let prompt;
4150 console.log('getPrompt: Generation mode', generationType, 'triggered with', trigger);
4151 switch (generationType) {
4152 case generationMode.RAW_LAST:
4153 prompt = message || getRawLastMessage();
4154 break;
4155 case generationMode.FREE:
4156 prompt = generateFreeModePrompt(trigger.trim(), combineNegatives);
4157 break;
4158 case generationMode.FACE_MULTIMODAL:
4159 case generationMode.CHARACTER_MULTIMODAL:
4160 case generationMode.USER_MULTIMODAL:
4161 prompt = await generateMultimodalPrompt(generationType, quietPrompt);
4162 break;
4163 default:
4164 prompt = await generatePrompt(quietPrompt);
4165 break;
4166 }
4167
4168 if (generationType === generationMode.FREE_EXTENDED) {
4169 prompt = generateFreeModePrompt(prompt.trim(), combineNegatives);
4170 }
4171
4172 if (generationType !== generationMode.FREE) {
4173 prompt = await refinePrompt(prompt);
4174 }
4175
4176 return prompt;
4177}
4178
4179/**
4180 * Generates a free prompt with a character-specific prompt prefix support.
4181 * @param {string} trigger - The prompt to use for the image generation.
4182 * @param {function} combineNegatives - A function that combines the negative prompt with other prompts.
4183 * @returns {string}
4184 */
4185function generateFreeModePrompt(trigger, combineNegatives) {
4186 return trigger
4187 .replace(/^char(\s|,)|{{charPrefix}}/gi, (_, suffix) => {
4188 const getLastCharacterKey = () => {
4189 if (typeof this_chid !== 'undefined') {
4190 return getCharaFilename(this_chid);
4191 }
4192 const context = getContext();
4193 for (let i = context.chat.length - 1; i >= 0; i--) {
4194 const message = context.chat[i];
4195 if (!message.is_user && !message.is_system && typeof message.original_avatar === 'string') {
4196 return message.original_avatar.replace(/\.[^/.]+$/, '');
4197 }
4198 }
4199 return '';
4200 };
4201
4202 const key = getLastCharacterKey();
4203 const value = (extension_settings.sd.character_prompts[key] || '').trim();
4204 const negativeValue = (extension_settings.sd.character_negative_prompts[key] || '').trim();
4205 typeof combineNegatives === 'function' && negativeValue ? combineNegatives(negativeValue) : void 0;
4206 return value ? combinePrefixes(value, (suffix || '')) : '';
4207 });
4208}
4209
4210/**
4211 * Generates a prompt using multimodal captioning.
4212 * @param {number} generationType - The type of image generation to perform.
4213 * @param {string} quietPrompt - The prompt to use for the image generation.
4214 */
4215async function generateMultimodalPrompt(generationType, quietPrompt) {
4216 let avatarUrl;
4217
4218 if (generationType === generationMode.USER_MULTIMODAL) {
4219 avatarUrl = getUserAvatarUrl();
4220 }
4221
4222 if (generationType === generationMode.CHARACTER_MULTIMODAL || generationType === generationMode.FACE_MULTIMODAL) {
4223 avatarUrl = getCharacterAvatarUrl();
4224 }
4225
4226 try {
4227 const toast = toastr.info('Generating multimodal caption...', 'Image Generation');
4228 const response = await fetch(avatarUrl);
4229
4230 if (!response.ok) {
4231 throw new Error('Could not fetch avatar image.');
4232 }
4233
4234 const avatarBlob = await response.blob();
4235 const avatarBase64 = await getBase64Async(avatarBlob);
4236
4237 const caption = await getMultimodalCaption(avatarBase64, quietPrompt);
4238 toastr.clear(toast);
4239
4240 if (!caption) {
4241 throw new Error('No caption returned from the API.');
4242 }
4243
4244 return caption;
4245 } catch (error) {
4246 console.error(error);
4247 toastr.error('Multimodal captioning failed. Please try again.', 'Image Generation');
4248 throw new Error('Multimodal captioning failed.');
4249 }
4250}
4251
4252function getCharacterAvatarUrl() {
4253 const context = getContext();
4254
4255 if (context.groupId) {
4256 const groupMembers = context.groups.find(x => x.id === context.groupId)?.members;
4257 const lastMessageAvatar = context.chat?.filter(x => !x.is_system && !x.is_user)?.slice(-1)[0]?.original_avatar;
4258 const randomMemberAvatar = Array.isArray(groupMembers) ? groupMembers[Math.floor(Math.random() * groupMembers.length)] : null;
4259 const avatarToUse = lastMessageAvatar || randomMemberAvatar;
4260 return formatCharacterAvatar(avatarToUse);
4261 } else {
4262 return getCharacterAvatar(context.characterId);
4263 }
4264}
4265
4266function getUserAvatarUrl() {
4267 return getUserAvatar(user_avatar);
4268}
4269
4270/**
4271 * Generates a prompt using the main LLM API.
4272 * @param {string} quietPrompt - The prompt to use for the image generation.
4273 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
4274 */
4275async function generatePrompt(quietPrompt) {
4276 const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
4277 const profileId = extension_settings.sd.prompt_generation_profile;
4278 let reply;
4279
4280 // When the workflow uses a reference image and there is more than one to choose
4281 // from, have the same LLM request return the selection along with the prompt.
4282 const refCandidates = await getEligibleReferenceImages();
4283 if (refCandidates.length === 1) {
4284 pendingReferenceImage = refCandidates[0];
4285 }
4286 const combineReferenceSelection = refCandidates.length > 1;
4287 const effectiveQuietPrompt = combineReferenceSelection
4288 ? quietPrompt + '\n' + buildReferenceSelectionAddendum(refCandidates)
4289 : quietPrompt;
4290
4291 try {
4292 reply = profileId
4293 ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt: effectiveQuietPrompt }))
4294 : await generateQuietPrompt({ quietPrompt: effectiveQuietPrompt });
4295 } finally {
4296 toastr.clear(toast);
4297 }
4298
4299 if (combineReferenceSelection) {
4300 const { cleaned, selected } = extractReferenceSelection(String(reply ?? ''), refCandidates);
4301 reply = cleaned;
4302 // No/invalid selection -> leave unset; the workflow builder retries with a dedicated call.
4303 pendingReferenceImage = selected;
4304 console.log('SD: reference image selected with the image prompt:', selected?.tag ?? '(none)');
4305 }
4306
4307 const processedReply = processReply(reply);
4308
4309 if (!processedReply) {
4310 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');
4311 throw new Error('Prompt generation failed.');
4312 }
4313
4314 return processedReply;
4315}
4316
4317/**
4318 * Runs a callback with a specific Connection Manager profile temporarily active,
4319 * then restores the previously active profile. This lets the image prompt be
4320 * generated by the chosen LLM *with the full chat context* (via generateQuietPrompt),
4321 * instead of a context-free one-off request.
4322 *
4323 * The switch is only performed when a real connection profile is currently active
4324 * (so it can be reliably restored). When no profile is active — i.e. the user drives
4325 * the API panel manually — switching to a profile could not be undone without
4326 * clobbering those manual settings, so we leave the active model in place and warn once.
4327 *
4328 * @param {string} targetProfileId Profile to activate for the duration of the callback.
4329 * @param {() => Promise<any>} callback Work to run while the target profile is active.
4330 * @returns {Promise<any>} The callback's result.
4331 */
4332async function withConnectionProfile(targetProfileId, callback) {
4333 const select = /** @type {HTMLSelectElement} */ (document.getElementById('connection_profiles'));
4334 const connectionManager = extension_settings.connectionManager;
4335 const currentProfileId = connectionManager?.selectedProfile;
4336
4337 const canSwitch = !!select
4338 && !!connectionManager
4339 && Array.isArray(connectionManager.profiles)
4340 && connectionManager.profiles.some(p => p.id === targetProfileId)
4341 && Array.from(select.options).some(o => o.value === targetProfileId)
4342 && !!currentProfileId // a real profile is active, so it can be restored afterwards
4343 && currentProfileId !== targetProfileId;
4344
4345 if (!canSwitch) {
4346 // Selected but no base profile to restore from -> use the active model and warn once.
4347 if (targetProfileId && !currentProfileId && !promptProfileWarnedNoBaseProfile) {
4348 promptProfileWarnedNoBaseProfile = true;
4349 toastr.info('The image-prompt LLM profile is only applied while a connection profile is active (so the original can be restored). Using the current model.', 'Image Generation');
4350 }
4351 return await callback();
4352 }
4353
4354 const switchToProfile = async (profileId) => {
4355 const loaded = new Promise(resolve => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
4356 const index = Array.from(select.options).findIndex(o => o.value === profileId);
4357 select.selectedIndex = index >= 0 ? index : 0;
4358 select.dispatchEvent(new Event('change'));
4359 // Wait for the profile's commands to finish applying (don't hang forever if the event never fires).
4360 await Promise.race([loaded, delay(10000)]);
4361 // Applying a profile reconnects the API, which is asynchronous. Generating before the
4362 // connection is re-established fails instantly, so wait for it to come back up
4363 // (mirrors the built-in /profile command). rejectOnTimeout:false -> proceed anyway after the timeout.
4364 await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100, { rejectOnTimeout: false });
4365 };
4366
4367 await switchToProfile(targetProfileId);
4368 try {
4369 return await callback();
4370 } finally {
4371 try {
4372 await switchToProfile(currentProfileId);
4373 } catch (err) {
4374 console.error('SD: failed to restore the previous connection profile after image-prompt generation', err);
4375 }
4376 }
4377}
4378
4379/**
4380 * Sends a request to image generation endpoint and processes the result.
4381 * @param {number} generationType Type of image generation
4382 * @param {string} prompt Prompt to be used for image generation
4383 * @param {string} additionalNegativePrefix Additional negative prompt to be used for image generation
4384 * @param {string} characterName Name of the character
4385 * @param {function} callback Callback function to be called after image generation
4386 * @param {string} initiator The initiator of the image generation
4387 * @param {AbortSignal} signal Abort signal to cancel the request
4388 * @returns
4389 */
4390async function sendGenerationRequest(generationType, prompt, additionalNegativePrefix, characterName, callback, initiator, signal) {
4391 const noCharPrefix = [generationMode.FREE, generationMode.BACKGROUND, generationMode.USER, generationMode.USER_MULTIMODAL, generationMode.FREE_EXTENDED];
4392 const isCharChat = this_chid !== undefined && !selected_group;
4393 const ignoreNoCharForSwipe = initiator === initiators.swipe && isCharChat;
4394
4395 const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType);
4396
4397 /**
4398 * Performs a single image generation attempt against the live extension_settings.sd config.
4399 * Reads settings live so it can be retried after applying a fallback preset.
4400 * @param {AbortSignal} attemptSignal Abort signal to cancel the request.
4401 * @returns {Promise<{result: {format: string, data: string}, prefixedPrompt: string}>}
4402 * @throws {Error} On failure or when the endpoint returns no image data.
4403 */
4404 async function attemptImageGeneration(attemptSignal) {
4405 const prefix = skipCharPrefix
4406 ? extension_settings.sd.prompt_prefix
4407 : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix());
4408
4409 const negativePrefix = skipCharPrefix
4410 ? extension_settings.sd.negative_prompt
4411 : combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix());
4412
4413 const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}'));
4414 const negativePrompt = substituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix));
4415
4416 let result = { format: '', data: '' };
4417 switch (extension_settings.sd.source) {
4418 case sources.extras:
4419 result = await generateExtrasImage(prefixedPrompt, negativePrompt, attemptSignal);
4420 break;
4421 case sources.horde:
4422 result = await generateHordeImage(prefixedPrompt, negativePrompt, attemptSignal);
4423 break;
4424 case sources.vlad:
4425 result = await generateAutoImage(prefixedPrompt, negativePrompt, attemptSignal);
4426 break;
4427 case sources.drawthings:
4428 result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, attemptSignal);
4429 break;
4430 case sources.auto:
4431 result = await generateAutoImage(prefixedPrompt, negativePrompt, attemptSignal);
4432 break;
4433 case sources.sdcpp:
4434 result = await generateSdcppImage(prefixedPrompt, negativePrompt, attemptSignal);
4435 break;
4436 case sources.novel:
4437 result = await generateNovelImage(prefixedPrompt, negativePrompt, attemptSignal);
4438 break;
4439 case sources.openai:
4440 result = await generateOpenAiImage(prefixedPrompt, attemptSignal);
4441 break;
4442 case sources.aimlapi:
4443 result = await generateAimlapiImage(prefixedPrompt, attemptSignal);
4444 break;
4445 case sources.comfy:
4446 switch (extension_settings.sd.comfy_type) {
4447 case comfyTypes.runpod_serverless:
4448 result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, attemptSignal);
4449 break;
4450 case comfyTypes.standard:
4451 result = await generateComfyImage(prefixedPrompt, negativePrompt, attemptSignal);
4452 break;
4453 default:
4454 throw new Error('Unknown comfyUI server type.');
4455 }
4456 break;
4457 case sources.togetherai:
4458 result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, attemptSignal);
4459 break;
4460 case sources.pollinations:
4461 result = await generatePollinationsImage(prefixedPrompt, negativePrompt, attemptSignal);
4462 break;
4463 case sources.stability:
4464 result = await generateStabilityImage(prefixedPrompt, negativePrompt, attemptSignal);
4465 break;
4466 case sources.huggingface:
4467 result = await generateHuggingFaceImage(prefixedPrompt, attemptSignal);
4468 break;
4469 case sources.chutes:
4470 result = await generateChutesImage(prefixedPrompt, negativePrompt, attemptSignal);
4471 break;
4472 case sources.electronhub:
4473 result = await generateElectronHubImage(prefixedPrompt, attemptSignal);
4474 break;
4475 case sources.nanogpt:
4476 result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, attemptSignal);
4477 break;
4478 case sources.bfl:
4479 result = await generateBflImage(prefixedPrompt, attemptSignal);
4480 break;
4481 case sources.falai:
4482 result = await generateFalaiImage(prefixedPrompt, negativePrompt, attemptSignal);
4483 break;
4484 case sources.xai:
4485 result = await generateXAIImage(prefixedPrompt, negativePrompt, attemptSignal);
4486 break;
4487 case sources.google:
4488 result = await generateGoogleImage(prefixedPrompt, negativePrompt, attemptSignal);
4489 break;
4490 case sources.zai:
4491 result = await generateZaiImage(prefixedPrompt, attemptSignal);
4492 break;
4493 case sources.openrouter:
4494 result = await generateOpenRouterImage(prefixedPrompt, attemptSignal);
4495 break;
4496 case sources.workersai:
4497 result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, attemptSignal);
4498 break;
4499 }
4500
4501 if (!result.data) {
4502 throw new Error('Endpoint did not return image data.');
4503 }
4504
4505 return { result, prefixedPrompt };
4506 }
4507
4508 const currentChatId = getCurrentChatId();
4509 const fallbackChain = extension_settings.sd.settings_fallback_enabled ? getConfiguredPresetChain() : [];
4510 let genOutput;
4511
4512 if (fallbackChain.length > 0) {
4513 // Chain mode: try every preset in order. Locally-hosted backends are probed
4514 // first so a powered-off server is skipped after ~1.5s instead of stalling
4515 // the attempt. The live settings are restored afterward either way.
4516 const restore = snapshotSdSettings();
4517 let lastError = new Error('No provider in the fallback chain was reachable.');
4518 try {
4519 for (const entry of fallbackChain) {
4520 applySdSettingsSnapshot(entry.preset);
4521
4522 if (!(await isCurrentSourceReachable())) {
4523 console.warn(`SD: chain entry "${entry.name}" is not reachable, skipping`);
4524 toastr.warning(`Provider "${entry.name}" is not reachable, trying the next one…`, 'Image Generation');
4525 continue;
4526 }
4527
4528 try {
4529 genOutput = await attemptImageGeneration(signal);
4530 break;
4531 } catch (err) {
4532 if (signal?.aborted) {
4533 console.log('SD: Image generation aborted by user');
4534 toastr.info('Image generation stopped.', 'Image Generation');
4535 return;
4536 }
4537 lastError = err;
4538 console.error(`SD: generation with chain entry "${entry.name}" failed`, err);
4539 toastr.warning(`Provider "${entry.name}" failed, trying the next one…`, 'Image Generation');
4540 }
4541 }
4542 } finally {
4543 applySdSettingsSnapshot(restore);
4544 }
4545
4546 if (!genOutput) {
4547 toastr.error('Image generation failed for every provider in the fallback chain.' + '\n\n' + String(lastError), 'Image Generation');
4548 return;
4549 }
4550 } else {
4551 try {
4552 genOutput = await attemptImageGeneration(signal);
4553 } catch (err) {
4554 // Check if this was an intentional abort by user
4555 if (signal?.aborted) {
4556 console.log('SD: Image generation aborted by user');
4557 toastr.info('Image generation stopped.', 'Image Generation');
4558 return;
4559 }
4560
4561 console.error('Image generation request error: ', err);
4562 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
4563 return;
4564 }
4565 }
4566
4567 const { result, prefixedPrompt } = genOutput;
4568
4569 if (currentChatId !== getCurrentChatId()) {
4570 console.warn('Chat changed, aborting SD result saving');
4571 toastr.warning('Chat changed, generated image discarded.', 'Image Generation');
4572 return;
4573 }
4574
4575 const filename = characterName ? `${characterName}_${humanizedDateTime()}` : humanizedDateTime();
4576 const base64Image = await saveBase64AsFile(result.data, characterName, filename, result.format);
4577 callback
4578 ? await callback(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format)
4579 : await sendMessage(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format);
4580 return base64Image;
4581}
4582
4583/**
4584 * Generates an image using the TogetherAI API.
4585 * @param {string} prompt - The main instruction used to guide the image generation.
4586 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4587 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4588 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4589 */
4590async function generateTogetherAIImage(prompt, negativePrompt, signal) {
4591 const result = await fetch('/api/sd/together/generate', {
4592 method: 'POST',
4593 headers: getRequestHeaders(),
4594 signal: signal,
4595 body: JSON.stringify({
4596 prompt: prompt,
4597 negative_prompt: negativePrompt,
4598 model: extension_settings.sd.model,
4599 steps: extension_settings.sd.steps,
4600 width: extension_settings.sd.width,
4601 height: extension_settings.sd.height,
4602 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4603 }),
4604 });
4605
4606 if (result.ok) {
4607 return await result.json();
4608 } else {
4609 const text = await result.text();
4610 throw new Error(text);
4611 }
4612}
4613
4614/**
4615 * Generates an image using the Pollinations API.
4616 * @param {string} prompt - The main instruction used to guide the image generation.
4617 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4618 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4619 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4620 */
4621async function generatePollinationsImage(prompt, negativePrompt, signal) {
4622 const result = await fetch('/api/sd/pollinations/generate', {
4623 method: 'POST',
4624 headers: getRequestHeaders(),
4625 signal: signal,
4626 body: JSON.stringify({
4627 prompt: prompt,
4628 negative_prompt: negativePrompt,
4629 model: extension_settings.sd.model,
4630 width: extension_settings.sd.width,
4631 height: extension_settings.sd.height,
4632 enhance: extension_settings.sd.pollinations_enhance,
4633 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4634 }),
4635 });
4636
4637 if (result.ok) {
4638 const data = await result.json();
4639 return { format: data?.format, data: data?.image };
4640 } else {
4641 const text = await result.text();
4642 throw new Error(text);
4643 }
4644}
4645
4646/**
4647 * Generates an "extras" image using a provided prompt and other settings.
4648 *
4649 * @param {string} prompt - The main instruction used to guide the image generation.
4650 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4651 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4652 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4653 */
4654async function generateExtrasImage(prompt, negativePrompt, signal) {
4655 const url = new URL(getApiUrl());
4656 url.pathname = '/api/image';
4657 const result = await doExtrasFetch(url, {
4658 method: 'POST',
4659 headers: {
4660 'Content-Type': 'application/json',
4661 },
4662 signal: signal,
4663 body: JSON.stringify({
4664 prompt: prompt,
4665 sampler: extension_settings.sd.sampler,
4666 steps: extension_settings.sd.steps,
4667 scale: extension_settings.sd.scale,
4668 width: extension_settings.sd.width,
4669 height: extension_settings.sd.height,
4670 negative_prompt: negativePrompt,
4671 restore_faces: !!extension_settings.sd.restore_faces,
4672 enable_hr: !!extension_settings.sd.enable_hr,
4673 karras: !!extension_settings.sd.horde_karras,
4674 hr_upscaler: extension_settings.sd.hr_upscaler,
4675 hr_scale: extension_settings.sd.hr_scale,
4676 denoising_strength: extension_settings.sd.denoising_strength,
4677 hr_second_pass_steps: extension_settings.sd.hr_second_pass_steps,
4678 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4679 }),
4680 });
4681
4682 if (result.ok) {
4683 const data = await result.json();
4684 return { format: 'jpg', data: data.image };
4685 } else {
4686 const text = await result.text();
4687 throw new Error(text);
4688 }
4689}
4690
4691/**
4692 * Gets an aspect ratio for Stability that is the closest to the given width and height.
4693 * @param {number} width Target width
4694 * @param {number} height Target height
4695 * @param {'google'|'stability'|'zai'|'xai'} source Source of the request, used to determine aspect ratio
4696 * @returns {string} Closest aspect ratio as a string
4697 */
4698function getClosestAspectRatio(width, height, source) {
4699 function getAspectRatios() {
4700 switch (source) {
4701 case 'stability':
4702 return {
4703 '16:9': 16 / 9,
4704 '1:1': 1,
4705 '21:9': 21 / 9,
4706 '2:3': 2 / 3,
4707 '3:2': 3 / 2,
4708 '4:5': 4 / 5,
4709 '5:4': 5 / 4,
4710 '9:16': 9 / 16,
4711 '9:21': 9 / 21,
4712 };
4713 case 'google':
4714 return {
4715 '1:1': 1,
4716 '16:9': 16 / 9,
4717 '9:16': 9 / 16,
4718 '4:3': 4 / 3,
4719 '3:4': 3 / 4,
4720 };
4721 case 'zai':
4722 return {
4723 '1:1': 1,
4724 '16:9': 16 / 9,
4725 '9:16': 9 / 16,
4726 };
4727 case 'xai':
4728 return {
4729 '1:1': 1,
4730 '3:4': 3 / 4,
4731 '4:3': 4 / 3,
4732 '9:16': 9 / 16,
4733 '16:9': 16 / 9,
4734 '2:3': 2 / 3,
4735 '3:2': 3 / 2,
4736 '9:19.5': 9 / 19.5,
4737 '19.5:9': 19.5 / 9,
4738 '9:20': 9 / 20,
4739 '20:9': 20 / 9,
4740 '1:2': 1 / 2,
4741 '2:1': 2 / 1,
4742 };
4743 default:
4744 console.warn(`Unknown source "${source}" for aspect ratio calculation.`);
4745 return null;
4746 }
4747 }
4748
4749 const aspectRatios = getAspectRatios() || { '1:1': 1 };
4750
4751 const aspectRatio = width / height;
4752
4753 let closestAspectRatio = Object.keys(aspectRatios)[0];
4754 let minDiff = Math.abs(aspectRatio - aspectRatios[closestAspectRatio]);
4755
4756 for (const key in aspectRatios) {
4757 const diff = Math.abs(aspectRatio - aspectRatios[key]);
4758 if (diff < minDiff) {
4759 minDiff = diff;
4760 closestAspectRatio = key;
4761 }
4762 }
4763
4764 return closestAspectRatio;
4765}
4766
4767/**
4768 * Get closest size for Electron Hub
4769 * @param {number} width - The width of the image
4770 * @param {number} height - The height of the image
4771 * @param {string[]} sizes - Available sizes
4772 * @returns {Promise<string>} - The closest size
4773 */
4774async function getClosestSize(width, height, sizes = []) {
4775 const sizesData = [];
4776
4777 if (Array.isArray(sizes) && sizes.length > 0) {
4778 sizesData.push(...sizes);
4779 } else if (extension_settings.sd.source === sources.electronhub) {
4780 const response = await fetch('/api/sd/electronhub/sizes', {
4781 method: 'POST',
4782 headers: getRequestHeaders(),
4783 body: JSON.stringify({
4784 model: extension_settings.sd.model,
4785 }),
4786 });
4787 if (!response.ok) {
4788 const text = await response.text();
4789 throw new Error(text);
4790 }
4791 const result = await response.json();
4792 sizesData.push(...result.sizes);
4793 } else {
4794 return null;
4795 }
4796
4797 const targetWidth = Number(width);
4798 const targetHeight = Number(height);
4799
4800 if (isNaN(targetWidth) || isNaN(targetHeight)) {
4801 return null;
4802 }
4803
4804 const targetAspect = targetWidth / targetHeight;
4805 const targetResolution = targetWidth * targetHeight;
4806
4807 const closestSize = sizesData.reduce((closest, size) => {
4808 if (!size || typeof size !== 'string') {
4809 return closest;
4810 }
4811 const sizeParts = size.split('x');
4812 if (sizeParts.length !== 2) {
4813 return closest;
4814 }
4815
4816 const sizeWidth = Number(sizeParts[0]);
4817 const sizeHeight = Number(sizeParts[1]);
4818
4819 if (isNaN(sizeWidth) || isNaN(sizeHeight)) {
4820 return closest;
4821 }
4822
4823 const aspectDiff = Math.abs((sizeWidth / sizeHeight) - targetAspect) / targetAspect;
4824 const resolutionDiff = Math.abs(sizeWidth * sizeHeight - targetResolution) / targetResolution;
4825 const diff = aspectDiff + resolutionDiff;
4826
4827 return diff < closest.diff ? { size, diff } : closest;
4828 }, { size: null, diff: Infinity });
4829
4830 const size = closestSize.size;
4831 return size;
4832}
4833
4834/**
4835 * Generates an image using Stability AI.
4836 * @param {string} prompt - The main instruction used to guide the image generation.
4837 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4838 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4839 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4840 */
4841async function generateStabilityImage(prompt, negativePrompt, signal) {
4842 const IMAGE_FORMAT = 'png';
4843 const PROMPT_LIMIT = 10000;
4844
4845 try {
4846 const response = await fetch('/api/sd/stability/generate', {
4847 method: 'POST',
4848 headers: getRequestHeaders(),
4849 signal: signal,
4850 body: JSON.stringify({
4851 model: extension_settings.sd.model,
4852 payload: {
4853 prompt: prompt.slice(0, PROMPT_LIMIT),
4854 negative_prompt: negativePrompt.slice(0, PROMPT_LIMIT),
4855 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'stability'),
4856 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4857 style_preset: extension_settings.sd.stability_style_preset,
4858 output_format: IMAGE_FORMAT,
4859 },
4860 }),
4861 });
4862
4863 if (!response.ok) {
4864 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4865 }
4866
4867 const base64Image = await response.text();
4868
4869 return {
4870 format: IMAGE_FORMAT,
4871 data: base64Image,
4872 };
4873 } catch (error) {
4874 console.error('Error generating image with Stability AI:', error);
4875 throw error;
4876 }
4877}
4878
4879/**
4880 * Generates a "horde" image using the provided prompt and configuration settings.
4881 *
4882 * @param {string} prompt - The main instruction used to guide the image generation.
4883 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4884 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4885 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4886 */
4887async function generateHordeImage(prompt, negativePrompt, signal) {
4888 const result = await fetch('/api/horde/generate-image', {
4889 method: 'POST',
4890 headers: getRequestHeaders(),
4891 signal: signal,
4892 body: JSON.stringify({
4893 prompt: prompt,
4894 sampler: extension_settings.sd.sampler,
4895 steps: extension_settings.sd.steps,
4896 scale: extension_settings.sd.scale,
4897 width: extension_settings.sd.width,
4898 height: extension_settings.sd.height,
4899 negative_prompt: negativePrompt,
4900 model: extension_settings.sd.model,
4901 nsfw: extension_settings.sd.horde_nsfw,
4902 restore_faces: !!extension_settings.sd.restore_faces,
4903 enable_hr: !!extension_settings.sd.enable_hr,
4904 sanitize: !!extension_settings.sd.horde_sanitize,
4905 clip_skip: extension_settings.sd.clip_skip,
4906 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4907 }),
4908 });
4909
4910 if (result.ok) {
4911 const data = await result.text();
4912 return { format: 'webp', data: data };
4913 } else {
4914 const text = await result.text();
4915 throw new Error(text);
4916 }
4917}
4918
4919/**
4920 * Generates an image in SD WebUI API using the provided prompt and configuration settings.
4921 *
4922 * @param {string} prompt - The main instruction used to guide the image generation.
4923 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4924 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
4925 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
4926 */
4927async function generateAutoImage(prompt, negativePrompt, signal) {
4928 const isValidVae = extension_settings.sd.vae && !['N/A', placeholderVae].includes(extension_settings.sd.vae);
4929 let payload = {
4930 ...getSdRequestBody(),
4931 prompt: prompt,
4932 negative_prompt: negativePrompt,
4933 sampler_name: extension_settings.sd.sampler,
4934 scheduler: extension_settings.sd.scheduler,
4935 steps: extension_settings.sd.steps,
4936 cfg_scale: extension_settings.sd.scale,
4937 width: extension_settings.sd.width,
4938 height: extension_settings.sd.height,
4939 restore_faces: !!extension_settings.sd.restore_faces,
4940 enable_hr: !!extension_settings.sd.enable_hr,
4941 hr_upscaler: extension_settings.sd.hr_upscaler,
4942 hr_scale: extension_settings.sd.hr_scale,
4943 hr_additional_modules: [],
4944 denoising_strength: extension_settings.sd.denoising_strength,
4945 hr_second_pass_steps: extension_settings.sd.hr_second_pass_steps,
4946 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4947 override_settings: {
4948 CLIP_stop_at_last_layers: extension_settings.sd.clip_skip,
4949 sd_vae: isValidVae ? extension_settings.sd.vae : undefined,
4950 forge_additional_modules: isValidVae ? [extension_settings.sd.vae] : undefined, // For SD Forge
4951 },
4952 override_settings_restore_afterwards: true,
4953 clip_skip: extension_settings.sd.clip_skip, // For SD.Next
4954 save_images: true,
4955 send_images: true,
4956 do_not_save_grid: false,
4957 do_not_save_samples: false,
4958 };
4959
4960 // Conditionally add the ADetailer if adetailer_face is enabled
4961 if (extension_settings.sd.adetailer_face) {
4962 payload = deepMerge(payload, {
4963 alwayson_scripts: {
4964 ADetailer: {
4965 args: [
4966 true, // ad_enable
4967 true, // skip_img2img
4968 {
4969 'ad_model': 'face_yolov8n.pt',
4970 },
4971 ],
4972 },
4973 },
4974 });
4975 }
4976
4977 // Make the fetch call with the payload
4978 const result = await fetch('/api/sd/generate', {
4979 method: 'POST',
4980 headers: getRequestHeaders(),
4981 signal: signal,
4982 body: JSON.stringify(payload),
4983 });
4984
4985 if (result.ok) {
4986 const data = await result.json();
4987 return { format: 'png', data: data.images[0] };
4988 } else {
4989 const text = await result.text();
4990 throw new Error(text);
4991 }
4992}
4993
4994/**
4995 * Generates an image using stable-diffusion.cpp server API.
4996 *
4997 * @param {string} prompt - The main instruction used to guide the image generation.
4998 * @param {string} negativePrompt - The instruction used to restrict the image generation.
4999 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5000 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5001 */
5002async function generateSdcppImage(prompt, negativePrompt, signal) {
5003 const payload = {
5004 url: extension_settings.sd.sdcpp_url,
5005 model: extension_settings.sd.model || undefined,
5006 prompt: prompt,
5007 negative_prompt: negativePrompt,
5008 steps: extension_settings.sd.steps,
5009 cfg_scale: extension_settings.sd.scale,
5010 width: extension_settings.sd.width,
5011 height: extension_settings.sd.height,
5012 batch_size: 1,
5013 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5014 };
5015
5016 if (extension_settings.sd.sampler && extension_settings.sd.sampler !== 'N/A') {
5017 payload.sampler_name = extension_settings.sd.sampler;
5018 }
5019
5020 if (extension_settings.sd.scheduler && extension_settings.sd.scheduler !== 'N/A') {
5021 payload.scheduler = extension_settings.sd.scheduler;
5022 }
5023
5024 if (Number.isFinite(extension_settings.sd.clip_skip)) {
5025 payload.clip_skip = extension_settings.sd.clip_skip;
5026 }
5027
5028 const result = await fetch('/api/sd/sdcpp/generate', {
5029 method: 'POST',
5030 headers: getRequestHeaders(),
5031 signal: signal,
5032 body: JSON.stringify(payload),
5033 });
5034
5035 if (result.ok) {
5036 const data = await result.json();
5037 return { format: 'png', data: data.images?.[0] };
5038 } else {
5039 const text = await result.text();
5040 throw new Error(text);
5041 }
5042}
5043
5044/**
5045 * Generates an image in Drawthings API using the provided prompt and configuration settings.
5046 *
5047 * @param {string} prompt - The main instruction used to guide the image generation.
5048 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5049 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5050 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5051 */
5052async function generateDrawthingsImage(prompt, negativePrompt, signal) {
5053 const result = await fetch('/api/sd/drawthings/generate', {
5054 method: 'POST',
5055 headers: getRequestHeaders(),
5056 signal: signal,
5057 body: JSON.stringify({
5058 ...getSdRequestBody(),
5059 prompt: prompt,
5060 negative_prompt: negativePrompt,
5061 sampler_name: extension_settings.sd.sampler,
5062 steps: extension_settings.sd.steps,
5063 cfg_scale: extension_settings.sd.scale,
5064 width: extension_settings.sd.width,
5065 height: extension_settings.sd.height,
5066 restore_faces: !!extension_settings.sd.restore_faces,
5067 enable_hr: !!extension_settings.sd.enable_hr,
5068 denoising_strength: extension_settings.sd.denoising_strength,
5069 clip_skip: extension_settings.sd.clip_skip,
5070 upscaler_scale: extension_settings.sd.hr_scale,
5071 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5072 // TODO: advanced API parameters: hr, upscaler
5073 }),
5074 });
5075
5076 if (result.ok) {
5077 const data = await result.json();
5078 return { format: 'png', data: data.images[0] };
5079 } else {
5080 const text = await result.text();
5081 throw new Error(text);
5082 }
5083}
5084
5085/**
5086 * Generates an image in NovelAI API using the provided prompt and configuration settings.
5087 *
5088 * @param {string} prompt - The main instruction used to guide the image generation.
5089 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5090 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5091 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5092 */
5093async function generateNovelImage(prompt, negativePrompt, signal) {
5094 const { steps, width, height, sm, sm_dyn } = getNovelParams();
5095
5096 const result = await fetch('/api/novelai/generate-image', {
5097 method: 'POST',
5098 headers: getRequestHeaders(),
5099 signal: signal,
5100 body: JSON.stringify({
5101 prompt: prompt,
5102 model: extension_settings.sd.model,
5103 sampler: extension_settings.sd.sampler,
5104 scheduler: extension_settings.sd.scheduler,
5105 steps: steps,
5106 scale: extension_settings.sd.scale,
5107 width: width,
5108 height: height,
5109 negative_prompt: negativePrompt,
5110 upscale_ratio: extension_settings.sd.hr_scale,
5111 decrisper: extension_settings.sd.novel_decrisper,
5112 variety_boost: extension_settings.sd.novel_variety_boost,
5113 sm: sm,
5114 sm_dyn: sm_dyn,
5115 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5116 }),
5117 });
5118
5119 if (result.ok) {
5120 const data = await result.text();
5121 return { format: 'png', data: data };
5122 } else {
5123 const text = await result.text();
5124 throw new Error(text);
5125 }
5126}
5127
5128/**
5129 * Adjusts extension parameters for NovelAI. Applies Anlas guard if needed.
5130 * @returns {{steps: number, width: number, height: number, sm: boolean, sm_dyn: boolean}} - A tuple of parameters for NovelAI API.
5131 */
5132function getNovelParams() {
5133 let steps = Math.min(extension_settings.sd.steps, 50);
5134 let width = extension_settings.sd.width;
5135 let height = extension_settings.sd.height;
5136 let sm = extension_settings.sd.novel_sm;
5137 let sm_dyn = extension_settings.sd.novel_sm_dyn;
5138
5139 // If a source was never changed after the scheduler setting was added, we need to set it to 'karras' for compatibility.
5140 const schedulers = loadNovelSchedulers();
5141 if (!schedulers.includes(extension_settings.sd.scheduler)) {
5142 extension_settings.sd.scheduler = 'karras';
5143 }
5144
5145 if (extension_settings.sd.sampler === 'ddim' ||
5146 ['nai-diffusion-4-curated-preview', 'nai-diffusion-4-full'].includes(extension_settings.sd.model)) {
5147 sm = false;
5148 sm_dyn = false;
5149 }
5150
5151 // Don't apply Anlas guard if it's disabled.
5152 if (!extension_settings.sd.novel_anlas_guard) {
5153 return { steps, width, height, sm, sm_dyn };
5154 }
5155
5156 const MAX_STEPS = 28;
5157 const MAX_PIXELS = 1024 * 1024;
5158
5159 if (width * height > MAX_PIXELS) {
5160 const ratio = Math.sqrt(MAX_PIXELS / (width * height));
5161
5162 // Calculate new width and height while maintaining aspect ratio.
5163 let newWidth = Math.round(width * ratio);
5164 let newHeight = Math.round(height * ratio);
5165
5166 // Ensure new dimensions are multiples of 64. If not, reduce accordingly.
5167 if (newWidth % 64 !== 0) {
5168 newWidth = newWidth - newWidth % 64;
5169 }
5170
5171 if (newHeight % 64 !== 0) {
5172 newHeight = newHeight - newHeight % 64;
5173 }
5174
5175 // If total pixel count after rounding still exceeds MAX_PIXELS, decrease dimension size by 64 accordingly.
5176 while (newWidth * newHeight > MAX_PIXELS) {
5177 if (newWidth > newHeight) {
5178 newWidth -= 64;
5179 } else {
5180 newHeight -= 64;
5181 }
5182 }
5183
5184 console.log(`Anlas Guard: Image size (${width}x${height}) > ${MAX_PIXELS}, reducing size to ${newWidth}x${newHeight}`);
5185 width = newWidth;
5186 height = newHeight;
5187 }
5188
5189 if (steps > MAX_STEPS) {
5190 console.log(`Anlas Guard: Steps (${steps}) > ${MAX_STEPS}, reducing steps to ${MAX_STEPS}`);
5191 steps = MAX_STEPS;
5192 }
5193
5194 return { steps, width, height, sm, sm_dyn };
5195}
5196
5197/**
5198 * Generates an image in OpenAI API using the provided prompt and configuration settings.
5199 * @param {string} prompt - The main instruction used to guide the image generation.
5200 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5201 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5202 */
5203async function generateOpenAiImage(prompt, signal) {
5204 const dalle2PromptLimit = 1000;
5205 const dalle3PromptLimit = 4000;
5206 const gptImgPromptLimit = 32000;
5207
5208 const isDalle2 = /dall-e-2/.test(extension_settings.sd.model);
5209 const isDalle3 = /dall-e-3/.test(extension_settings.sd.model);
5210 const isGptImg = /gpt-image-(1|2|latest)/.test(extension_settings.sd.model);
5211 const isSora2 = /sora-2/.test(extension_settings.sd.model);
5212
5213 if (isDalle2 && prompt.length > dalle2PromptLimit) {
5214 prompt = prompt.substring(0, dalle2PromptLimit);
5215 }
5216
5217 if (isDalle3 && prompt.length > dalle3PromptLimit) {
5218 prompt = prompt.substring(0, dalle3PromptLimit);
5219 }
5220
5221 if (isGptImg && prompt.length > gptImgPromptLimit) {
5222 prompt = prompt.substring(0, gptImgPromptLimit);
5223 }
5224
5225 let width = 1024;
5226 let height = 1024;
5227 let aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
5228
5229 if (isDalle3 && aspectRatio < 1) {
5230 height = 1792;
5231 }
5232
5233 if (isDalle3 && aspectRatio > 1) {
5234 width = 1792;
5235 }
5236
5237 if (isGptImg && aspectRatio < 1) {
5238 height = 1536;
5239 }
5240
5241 if (isGptImg && aspectRatio > 1) {
5242 width = 1536;
5243 }
5244
5245 if (isDalle2 && (extension_settings.sd.width <= 512 && extension_settings.sd.height <= 512)) {
5246 width = 512;
5247 height = 512;
5248 }
5249
5250 if (isSora2) {
5251 width = aspectRatio >= 1 ? 1280 : 720;
5252 height = aspectRatio >= 1 ? 720 : 1280;
5253
5254 const videoResult = await fetch('/api/openai/generate-video', {
5255 method: 'POST',
5256 headers: getRequestHeaders(),
5257 signal: signal,
5258 body: JSON.stringify({
5259 prompt: prompt,
5260 model: extension_settings.sd.model,
5261 size: `${width}x${height}`,
5262 seconds: extension_settings.sd.openai_duration,
5263 }),
5264 });
5265
5266 if (!videoResult.ok) {
5267 throw new Error(await videoResult.text());
5268 }
5269
5270 const { format, data } = await videoResult.json();
5271 return { format, data };
5272 }
5273
5274 const result = await fetch('/api/openai/generate-image', {
5275 method: 'POST',
5276 headers: getRequestHeaders(),
5277 signal: signal,
5278 body: JSON.stringify({
5279 prompt: prompt,
5280 model: extension_settings.sd.model,
5281 size: `${width}x${height}`,
5282 n: 1,
5283 quality: isDalle3 ? extension_settings.sd.openai_quality : (isGptImg ? extension_settings.sd.openai_quality_gpt : undefined),
5284 style: isDalle3 ? extension_settings.sd.openai_style : undefined,
5285 response_format: isDalle2 || isDalle3 ? 'b64_json' : undefined,
5286 moderation: isGptImg ? 'low' : undefined,
5287 }),
5288 });
5289
5290 if (result.ok) {
5291 const data = await result.json();
5292 return { format: 'png', data: data?.data[0]?.b64_json };
5293 } else {
5294 const text = await result.text();
5295 throw new Error(text);
5296 }
5297}
5298
5299/**
5300 * Universal image generation via AIMLAPI:
5301 * - Builds the right request body for any model (OpenAI vs SD/Flux/Recraft).
5302 * - Extracts the URL or base64 response.
5303 * - If it’s a URL, fetches the image and converts to base64.
5304 * - Returns { format: 'png', data: '<base64 string>' }, ready for saveBase64AsFile().
5305 */
5306async function generateAimlapiImage(prompt, signal) {
5307 const model = extension_settings.sd.model.toLowerCase();
5308 const isSdLike =
5309 model.startsWith('flux/') ||
5310 model.startsWith('stable') ||
5311 model === 'recraft-v3' ||
5312 model === 'triposr';
5313
5314 const body = { prompt, model };
5315 if (isSdLike) {
5316 body.steps = clamp(extension_settings.sd.steps, 1, 50);
5317 body.guidance = clamp(extension_settings.sd.scale, 1.5, 5);
5318 body.width = clamp(extension_settings.sd.width, 256, 1440);
5319 body.height = clamp(extension_settings.sd.height, 256, 1440);
5320 if (extension_settings.sd.seed >= 0) body.seed = extension_settings.sd.seed;
5321 } else {
5322 body.n = 1;
5323 body.size = `${extension_settings.sd.width}x${extension_settings.sd.height}`;
5324 body.quality = extension_settings.sd.openai_quality;
5325 body.style = extension_settings.sd.openai_style;
5326 }
5327
5328 const res = await fetch('/api/sd/aimlapi/generate-image', {
5329 method: 'POST',
5330 headers: getRequestHeaders(),
5331 signal,
5332 body: JSON.stringify(body),
5333 });
5334 if (!res.ok) throw new Error(await res.text());
5335
5336 const { format, data } = await res.json();
5337 return { format, data };
5338}
5339
5340/**
5341 * Generates an image in local ComfyUI or serverless runpod using the provided prompt and configuration settings.
5342 *
5343 * @param {string} prompt - The main instruction used to guide the image generation.
5344 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5345 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5346 * @param {string} basePath - ST server endpoint for the service. '/api/sd/comfy' for local, '/api/sd/comfyrunpod' for serverless.
5347 * @param {string[]} placeholders - Array of substitutions to apply to the workflow.
5348 * @param {string} url - The url of the service to call. Passed to ST server.
5349 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5350 */
5351async function generateComfyImageCommon(prompt, negativePrompt, signal, basePath, placeholders, url) {
5352 const workflowResponse = await fetch('/api/sd/comfy/workflow', {
5353 method: 'POST',
5354 headers: getRequestHeaders(),
5355 body: JSON.stringify({
5356 file_name: extension_settings.sd.comfy_workflow,
5357 }),
5358 });
5359 if (!workflowResponse.ok) {
5360 const text = await workflowResponse.text();
5361 toastr.error(`Failed to load workflow.\n\n${text}`);
5362 }
5363 let workflow = (await workflowResponse.json()).replaceAll('"%prompt%"', JSON.stringify(prompt));
5364 workflow = workflow.replaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt));
5365
5366 const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
5367 workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
5368
5369 const denoising_strength = extension_settings.sd.denoising_strength === undefined ? 1.0 : extension_settings.sd.denoising_strength;
5370 workflow = workflow.replaceAll('"%denoise%"', JSON.stringify(denoising_strength));
5371
5372 const clip_skip = isNaN(extension_settings.sd.clip_skip) ? -1 : -extension_settings.sd.clip_skip;
5373 workflow = workflow.replaceAll('"%clip_skip%"', JSON.stringify(clip_skip));
5374
5375 placeholders.forEach(ph => {
5376 workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
5377 });
5378 (extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {
5379 workflow = workflow.replaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
5380 });
5381 // Log the workflow before image payloads are substituted in: keeps private
5382 // image data (avatars, reference images) out of the console and avoids
5383 // scanning multi-megabyte strings (a redaction regex here previously blew
5384 // the stack on large reference images).
5385 console.log(`{
5386 "prompt": ${workflow}
5387 }`);
5388 if (/%user_avatar%/gi.test(workflow)) {
5389 const response = await fetch(getUserAvatarUrl());
5390 if (response.ok) {
5391 const avatarBlob = await response.blob();
5392 const avatarBase64DataUrl = await getBase64Async(avatarBlob);
5393 const avatarBase64 = avatarBase64DataUrl.split(',')[1];
5394 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
5395 } else {
5396 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
5397 }
5398 }
5399 if (/%char_avatar%/gi.test(workflow)) {
5400 const response = await fetch(getCharacterAvatarUrl());
5401 if (response.ok) {
5402 const avatarBlob = await response.blob();
5403 const avatarBase64DataUrl = await getBase64Async(avatarBlob);
5404 const avatarBase64 = avatarBase64DataUrl.split(',')[1];
5405 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
5406 } else {
5407 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
5408 }
5409 }
5410 if (REFERENCE_IMAGE_PLACEHOLDER.test(workflow)) {
5411 const refImage = await resolveReferenceImageForGeneration(prompt);
5412 const refBase64 = (refImage && await fetchReferenceImageBase64(refImage)) || PNG_PIXEL;
5413 if (refBase64 !== PNG_PIXEL) {
5414 console.log(`SD: workflow reference image: "${refImage.tag}" (${refImage.path})`);
5415 toastr.info(`Reference image: ${refImage.description || refImage.tag || refImage.path}`, 'Image Generation');
5416 } else {
5417 console.warn('SD: workflow uses %reference_image% but no library image was available; sending a transparent pixel');
5418 }
5419 workflow = workflow.replaceAll('"%reference_image%"', JSON.stringify(refBase64));
5420 workflow = workflow.replaceAll('"%reference-image%"', JSON.stringify(refBase64));
5421 }
5422 const promptResult = await fetch(`${basePath}/generate`, {
5423 method: 'POST',
5424 headers: getRequestHeaders(),
5425 signal: signal,
5426 body: JSON.stringify({
5427 url,
5428 prompt: `{
5429 "prompt": ${workflow}
5430 }`,
5431 }),
5432 });
5433 if (!promptResult.ok) {
5434 const text = await promptResult.text();
5435 throw new Error(text);
5436 }
5437 const { format, data } = await promptResult.json();
5438 return { format, data };
5439}
5440
5441/**
5442 * Generates an image in ComfyUI using the provided prompt and configuration settings.
5443 *
5444 * @param {string} prompt - The main instruction used to guide the image generation.
5445 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5446 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5447 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5448 */
5449async function generateComfyImage(prompt, negativePrompt, signal) {
5450 const placeholders = [
5451 'model',
5452 'vae',
5453 'sampler',
5454 'scheduler',
5455 'steps',
5456 'scale',
5457 'width',
5458 'height',
5459 ];
5460 return generateComfyImageCommon(prompt, negativePrompt, signal, '/api/sd/comfy', placeholders, extension_settings.sd.comfy_url);
5461}
5462
5463/**
5464 * Generates an image using ComfyUI through serverless runpod endpoint using the provided prompt and configuration settings.
5465 *
5466 * @param {string} prompt - The main instruction used to guide the image generation.
5467 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5468 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5469 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5470 */
5471async function generateComfyRunPodImage(prompt, negativePrompt, signal) {
5472 const placeholders = [
5473 'steps',
5474 'scale',
5475 'width',
5476 'height',
5477 ];
5478
5479 return generateComfyImageCommon(prompt, negativePrompt, signal, '/api/sd/comfyrunpod', placeholders, extension_settings.sd.comfy_runpod_url);
5480}
5481
5482/**
5483 * Generates an image in Hugging Face Inference API using the provided prompt and configuration settings (model selected).
5484 * @param {string} prompt - The main instruction used to guide the image generation.
5485 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5486 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5487 */
5488async function generateHuggingFaceImage(prompt, signal) {
5489 const result = await fetch('/api/sd/huggingface/generate', {
5490 method: 'POST',
5491 headers: getRequestHeaders(),
5492 signal: signal,
5493 body: JSON.stringify({
5494 model: extension_settings.sd.huggingface_model_id,
5495 prompt: prompt,
5496 }),
5497 });
5498
5499 if (result.ok) {
5500 const data = await result.json();
5501 return { format: 'jpg', data: data.image };
5502 } else {
5503 const text = await result.text();
5504 throw new Error(text);
5505 }
5506}
5507
5508/**
5509 * Generates an image using the Chutes API.
5510 * @param {string} prompt - The main instruction used to guide the image generation.
5511 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5512 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5513 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5514 */
5515async function generateChutesImage(prompt, negativePrompt, signal) {
5516 const result = await fetch('/api/sd/chutes/generate', {
5517 method: 'POST',
5518 headers: getRequestHeaders(),
5519 signal: signal,
5520 body: JSON.stringify({
5521 model: extension_settings.sd.model,
5522 prompt: prompt,
5523 negative_prompt: negativePrompt,
5524 width: extension_settings.sd.width,
5525 height: extension_settings.sd.height,
5526 steps: extension_settings.sd.steps,
5527 guidance_scale: extension_settings.sd.scale,
5528 }),
5529 });
5530
5531 if (result.ok) {
5532 const data = await result.json();
5533 return { format: 'jpg', data: data.image };
5534 } else {
5535 const text = await result.text();
5536 throw new Error(text);
5537 }
5538}
5539
5540/**
5541 * Generates an image using the Electron Hub API.
5542 * @param {string} prompt - The main instruction used to guide the image generation.
5543 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5544 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5545 */
5546async function generateElectronHubImage(prompt, signal) {
5547 const size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height);
5548
5549 const result = await fetch('/api/sd/electronhub/generate', {
5550 method: 'POST',
5551 headers: getRequestHeaders(),
5552 signal: signal,
5553 body: JSON.stringify({
5554 model: extension_settings.sd.model,
5555 prompt: prompt,
5556 size: size,
5557 quality: String(extension_settings.sd.electronhub_quality || '').trim() || undefined,
5558 }),
5559 });
5560
5561 if (result.ok) {
5562 const data = await result.json();
5563 return { format: 'jpg', data: data.image };
5564 } else {
5565 const text = await result.text();
5566 throw new Error(text);
5567 }
5568}
5569
5570/**
5571 * Generates an image using the NanoGPT API.
5572 * @param {string} prompt - The main instruction used to guide the image generation.
5573 * @param {string} negativePrompt - The instruction used to restrict the image generation.
5574 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5575 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5576 */
5577async function generateNanoGPTImage(prompt, negativePrompt, signal) {
5578 const result = await fetch('/api/sd/nanogpt/generate', {
5579 method: 'POST',
5580 headers: getRequestHeaders(),
5581 signal: signal,
5582 body: JSON.stringify({
5583 model: extension_settings.sd.model,
5584 prompt: prompt,
5585 negative_prompt: negativePrompt,
5586 num_steps: parseInt(extension_settings.sd.steps),
5587 scale: parseFloat(extension_settings.sd.scale),
5588 width: parseInt(extension_settings.sd.width),
5589 height: parseInt(extension_settings.sd.height),
5590 resolution: `${extension_settings.sd.width}x${extension_settings.sd.height}`,
5591 showExplicitContent: true,
5592 nImages: 1,
5593 }),
5594 });
5595
5596 if (result.ok) {
5597 const data = await result.json();
5598 return { format: 'jpg', data: data.image };
5599 } else {
5600 const text = await result.text();
5601 throw new Error(text);
5602 }
5603}
5604
5605/**
5606 * Generates an image using the BFL API.
5607 * @param {string} prompt - The main instruction used to guide the image generation.
5608 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5609 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5610 */
5611async function generateBflImage(prompt, signal) {
5612 const result = await fetch('/api/sd/bfl/generate', {
5613 method: 'POST',
5614 headers: getRequestHeaders(),
5615 signal: signal,
5616 body: JSON.stringify({
5617 prompt: prompt,
5618 model: extension_settings.sd.model,
5619 steps: clamp(extension_settings.sd.steps, 1, 50),
5620 guidance: clamp(extension_settings.sd.scale, 1.5, 5),
5621 width: clamp(extension_settings.sd.width, 256, 1440),
5622 height: clamp(extension_settings.sd.height, 256, 1440),
5623 prompt_upsampling: !!extension_settings.sd.bfl_upsampling,
5624 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5625 }),
5626 });
5627
5628 if (result.ok) {
5629 const data = await result.json();
5630 return { format: 'jpg', data: data.image };
5631 } else {
5632 const text = await result.text();
5633 throw new Error(text);
5634 }
5635}
5636
5637/**
5638 * Generates an image using the xAI API.
5639 * @param {string} prompt The main instruction used to guide the image generation.
5640 * @param {string} _negativePrompt Negative prompt is not used in this API
5641 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
5642 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
5643 */
5644async function generateXAIImage(prompt, _negativePrompt, signal) {
5645 let aspectRatio;
5646 let resolution;
5647
5648 if (/grok-imagine/.test(extension_settings.sd.model)) {
5649 const resolutionThreshold = 1296 * 864;
5650 const use2kResolution = (extension_settings.sd.width * extension_settings.sd.height) > resolutionThreshold;
5651 aspectRatio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'xai');
5652 resolution = use2kResolution ? '2k' : '1k';
5653 }
5654
5655 const result = await fetch('/api/sd/xai/generate', {
5656 method: 'POST',
5657 headers: getRequestHeaders(),
5658 signal: signal,
5659 body: JSON.stringify({
5660 prompt: prompt,
5661 model: extension_settings.sd.model,
5662 aspect_ratio: aspectRatio,
5663 resolution: resolution,
5664 }),
5665 });
5666
5667 if (result.ok) {
5668 const data = await result.json();
5669 return { format: data.format, data: data.image };
5670 } else {
5671 const text = await result.text();
5672 throw new Error(text);
5673 }
5674}
5675
5676/**
5677 * Generates an image using the FAL.AI API.
5678 * @param {string} prompt - The main instruction used to guide the image generation.
5679 * @param {string} negativePrompt - The negative prompt used to guide the image generation.
5680 * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
5681 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
5682 */
5683async function generateFalaiImage(prompt, negativePrompt, signal) {
5684 const result = await fetch('/api/sd/falai/generate', {
5685 method: 'POST',
5686 headers: getRequestHeaders(),
5687 signal: signal,
5688 body: JSON.stringify({
5689 prompt: prompt,
5690 negative_prompt: negativePrompt,
5691 model: extension_settings.sd.model,
5692 steps: clamp(extension_settings.sd.steps, 1, 50),
5693 guidance: clamp(extension_settings.sd.scale, 1.5, 5),
5694 width: clamp(extension_settings.sd.width, 256, 1440),
5695 height: clamp(extension_settings.sd.height, 256, 1440),
5696 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5697 }),
5698 });
5699
5700 if (result.ok) {
5701 const data = await result.json();
5702 return { format: 'jpg', data: data.image };
5703 } else {
5704 const text = await result.text();
5705 throw new Error(text);
5706 }
5707}
5708
5709/**
5710 * Generates an image using the Google Vertex AI API.
5711 * @param {string} prompt The main instruction used to guide the image generation.
5712 * @param {string} negativePrompt The instruction used to restrict the image generation.
5713 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
5714 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
5715 */
5716async function generateGoogleImage(prompt, negativePrompt, signal) {
5717 const isVeo = /^veo-/.test(extension_settings.sd.model);
5718
5719 if (isVeo) {
5720 const aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
5721 const maxPromptLength = 3000; // 1024 tokens approx.
5722 const videoResult = await fetch('/api/google/generate-video', {
5723 method: 'POST',
5724 headers: getRequestHeaders(),
5725 signal: signal,
5726 body: JSON.stringify({
5727 prompt: prompt.slice(0, maxPromptLength),
5728 aspect_ratio: aspectRatio >= 1 ? '16:9' : '9:16',
5729 seconds: extension_settings.sd.google_duration,
5730 negative_prompt: negativePrompt,
5731 model: extension_settings.sd.model,
5732 api: extension_settings.sd.google_api || 'makersuite',
5733 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5734 vertexai_auth_mode: oai_settings.vertexai_auth_mode,
5735 vertexai_region: oai_settings.vertexai_region,
5736 vertexai_express_project_id: oai_settings.vertexai_express_project_id,
5737 }),
5738 });
5739
5740 if (!videoResult.ok) {
5741 const text = await videoResult.text();
5742 throw new Error(text);
5743 }
5744
5745 const data = await videoResult.json();
5746 return { format: 'mp4', data: data.video };
5747 }
5748
5749 const result = await fetch('/api/google/generate-image', {
5750 method: 'POST',
5751 headers: getRequestHeaders(),
5752 signal: signal,
5753 body: JSON.stringify({
5754 prompt: prompt,
5755 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'google'),
5756 negative_prompt: negativePrompt,
5757 model: extension_settings.sd.model,
5758 enhance: extension_settings.sd.google_enhance,
5759 api: extension_settings.sd.google_api || 'makersuite',
5760 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5761 vertexai_auth_mode: oai_settings.vertexai_auth_mode,
5762 vertexai_region: oai_settings.vertexai_region,
5763 vertexai_express_project_id: oai_settings.vertexai_express_project_id,
5764 }),
5765 });
5766
5767 if (result.ok) {
5768 const data = await result.json();
5769 return { format: 'jpg', data: data.image };
5770 } else {
5771 const text = await result.text();
5772 throw new Error(text);
5773 }
5774}
5775
5776/**
5777 * Generates an image using the Z.AI API.
5778 * @param {string} prompt The main instruction used to guide the image generation.
5779 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
5780 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
5781 */
5782async function generateZaiImage(prompt, signal) {
5783 // Video generation models (CogVideoX, Viduq1)
5784 if (/(cogvideox|vidu)/.test(extension_settings.sd.model)) {
5785 const videoParams = {};
5786 if (/cogvideox/.test(extension_settings.sd.model)) {
5787 const cogVideoSizes = ['1280x720', '720x1280', '1024x1024', '1080x1920', '2048x1080', '3840x2160'];
5788 videoParams.quality = extension_settings.sd.openai_quality === 'hd' ? 'quality' : 'speed';
5789 videoParams.size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height, cogVideoSizes);
5790 }
5791 if (/vidu/.test(extension_settings.sd.model)) {
5792 videoParams.aspect_ratio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'zai');
5793 }
5794
5795 const videoResult = await fetch('/api/sd/zai/generate-video', {
5796 method: 'POST',
5797 headers: getRequestHeaders(),
5798 signal: signal,
5799 body: JSON.stringify({
5800 prompt: prompt,
5801 model: extension_settings.sd.model,
5802 ...videoParams,
5803 }),
5804 });
5805
5806 if (videoResult.ok) {
5807 const data = await videoResult.json();
5808 return { format: data.format, data: data.video };
5809 }
5810
5811 const text = await videoResult.text();
5812 throw new Error(text);
5813 } else {
5814 // Image generation models (GLM-Image, CogView)
5815 // GLM-Image requires multiples of 32, CogView requires multiples of 16
5816 const isGlmImage = /glm-image/.test(extension_settings.sd.model);
5817 const multiple = isGlmImage ? 32 : 16;
5818
5819 // Round width and height to nearest multiple and clamp to 512-2048 range
5820 let width = clamp(Math.round(extension_settings.sd.width / multiple) * multiple, 512, 2048);
5821 let height = clamp(Math.round(extension_settings.sd.height / multiple) * multiple, 512, 2048);
5822
5823 // CogView has a 2^21px pixel count limit, GLM-Image does not
5824 if (!isGlmImage) {
5825 while ((width * height) > Math.pow(2, 21)) {
5826 if (width >= height) {
5827 width -= multiple;
5828 } else {
5829 height -= multiple;
5830 }
5831 }
5832 }
5833
5834 const result = await fetch('/api/sd/zai/generate', {
5835 method: 'POST',
5836 headers: getRequestHeaders(),
5837 signal: signal,
5838 body: JSON.stringify({
5839 prompt: prompt,
5840 model: extension_settings.sd.model,
5841 quality: extension_settings.sd.openai_quality,
5842 size: `${width}x${height}`,
5843 }),
5844 });
5845
5846 if (result.ok) {
5847 const data = await result.json();
5848 return { format: data.format, data: data.image };
5849 }
5850
5851 const text = await result.text();
5852 throw new Error(text);
5853 }
5854}
5855
5856/**
5857 * Generates an image using the OpenRouter API.
5858 * @param {string} prompt The main instruction used to guide the image generation.
5859 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
5860 * @returns {Promise<{format: string, data: string}>}
5861 */
5862async function generateOpenRouterImage(prompt, signal) {
5863 const result = await fetch('/api/openrouter/image/generate', {
5864 method: 'POST',
5865 headers: getRequestHeaders(),
5866 signal: signal,
5867 body: JSON.stringify({
5868 model: extension_settings.sd.model,
5869 prompt: prompt,
5870 aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'stability'),
5871 }),
5872 });
5873
5874 if (result.ok) {
5875 const data = await result.json();
5876 return { format: 'jpg', data: data.image };
5877 }
5878
5879 const text = await result.text();
5880 throw new Error(text);
5881}
5882
5883async function generateWorkersAIImage(prompt, negativePrompt, signal) {
5884 const result = await fetch('/api/sd/workersai/generate', {
5885 method: 'POST',
5886 headers: getRequestHeaders(),
5887 signal: signal,
5888 body: JSON.stringify({
5889 prompt: prompt,
5890 negative_prompt: negativePrompt,
5891 model: extension_settings.sd.model,
5892 width: extension_settings.sd.width,
5893 height: extension_settings.sd.height,
5894 steps: extension_settings.sd.steps,
5895 scale: extension_settings.sd.scale,
5896 seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
5897 account_id: oai_settings.workers_ai_account_id,
5898 }),
5899 });
5900
5901 if (result.ok) {
5902 const data = await result.json();
5903 return { format: data?.format, data: data?.image };
5904 } else {
5905 const text = await result.text();
5906 throw new Error(text);
5907 }
5908}
5909
5910async function onComfyOpenWorkflowEditorClick() {
5911 let workflow = await (await fetch('/api/sd/comfy/workflow', {
5912 method: 'POST',
5913 headers: getRequestHeaders(),
5914 body: JSON.stringify({
5915 file_name: extension_settings.sd.comfy_workflow,
5916 }),
5917 })).json();
5918 const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));
5919 const saveValue = (/** @type {Popup} */ _popup) => {
5920 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
5921 return true;
5922 };
5923 const popup = new Popup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: 'Save', cancelButton: 'Cancel', wide: true, large: true, onClosing: saveValue });
5924 const popupResult = popup.show();
5925 const checkPlaceholders = () => {
5926 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
5927 $('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function () {
5928 const key = this.getAttribute('data-placeholder');
5929 const found = workflow.search(`"%${key}%"`) !== -1;
5930 this.classList[found ? 'remove' : 'add']('sd_comfy_workflow_editor_not_found');
5931 });
5932 };
5933 $('#sd_comfy_workflow_editor_name').text(extension_settings.sd.comfy_workflow);
5934 $('#sd_comfy_workflow_editor_workflow').val(workflow);
5935 const addPlaceholderDom = (placeholder) => {
5936 const el = $(`
5937 <li class="sd_comfy_workflow_editor_not_found" data-placeholder="${placeholder.find}">
5938 <span class="sd_comfy_workflow_editor_custom_remove" title="Remove custom placeholder">⊘</span>
5939 <span class="sd_comfy_workflow_editor_custom_final">"%${placeholder.find}%"</span><br>
5940 <input placeholder="find" title="find" type="text" class="text_pole sd_comfy_workflow_editor_custom_find" value=""><br>
5941 <input placeholder="replace" title="replace" type="text" class="text_pole sd_comfy_workflow_editor_custom_replace">
5942 </li>
5943 `);
5944 $('#sd_comfy_workflow_editor_placeholder_list_custom').append(el);
5945 el.find('.sd_comfy_workflow_editor_custom_find').val(placeholder.find);
5946 el.find('.sd_comfy_workflow_editor_custom_find').on('input', function () {
5947 if (!(this instanceof HTMLInputElement)) {
5948 return;
5949 }
5950 placeholder.find = this.value;
5951 el.find('.sd_comfy_workflow_editor_custom_final').text(`"%${this.value}%"`);
5952 el.attr('data-placeholder', `${this.value}`);
5953 checkPlaceholders();
5954 saveSettingsDebounced();
5955 });
5956 el.find('.sd_comfy_workflow_editor_custom_replace').val(placeholder.replace);
5957 el.find('.sd_comfy_workflow_editor_custom_replace').on('input', function () {
5958 if (!(this instanceof HTMLInputElement)) {
5959 return;
5960 }
5961 placeholder.replace = this.value;
5962 saveSettingsDebounced();
5963 });
5964 el.find('.sd_comfy_workflow_editor_custom_remove').on('click', () => {
5965 el.remove();
5966 extension_settings.sd.comfy_placeholders.splice(extension_settings.sd.comfy_placeholders.indexOf(placeholder));
5967 saveSettingsDebounced();
5968 });
5969 };
5970 $('#sd_comfy_workflow_editor_placeholder_add').on('click', () => {
5971 if (!extension_settings.sd.comfy_placeholders) {
5972 extension_settings.sd.comfy_placeholders = [];
5973 }
5974 const placeholder = {
5975 find: '',
5976 replace: '',
5977 };
5978 extension_settings.sd.comfy_placeholders.push(placeholder);
5979 addPlaceholderDom(placeholder);
5980 saveSettingsDebounced();
5981 });
5982 (extension_settings.sd.comfy_placeholders ?? []).forEach(placeholder => {
5983 addPlaceholderDom(placeholder);
5984 });
5985 checkPlaceholders();
5986 $('#sd_comfy_workflow_editor_workflow').on('input', checkPlaceholders);
5987 if (await popupResult) {
5988 const response = await fetch('/api/sd/comfy/save-workflow', {
5989 method: 'POST',
5990 headers: getRequestHeaders(),
5991 body: JSON.stringify({
5992 file_name: extension_settings.sd.comfy_workflow,
5993 workflow: workflow,
5994 }),
5995 });
5996 if (!response.ok) {
5997 const text = await response.text();
5998 toastr.error(`Failed to save workflow.\n\n${text}`);
5999 }
6000 }
6001}
6002
6003async function onComfyNewWorkflowClick() {
6004 let name = await callGenericPopup('Workflow name:', POPUP_TYPE.INPUT);
6005 if (!name) {
6006 return;
6007 }
6008 if (!String(name).toLowerCase().endsWith('.json')) {
6009 name += '.json';
6010 }
6011 extension_settings.sd.comfy_workflow = name;
6012 const response = await fetch('/api/sd/comfy/save-workflow', {
6013 method: 'POST',
6014 headers: getRequestHeaders(),
6015 body: JSON.stringify({
6016 file_name: extension_settings.sd.comfy_workflow,
6017 workflow: '',
6018 }),
6019 });
6020 if (!response.ok) {
6021 const text = await response.text();
6022 toastr.error(`Failed to save workflow.\n\n${text}`);
6023 }
6024 saveSettingsDebounced();
6025 await loadComfyWorkflows();
6026 await delay(200);
6027 await onComfyOpenWorkflowEditorClick();
6028}
6029
6030async function onComfyDeleteWorkflowClick() {
6031 const confirm = await callGenericPopup(t`Delete the workflow? This action is irreversible.`, POPUP_TYPE.CONFIRM, '', { okButton: t`Delete`, cancelButton: t`Cancel` });
6032 if (!confirm) {
6033 return;
6034 }
6035 const response = await fetch('/api/sd/comfy/delete-workflow', {
6036 method: 'POST',
6037 headers: getRequestHeaders(),
6038 body: JSON.stringify({
6039 file_name: extension_settings.sd.comfy_workflow,
6040 }),
6041 });
6042 if (!response.ok) {
6043 const text = await response.text();
6044 toastr.error(`Failed to save workflow.\n\n${text}`);
6045 }
6046 await loadComfyWorkflows();
6047 onComfyWorkflowChange();
6048}
6049
6050async function onComfyRenameWorkflowClick() {
6051 const oldName = extension_settings.sd.comfy_workflow;
6052
6053 if (!oldName) {
6054 return;
6055 }
6056
6057 let newName = await callGenericPopup(t`Enter new workflow name:`, POPUP_TYPE.INPUT, oldName);
6058
6059 if (!newName) {
6060 return;
6061 }
6062
6063 newName = String(newName).trim();
6064
6065 if (!newName.toLowerCase().endsWith('.json')) {
6066 newName += '.json';
6067 }
6068
6069 if (newName === oldName) {
6070 return;
6071 }
6072
6073 const existingWorkflow = Array
6074 .from(document.querySelectorAll('#sd_comfy_workflow option'))
6075 .find(opt => opt instanceof HTMLOptionElement && opt.value === newName);
6076
6077 if (existingWorkflow) {
6078 toastr.warning(t`A workflow with that name already exists`);
6079 return;
6080 }
6081
6082 const response = await fetch('/api/sd/comfy/rename-workflow', {
6083 method: 'POST',
6084 headers: getRequestHeaders(),
6085 body: JSON.stringify({
6086 old_name: oldName,
6087 new_name: newName,
6088 }),
6089 });
6090
6091 if (!response.ok) {
6092 const text = await response.text();
6093 toastr.error(t`Failed to rename workflow.\n\n${text}`);
6094 return;
6095 }
6096
6097 extension_settings.sd.comfy_workflow = newName;
6098 saveSettingsDebounced();
6099 await loadComfyWorkflows();
6100}
6101
6102/**
6103 * Sends a chat message with the generated image.
6104 * @param {string} prompt Prompt used for the image generation
6105 * @param {string} image Base64 encoded image
6106 * @param {number} generationType Generation type of the image
6107 * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation
6108 * @param {string} initiator The initiator of the image generation
6109 * @param {string} prefixedPrompt Prompt with an attached specific prefix
6110 * @param {string} format Format of the image (e.g., 'png', 'jpg')
6111 */
6112async function sendMessage(prompt, image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, format) {
6113 const context = getContext();
6114 const name = context.groupId ? systemUserName : context.name2;
6115 const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}';
6116 const messageText = substituteParamsExtended(template, { char: name, prompt: prompt, prefixedPrompt: prefixedPrompt });
6117 const mediaType = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE;
6118 /** @type {MediaAttachment} */
6119 const mediaAttachment = {
6120 url: image,
6121 type: mediaType,
6122 title: prompt,
6123 generation_type: generationType,
6124 negative: additionalNegativePrefix,
6125 source: MEDIA_SOURCE.GENERATED,
6126 };
6127 /** @type {ChatMessage} */
6128 const message = {
6129 name: name,
6130 is_user: false,
6131 is_system: !getVisibilityByInitiator(initiator),
6132 send_date: getMessageTimeStamp(),
6133 mes: messageText,
6134 extra: {
6135 media: [mediaAttachment],
6136 media_display: MEDIA_DISPLAY.GALLERY,
6137 media_index: 0,
6138 inline_image: false,
6139 },
6140 };
6141 context.chat.push(message);
6142 const messageId = context.chat.length - 1;
6143 await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension');
6144 context.addOneMessage(message);
6145 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, messageId, 'extension');
6146 await context.saveChat();
6147 setTimeout(() => context.scrollOnMediaLoad(), debounce_timeout.short);
6148}
6149
6150/**
6151 * Gets the visibility of the resulting message based on the initiator.
6152 * @param {string} initiator Generation initiator
6153 * @returns {boolean} Is resulting message visible
6154 */
6155function getVisibilityByInitiator(initiator) {
6156 switch (initiator) {
6157 case initiators.interactive:
6158 return !!extension_settings.sd.interactive_visible;
6159 case initiators.wand:
6160 return !!extension_settings.sd.wand_visible;
6161 case initiators.command:
6162 return !!extension_settings.sd.command_visible;
6163 case initiators.tool:
6164 return !!extension_settings.sd.tool_visible;
6165 default:
6166 return false;
6167 }
6168}
6169
6170async function addSDGenButtons() {
6171 const buttonHtml = await renderExtensionTemplateAsync('stable-diffusion', 'button');
6172 const dropdownHtml = await renderExtensionTemplateAsync('stable-diffusion', 'dropdown');
6173
6174 $('#sd_wand_container').append(buttonHtml);
6175 $(document.body).append(dropdownHtml);
6176
6177 const button = $('#sd_gen');
6178 const dropdown = $('#sd_dropdown');
6179 dropdown.hide();
6180
6181 let popper = Popper.createPopper(button.get(0), dropdown.get(0), {
6182 placement: 'top',
6183 });
6184
6185 $(document).on('click', '.sd_message_gen', (e) => sdMessageButton($(e.currentTarget), { animate: false }));
6186
6187 $(document).on('click touchend', function (e) {
6188 const target = $(e.target);
6189 if (target.is(dropdown) || target.closest(dropdown).length) return;
6190 if ((target.is(button) || target.closest(button).length) && !dropdown.is(':visible')) {
6191 e.preventDefault();
6192
6193 dropdown.fadeIn(animation_duration);
6194 popper.update();
6195 } else {
6196 dropdown.fadeOut(animation_duration);
6197 }
6198 });
6199
6200 renderCustomDropdownEntries();
6201
6202 // Use event delegation so dynamically-added custom entries also respond to clicks.
6203 $('#sd_dropdown').on('click', 'li[id]', function () {
6204 dropdown.fadeOut(animation_duration);
6205 const id = $(this).attr('id');
6206 const idParamMap = {
6207 'sd_you': 'you',
6208 'sd_face': 'face',
6209 'sd_me': 'me',
6210 'sd_world': 'scene',
6211 'sd_last': 'last',
6212 'sd_raw_last': 'raw_last',
6213 'sd_background': 'background',
6214 };
6215
6216 const param = idParamMap[id];
6217
6218 if (param) {
6219 console.log('doing /sd ' + param);
6220 generatePicture(initiators.wand, {}, param);
6221 return;
6222 }
6223
6224 if (id && id.startsWith('sd_custom_')) {
6225 const entryId = id.slice('sd_custom_'.length);
6226 console.log('doing /sd custom_' + entryId);
6227 generatePicture(initiators.wand, {}, 'custom_' + entryId);
6228 }
6229 });
6230}
6231
6232/**
6233 * Renders the user-defined custom entries into the wand dropdown.
6234 * Removes any previously rendered custom entries first.
6235 */
6236function renderCustomDropdownEntries() {
6237 const list = $('#sd_dropdown ul.list-group');
6238 if (!list.length) {
6239 return;
6240 }
6241
6242 list.find('li.sd_custom_entry').remove();
6243
6244 const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
6245 for (const entry of entries) {
6246 const li = $('<li></li>')
6247 .addClass('list-group-item sd_custom_entry')
6248 .attr('id', 'sd_custom_' + entry.id)
6249 .text(entry.title);
6250 list.append(li);
6251 }
6252}
6253
6254function isValidState() {
6255 switch (extension_settings.sd.source) {
6256 case sources.extras:
6257 return modules.includes('sd');
6258 case sources.horde:
6259 return true;
6260 case sources.auto:
6261 return !!extension_settings.sd.auto_url;
6262 case sources.sdcpp:
6263 return !!extension_settings.sd.sdcpp_url;
6264 case sources.drawthings:
6265 return !!extension_settings.sd.drawthings_url;
6266 case sources.vlad:
6267 return !!extension_settings.sd.vlad_url;
6268 case sources.novel:
6269 return secret_state[SECRET_KEYS.NOVEL];
6270 case sources.openai:
6271 return secret_state[SECRET_KEYS.OPENAI];
6272 case sources.aimlapi:
6273 return secret_state[SECRET_KEYS.AIMLAPI];
6274 case sources.comfy:
6275 switch (extension_settings.sd.comfy_type) {
6276 case comfyTypes.runpod_serverless:
6277 return !!extension_settings.sd.comfy_runpod_url &&
6278 secret_state[SECRET_KEYS.COMFY_RUNPOD];
6279 case comfyTypes.standard:
6280 return !!extension_settings.sd.comfy_url;
6281 default:
6282 return false;
6283 }
6284 case sources.togetherai:
6285 return secret_state[SECRET_KEYS.TOGETHERAI];
6286 case sources.pollinations:
6287 return secret_state[SECRET_KEYS.POLLINATIONS];
6288 case sources.stability:
6289 return secret_state[SECRET_KEYS.STABILITY];
6290 case sources.huggingface:
6291 return secret_state[SECRET_KEYS.HUGGINGFACE];
6292 case sources.chutes:
6293 return secret_state[SECRET_KEYS.CHUTES];
6294 case sources.electronhub:
6295 return secret_state[SECRET_KEYS.ELECTRONHUB];
6296 case sources.nanogpt:
6297 return secret_state[SECRET_KEYS.NANOGPT];
6298 case sources.bfl:
6299 return secret_state[SECRET_KEYS.BFL];
6300 case sources.falai:
6301 return secret_state[SECRET_KEYS.FALAI];
6302 case sources.xai:
6303 return secret_state[SECRET_KEYS.XAI];
6304 case sources.google:
6305 return secret_state[SECRET_KEYS.MAKERSUITE] || secret_state[SECRET_KEYS.VERTEXAI] || secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT];
6306 case sources.zai:
6307 return secret_state[SECRET_KEYS.ZAI];
6308 case sources.openrouter:
6309 return secret_state[SECRET_KEYS.OPENROUTER];
6310 case sources.workersai:
6311 return !!oai_settings.workers_ai_account_id && secret_state[SECRET_KEYS.WORKERS_AI];
6312 default:
6313 return false;
6314 }
6315}
6316
6317/** @type {WeakMap<HTMLElement, AbortController>} */
6318const buttonAbortControllers = new WeakMap();
6319
6320/**
6321 * "Paintbrush" button handler to generate a new image for a message.
6322 * @param {JQuery<HTMLElement>} $icon The click target.
6323 * @param {Object} [options] Additional options for image generation.
6324 * @param {boolean} [options.animate] Whether to animate the media during generation.
6325 * @returns {Promise<void>} A promise that resolves when the image generation process is complete.
6326 */
6327async function sdMessageButton($icon, { animate } = {}) {
6328 /**
6329 * Sets the icon to indicate busy or idle state.
6330 * @param {boolean} isBusy Whether the icon should indicate a busy state.
6331 */
6332 function setBusyIcon(isBusy) {
6333 $icon.toggleClass(classes.idle, !isBusy);
6334 $icon.toggleClass(classes.busy, isBusy);
6335 $media.toggleClass(classes.animation, isBusy);
6336 }
6337
6338 let $media = jQuery();
6339
6340 const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' };
6341 const context = getContext();
6342 const abortController = (() => {
6343 const nativeElement = $icon.get(0);
6344 if (buttonAbortControllers.has(nativeElement)) {
6345 return buttonAbortControllers.get(nativeElement);
6346 } else {
6347 const controller = new AbortController();
6348 buttonAbortControllers.set(nativeElement, controller);
6349 return controller;
6350 }
6351 })();
6352
6353 if ($icon.hasClass(classes.busy)) {
6354 abortController.abort('Aborted by user');
6355 console.log('SD: Image generation aborted by user');
6356 return;
6357 }
6358
6359 const messageElement = $icon.closest('.mes');
6360 const messageId = Number(messageElement.attr('mesid'));
6361
6362 /** @type {ChatMessage} */
6363 const message = context.chat[messageId];
6364
6365 if (!message) {
6366 console.error('Could not find message for SD generation button');
6367 return;
6368 }
6369
6370 if (!message.extra || typeof message.extra !== 'object') {
6371 message.extra = {};
6372 }
6373
6374 if (!Array.isArray(message.extra.media)) {
6375 message.extra.media = [];
6376 }
6377
6378 if (!message.extra.media.length && !message.extra.media_display) {
6379 message.extra.media_display = MEDIA_DISPLAY.GALLERY;
6380 }
6381
6382 /** @type {MediaAttachment} */
6383 const selectedMedia = message.extra.media.length > 0
6384 ? (message.extra.media[message.extra.media_index] ?? message.extra.media[message.extra.media.length - 1])
6385 : { url: '', title: message.mes, type: MEDIA_TYPE.IMAGE, generation_type: generationMode.FREE };
6386
6387 if (animate && message.extra.media.length > 0) {
6388 const index = message.extra.media.indexOf(selectedMedia);
6389 $media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video');
6390 }
6391
6392 const newMediaAttachment = await generateMediaSwipe(
6393 selectedMedia,
6394 message,
6395 () => setBusyIcon(true),
6396 () => setBusyIcon(false),
6397 abortController,
6398 );
6399
6400 if (!newMediaAttachment) {
6401 return;
6402 }
6403
6404 // If already contains an image and it's not inline - leave it as is
6405 message.extra.inline_image = !(message.extra.media.length && !message.extra.inline_image);
6406 message.extra.media.push(newMediaAttachment);
6407 message.extra.media_index = message.extra.media.length - 1;
6408
6409 appendMediaToMessage(message, messageElement, SCROLL_BEHAVIOR.KEEP);
6410
6411 await context.saveChat();
6412}
6413
6414async function onCharacterPromptShareInput() {
6415 // Not a valid state to share character prompt
6416 if (this_chid === undefined || selected_group) {
6417 return;
6418 }
6419
6420 const shouldShare = !!$('#sd_character_prompt_share').prop('checked');
6421
6422 if (shouldShare) {
6423 await writePromptFields(this_chid);
6424 } else {
6425 await writeExtensionField(this_chid, 'sd_character_prompt', null);
6426 }
6427}
6428
6429async function writePromptFields(characterId) {
6430 const key = getCharaFilename(characterId);
6431 const promptPrefix = key ? (extension_settings.sd.character_prompts[key] || '') : '';
6432 const negativePromptPrefix = key ? (extension_settings.sd.character_negative_prompts[key] || '') : '';
6433 const promptObject = {
6434 positive: promptPrefix,
6435 negative: negativePromptPrefix,
6436 };
6437 await writeExtensionField(characterId, 'sd_character_prompt', promptObject);
6438}
6439
6440/**
6441 * Generates a new media attachment based on the provided media attachment metadata.
6442 * @param {MediaAttachment} mediaAttachment - The media attachment metadata.
6443 * @param {ChatMessage} message - The chat message containing the media attachment.
6444 * @param {Function} onStart - Callback function to be called when generation starts.
6445 * @param {Function} onComplete - Callback function to be called when generation completes.
6446 * @param {AbortController} abortController - An AbortController to handle cancellation of the generation process.
6447 * @returns {Promise<MediaAttachment|null>} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted.
6448 */
6449async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) {
6450 const stopListener = () => abortController.abort('Aborted by user');
6451 const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE;
6452 let dimensions = { width: extension_settings.sd.width, height: extension_settings.sd.height };
6453 extension_settings.sd.original_seed = extension_settings.sd.seed;
6454 extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1;
6455
6456 /** @type {MediaAttachment} */
6457 const result = {
6458 url: '',
6459 type: MEDIA_TYPE.IMAGE,
6460 source: MEDIA_SOURCE.GENERATED,
6461 };
6462
6463 let loaderHandle = ActionLoaderHandle.EMPTY;
6464
6465 try {
6466 const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; };
6467 const savedPrompt = mediaAttachment.title ?? message.extra.title ?? '';
6468 const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? '';
6469 const refineArgs = {
6470 negative: savedNegative,
6471 resolution: mediaAttachment.width && mediaAttachment.height ? `${mediaAttachment.width}x${mediaAttachment.height}` : null,
6472 };
6473 const prompt = await refinePrompt(savedPrompt, refineArgs);
6474 dimensions = setTypeSpecificDimensions(generationType, refineArgs.resolution ? mediaAttachment : null);
6475
6476 const context = getContext();
6477 const characterName = context.groupId
6478 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
6479 : context.characters[context.characterId]?.name;
6480
6481 // Show non-blocking stoppable toast for this generation
6482 loaderHandle = loader.show({
6483 blocking: false,
6484 slug: `${MODULE_NAME}-image-generation`,
6485 title: t`Image Generation`,
6486 message: t`Generating an image...`,
6487 onStop: stopListener,
6488 });
6489
6490 onStart();
6491 result.url = await sendGenerationRequest(generationType, prompt, refineArgs.negative, characterName, callback, initiators.swipe, abortController.signal);
6492 result.generation_type = generationType;
6493 result.title = prompt;
6494 result.negative = refineArgs.negative;
6495 if (refineArgs.resolution) {
6496 result.width = mediaAttachment.width;
6497 result.height = mediaAttachment.height;
6498 }
6499 } finally {
6500 onComplete();
6501 restoreOriginalDimensions(dimensions);
6502 extension_settings.sd.seed = extension_settings.sd.original_seed;
6503 delete extension_settings.sd.original_seed;
6504 await loaderHandle.hide();
6505 }
6506
6507 if (!result.url) {
6508 return null;
6509 }
6510
6511 return result;
6512}
6513
6514/**
6515 * Handles the image swipe event to potentially generate a new image.
6516 * @param {object} param Parameters object
6517 * @param {ChatMessage} param.message Message object
6518 * @param {JQuery<HTMLElement>} param.element Message element
6519 * @param {string} param.direction Swipe direction
6520 */
6521async function onImageSwiped({ message, element, direction }) {
6522 const { powerUserSettings, accountStorage } = getContext();
6523
6524 if (!isValidState()) {
6525 return;
6526 }
6527
6528 if (!message || direction !== SWIPE_DIRECTION.RIGHT || powerUserSettings.image_overswipe !== IMAGE_OVERSWIPE.GENERATE) {
6529 return;
6530 }
6531
6532 const media = message?.extra?.media;
6533 if (!Array.isArray(media) || media.length === 0) {
6534 return;
6535 }
6536
6537 const shouldGenerate = message?.extra?.media_index === media.length - 1;
6538 if (!shouldGenerate) {
6539 return;
6540 }
6541
6542 const key = 'imageSwipeNoticeShown';
6543 const hasSeenNotice = accountStorage.getItem(key);
6544 if (!hasSeenNotice) {
6545 await Popup.show.text(
6546 t`Image Generation Notice`,
6547 t`To disable generation on image swipes, change the "Image Swipe Behavior" setting in the User Settings panel. This message will not be shown again.`,
6548 );
6549 accountStorage.setItem(key, 'true');
6550 }
6551
6552 await sdMessageButton(element.find('.sd_message_gen'), { animate: true });
6553}
6554
6555/**
6556 * Applies the command arguments to the extension settings.
6557 * @typedef {import('../../slash-commands/SlashCommand.js').NamedArguments} NamedArguments
6558 * @typedef {import('../../slash-commands/SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture
6559 * @param {NamedArguments | NamedArgumentsCapture} args - Command arguments
6560 * @returns {Record<string, any>} - Current settings before applying the command arguments
6561 */
6562function applyCommandArguments(args) {
6563 const overrideSettings = {};
6564 const currentSettings = {};
6565 const settingMap = {
6566 'edit': 'refine_mode',
6567 'extend': 'free_extend',
6568 'multimodal': 'multimodal_captioning',
6569 'seed': 'seed',
6570 'width': 'width',
6571 'height': 'height',
6572 'steps': 'steps',
6573 'cfg': 'scale',
6574 'skip': 'clip_skip',
6575 'model': 'model',
6576 'sampler': 'sampler',
6577 'scheduler': 'scheduler',
6578 'vae': 'vae',
6579 'upscaler': 'hr_upscaler',
6580 'scale': 'hr_scale',
6581 'hires': 'enable_hr',
6582 'denoise': 'denoising_strength',
6583 '2ndpass': 'hr_second_pass_steps',
6584 'faces': 'restore_faces',
6585 'processing': 'minimal_prompt_processing',
6586 };
6587 const enumHandlers = {
6588 'processing': (value) => {
6589 if (/standard/gi.test(String(value))) {
6590 return false;
6591 }
6592 if (/minimal/gi.test(String(value))) {
6593 return true;
6594 }
6595 },
6596 };
6597
6598 for (const [param, setting] of Object.entries(settingMap)) {
6599 if (args[param] === undefined || defaultSettings[setting] === undefined) {
6600 continue;
6601 }
6602 currentSettings[setting] = extension_settings.sd[setting];
6603 const value = String(args[param]);
6604 const enumHandler = enumHandlers[param];
6605 if (typeof enumHandler === 'function') {
6606 const enumValue = enumHandler(value);
6607 if (enumValue !== undefined) {
6608 overrideSettings[setting] = enumValue;
6609 }
6610 continue;
6611 }
6612 const type = typeof defaultSettings[setting];
6613 switch (type) {
6614 case 'boolean':
6615 overrideSettings[setting] = isTrueBoolean(value) || !isFalseBoolean(value);
6616 break;
6617 case 'number':
6618 overrideSettings[setting] = Number(value);
6619 break;
6620 default:
6621 overrideSettings[setting] = value;
6622 break;
6623 }
6624 }
6625
6626 Object.assign(extension_settings.sd, overrideSettings);
6627 return currentSettings;
6628}
6629
6630function registerFunctionTool() {
6631 if (!extension_settings.sd.function_tool) {
6632 return ToolManager.unregisterFunctionTool('GenerateImage');
6633 }
6634
6635 ToolManager.registerFunctionTool({
6636 name: 'GenerateImage',
6637 displayName: 'Generate Image',
6638 description: [
6639 'Generate an image from a given text prompt.',
6640 'Use when a user asks to generate an image, imagine a concept or an item, send a picture of a scene, a selfie, etc.',
6641 ].join(' '),
6642 parameters: Object.freeze({
6643 $schema: 'http://json-schema.org/draft-04/schema#',
6644 type: 'object',
6645 properties: {
6646 prompt: {
6647 type: 'string',
6648 description: extension_settings.sd.prompts[generationMode.TOOL] || promptTemplates[generationMode.TOOL],
6649 },
6650 },
6651 required: [
6652 'prompt',
6653 ],
6654 }),
6655 action: async (args) => {
6656 if (!isValidState()) throw new Error('Image generation is not configured.');
6657 if (!args) throw new Error('Missing arguments');
6658 if (!args.prompt) throw new Error('Missing prompt');
6659 const url = await generatePicture(initiators.tool, {}, args.prompt);
6660 return encodeURI(url);
6661 },
6662 });
6663}
6664
6665export async function init() {
6666 await addSDGenButtons();
6667
6668 const getSelectEnumProvider = (id, text) => () => Array.from(document.querySelectorAll(`#${id} > [value]`)).map(x => new SlashCommandEnumValue(x.getAttribute('value'), text ? x.textContent : null));
6669
6670 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
6671 name: 'imagine',
6672 returns: 'URL of the generated image, or an empty string if the generation failed',
6673 callback: async (args, trigger) => {
6674 const currentSettings = applyCommandArguments(args);
6675
6676 try {
6677 const url = await generatePicture(initiators.command, args, String(trigger));
6678
6679 // Save override width/height into a message result
6680 if (!isTrueBoolean(args?.quiet?.toString()) && Object.hasOwn(args, 'width') && Object.hasOwn(args, 'height')) {
6681 const context = getContext();
6682 const message = context.chat.at(-1);
6683 if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) {
6684 const mediaAttachment = message.extra.media.findLast(m => m.url === url);
6685 if (mediaAttachment) {
6686 mediaAttachment.width = extension_settings.sd.width;
6687 mediaAttachment.height = extension_settings.sd.height;
6688 await context.saveChat();
6689 }
6690 }
6691 }
6692
6693 return url;
6694 } catch (error) {
6695 console.error('Failed to generate image:', error);
6696 return '';
6697 } finally {
6698 if (Object.keys(currentSettings).length) {
6699 Object.assign(extension_settings.sd, currentSettings);
6700 saveSettingsDebounced();
6701 }
6702 }
6703 },
6704 aliases: ['sd', 'img', 'image'],
6705 namedArgumentList: [
6706 new SlashCommandNamedArgument(
6707 'quiet', 'whether to post the generated image to chat', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
6708 ),
6709 new SlashCommandNamedArgument(
6710 'gallery', 'whether to save the generated image to the character gallery', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true',
6711 ),
6712 SlashCommandNamedArgument.fromProps({
6713 name: 'negative',
6714 description: 'negative prompt prefix',
6715 typeList: [ARGUMENT_TYPE.STRING],
6716 isRequired: false,
6717 acceptsMultiple: false,
6718 }),
6719 SlashCommandNamedArgument.fromProps({
6720 name: 'extend',
6721 description: 'auto-extend free mode prompts with the LLM',
6722 typeList: [ARGUMENT_TYPE.BOOLEAN],
6723 enumProvider: commonEnumProviders.boolean('trueFalse'),
6724 isRequired: false,
6725 acceptsMultiple: false,
6726 }),
6727 SlashCommandNamedArgument.fromProps({
6728 name: 'edit',
6729 description: 'edit the prompt before generation',
6730 typeList: [ARGUMENT_TYPE.BOOLEAN],
6731 enumProvider: commonEnumProviders.boolean('trueFalse'),
6732 isRequired: false,
6733 acceptsMultiple: false,
6734 }),
6735 SlashCommandNamedArgument.fromProps({
6736 name: 'multimodal',
6737 description: 'use multimodal captioning (for portraits only)',
6738 typeList: [ARGUMENT_TYPE.BOOLEAN],
6739 enumProvider: commonEnumProviders.boolean('trueFalse'),
6740 isRequired: false,
6741 acceptsMultiple: false,
6742 }),
6743 SlashCommandNamedArgument.fromProps({
6744 name: 'snap',
6745 description: 'snap auto-adjusted dimensions to the nearest known resolution (portraits and backgrounds only)',
6746 typeList: [ARGUMENT_TYPE.BOOLEAN],
6747 enumProvider: commonEnumProviders.boolean('trueFalse'),
6748 isRequired: false,
6749 acceptsMultiple: false,
6750 }),
6751 SlashCommandNamedArgument.fromProps({
6752 name: 'processing',
6753 description: 'level of response prompt processing returned by the LLM',
6754 typeList: [ARGUMENT_TYPE.STRING],
6755 enumList: [
6756 new SlashCommandEnumValue('standard', 'Standard prompt processing'),
6757 new SlashCommandEnumValue('minimal', 'Minimal prompt processing'),
6758 ],
6759 isRequired: false,
6760 acceptsMultiple: false,
6761 }),
6762 SlashCommandNamedArgument.fromProps({
6763 name: 'seed',
6764 description: 'random seed',
6765 isRequired: false,
6766 typeList: [ARGUMENT_TYPE.NUMBER],
6767 acceptsMultiple: false,
6768 }),
6769 SlashCommandNamedArgument.fromProps({
6770 name: 'width',
6771 description: 'image width',
6772 isRequired: false,
6773 typeList: [ARGUMENT_TYPE.NUMBER],
6774 acceptsMultiple: false,
6775 }),
6776 SlashCommandNamedArgument.fromProps({
6777 name: 'height',
6778 description: 'image height',
6779 isRequired: false,
6780 typeList: [ARGUMENT_TYPE.NUMBER],
6781 acceptsMultiple: false,
6782 }),
6783 SlashCommandNamedArgument.fromProps({
6784 name: 'steps',
6785 description: 'number of steps',
6786 isRequired: false,
6787 typeList: [ARGUMENT_TYPE.NUMBER],
6788 acceptsMultiple: false,
6789 }),
6790 SlashCommandNamedArgument.fromProps({
6791 name: 'cfg',
6792 description: 'CFG scale',
6793 isRequired: false,
6794 typeList: [ARGUMENT_TYPE.NUMBER],
6795 acceptsMultiple: false,
6796 }),
6797 SlashCommandNamedArgument.fromProps({
6798 name: 'skip',
6799 description: 'CLIP skip layers',
6800 isRequired: false,
6801 typeList: [ARGUMENT_TYPE.NUMBER],
6802 acceptsMultiple: false,
6803 }),
6804 SlashCommandNamedArgument.fromProps({
6805 name: 'model',
6806 description: 'model override',
6807 isRequired: false,
6808 typeList: [ARGUMENT_TYPE.STRING],
6809 acceptsMultiple: false,
6810 forceEnum: true,
6811 enumProvider: getSelectEnumProvider('sd_model', true),
6812 }),
6813 SlashCommandNamedArgument.fromProps({
6814 name: 'sampler',
6815 description: 'sampler override',
6816 isRequired: false,
6817 typeList: [ARGUMENT_TYPE.STRING],
6818 acceptsMultiple: false,
6819 forceEnum: true,
6820 enumProvider: getSelectEnumProvider('sd_sampler', false),
6821 }),
6822 SlashCommandNamedArgument.fromProps({
6823 name: 'scheduler',
6824 description: 'scheduler override',
6825 isRequired: false,
6826 typeList: [ARGUMENT_TYPE.STRING],
6827 acceptsMultiple: false,
6828 forceEnum: true,
6829 enumProvider: getSelectEnumProvider('sd_scheduler', false),
6830 }),
6831 SlashCommandNamedArgument.fromProps({
6832 name: 'vae',
6833 description: 'VAE name override',
6834 isRequired: false,
6835 typeList: [ARGUMENT_TYPE.STRING],
6836 acceptsMultiple: false,
6837 forceEnum: true,
6838 enumProvider: getSelectEnumProvider('sd_vae', false),
6839 }),
6840 SlashCommandNamedArgument.fromProps({
6841 name: 'upscaler',
6842 description: 'upscaler override',
6843 isRequired: false,
6844 typeList: [ARGUMENT_TYPE.STRING],
6845 acceptsMultiple: false,
6846 forceEnum: true,
6847 enumProvider: getSelectEnumProvider('sd_hr_upscaler', false),
6848 }),
6849 SlashCommandNamedArgument.fromProps({
6850 name: 'hires',
6851 description: 'enable high-res fix',
6852 isRequired: false,
6853 typeList: [ARGUMENT_TYPE.BOOLEAN],
6854 acceptsMultiple: false,
6855 enumProvider: commonEnumProviders.boolean('trueFalse'),
6856 }),
6857 SlashCommandNamedArgument.fromProps({
6858 name: 'scale',
6859 description: 'upscale amount',
6860 isRequired: false,
6861 typeList: [ARGUMENT_TYPE.NUMBER],
6862 acceptsMultiple: false,
6863 }),
6864 SlashCommandNamedArgument.fromProps({
6865 name: 'denoise',
6866 description: 'denoising strength',
6867 isRequired: false,
6868 typeList: [ARGUMENT_TYPE.NUMBER],
6869 acceptsMultiple: false,
6870 }),
6871 SlashCommandNamedArgument.fromProps({
6872 name: '2ndpass',
6873 description: 'second pass steps',
6874 isRequired: false,
6875 typeList: [ARGUMENT_TYPE.NUMBER],
6876 acceptsMultiple: false,
6877 }),
6878 SlashCommandNamedArgument.fromProps({
6879 name: 'faces',
6880 description: 'restore faces',
6881 isRequired: false,
6882 typeList: [ARGUMENT_TYPE.BOOLEAN],
6883 acceptsMultiple: false,
6884 enumProvider: commonEnumProviders.boolean('trueFalse'),
6885 }),
6886 ],
6887 unnamedArgumentList: [
6888 new SlashCommandArgument(
6889 'argument', [ARGUMENT_TYPE.STRING], false, false, null, Object.values(triggerWords).flat(),
6890 ),
6891 ],
6892 helpString: `
6893 <div>
6894 Requests to generate an image and posts it to chat (unless <code>quiet=true</code> argument is specified). The image is saved to the character gallery by default; use <code>gallery=false</code> to save to the root of the user images directory.
6895 </div>
6896 <div>
6897 Supported arguments: <code>${Object.values(triggerWords).flat().join(', ')}</code>.
6898 </div>
6899 <div>
6900 Anything else would trigger a "free mode" to make generate whatever you prompted. Example: <code>/imagine apple tree</code> would generate a picture of an apple tree. Returns a link to the generated image.
6901 </div>
6902 `,
6903 }));
6904
6905 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
6906 name: 'imagine-source',
6907 aliases: ['sd-source', 'img-source'],
6908 returns: 'a name of the current generation source',
6909 unnamedArgumentList: [
6910 SlashCommandArgument.fromProps({
6911 description: 'source name',
6912 typeList: [ARGUMENT_TYPE.STRING],
6913 isRequired: false,
6914 forceEnum: true,
6915 enumProvider: getSelectEnumProvider('sd_source', true),
6916 }),
6917 ],
6918 helpString: 'If an argument is provided, change the source of the image generation, e.g. <code>/imagine-source comfy</code>. Returns the current source.',
6919 callback: async (_args, name) => {
6920 if (!name) {
6921 return extension_settings.sd.source;
6922 }
6923 const isKnownSource = Object.keys(sources).includes(String(name));
6924 if (!isKnownSource) {
6925 throw new Error('The value provided is not a valid image generation source.');
6926 }
6927 const option = document.querySelector(`#sd_source [value="${name}"]`);
6928 if (!(option instanceof HTMLOptionElement)) {
6929 throw new Error('Could not find the source option in the dropdown.');
6930 }
6931 option.selected = true;
6932 await onSourceChange();
6933 return extension_settings.sd.source;
6934 },
6935 }));
6936
6937 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
6938 name: 'imagine-style',
6939 aliases: ['sd-style', 'img-style'],
6940 returns: 'a name of the current style',
6941 unnamedArgumentList: [
6942 SlashCommandArgument.fromProps({
6943 description: 'style name',
6944 typeList: [ARGUMENT_TYPE.STRING],
6945 isRequired: false,
6946 forceEnum: true,
6947 enumProvider: getSelectEnumProvider('sd_style', false),
6948 }),
6949 ],
6950 helpString: 'If an argument is provided, change the style of the image generation, e.g. <code>/imagine-style MyStyle</code>. Returns the current style.',
6951 callback: async (_args, name) => {
6952 if (!name) {
6953 return extension_settings.sd.style;
6954 }
6955 const option = document.querySelector(`#sd_style [value="${name}"]`);
6956 if (!(option instanceof HTMLOptionElement)) {
6957 throw new Error('Could not find the style option in the dropdown.');
6958 }
6959 option.selected = true;
6960 onStyleSelect();
6961 return extension_settings.sd.style;
6962 },
6963 }));
6964
6965 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
6966 name: 'imagine-comfy-workflow',
6967 callback: changeComfyWorkflow,
6968 aliases: ['icw'],
6969 unnamedArgumentList: [
6970 SlashCommandArgument.fromProps({
6971 description: 'workflow name',
6972 typeList: [ARGUMENT_TYPE.STRING],
6973 isRequired: true,
6974 enumProvider: getSelectEnumProvider('sd_comfy_workflow', false),
6975 }),
6976 ],
6977 helpString: '(workflowName) - change the workflow to be used for image generation with ComfyUI, e.g. <pre><code>/imagine-comfy-workflow MyWorkflow</code></pre>',
6978 }));
6979
6980
6981 const template = await renderExtensionTemplateAsync('stable-diffusion', 'settings', defaultSettings);
6982 $('#sd_container').append(template);
6983 $('#sd_source').on('change', onSourceChange);
6984 $('#sd_scale').on('input', onScaleInput);
6985 $('#sd_steps').on('input', onStepsInput);
6986 $('#sd_model').on('change', onModelChange);
6987 $('#sd_vae').on('change', onVaeChange);
6988 $('#sd_sampler').on('change', onSamplerChange);
6989 $('#sd_resolution').on('change', onResolutionChange);
6990 $('#sd_scheduler').on('change', onSchedulerChange);
6991 $('#sd_prompt_prefix').on('input', onPromptPrefixInput);
6992 $('#sd_negative_prompt').on('input', onNegativePromptInput);
6993 $('#sd_width').on('input', onWidthInput);
6994 $('#sd_height').on('input', onHeightInput);
6995 $('#sd_horde_nsfw').on('input', onHordeNsfwInput);
6996 $('#sd_horde_karras').on('input', onHordeKarrasInput);
6997 $('#sd_horde_sanitize').on('input', onHordeSanitizeInput);
6998 $('#sd_restore_faces').on('input', onRestoreFacesInput);
6999 $('#sd_enable_hr').on('input', onHighResFixInput);
7000 $('#sd_adetailer_face').on('change', onADetailerFaceChange);
7001 $('#sd_refine_mode').on('input', onRefineModeInput);
7002 $('#sd_character_prompt').on('input', onCharacterPromptInput);
7003 $('#sd_character_negative_prompt').on('input', onCharacterNegativePromptInput);
7004 $('#sd_auto_validate').on('click', validateAutoUrl);
7005 $('#sd_auto_url').on('input', onAutoUrlInput);
7006 $('#sd_auto_auth').on('input', onAutoAuthInput);
7007 $('#sd_sdcpp_validate').on('click', validateSdcppUrl);
7008 $('#sd_sdcpp_url').on('input', onSdcppUrlInput);
7009 $('#sd_drawthings_validate').on('click', validateDrawthingsUrl);
7010 $('#sd_drawthings_url').on('input', onDrawthingsUrlInput);
7011 $('#sd_drawthings_auth').on('input', onDrawthingsAuthInput);
7012 $('#sd_vlad_validate').on('click', validateVladUrl);
7013 $('#sd_vlad_url').on('input', onVladUrlInput);
7014 $('#sd_vlad_auth').on('input', onVladAuthInput);
7015 $('#sd_hr_upscaler').on('change', onHrUpscalerChange);
7016 $('#sd_hr_scale').on('input', onHrScaleInput);
7017 $('#sd_denoising_strength').on('input', onDenoisingStrengthInput);
7018 $('#sd_hr_second_pass_steps').on('input', onHrSecondPassStepsInput);
7019 $('#sd_novel_anlas_guard').on('input', onNovelAnlasGuardInput);
7020 $('#sd_novel_view_anlas').on('click', onViewAnlasClick);
7021 $('#sd_novel_sm').on('input', onNovelSmInput);
7022 $('#sd_novel_sm_dyn').on('input', onNovelSmDynInput);
7023 $('#sd_novel_decrisper').on('input', onNovelDecrisperInput);
7024 $('#sd_novel_variety_boost').on('input', onNovelVarietyBoostInput);
7025 $('#sd_pollinations_enhance').on('input', onPollinationsEnhanceInput);
7026 $('#sd_comfy_type').on('change', onComfyTypeChange);
7027 $('#sd_comfy_validate').on('click', validateComfyUrl);
7028 $('#sd_comfy_runpod_validate').on('click', validateComfyRunPodUrl);
7029 $('#sd_comfy_url').on('input', onComfyUrlInput);
7030 $('#sd_comfy_runpod_url').on('input', onComfyRunPodUrlInput);
7031 $('#sd_comfy_workflow').on('change', onComfyWorkflowChange);
7032 $('#sd_comfy_open_workflow_editor').on('click', onComfyOpenWorkflowEditorClick);
7033 $('#sd_comfy_new_workflow').on('click', onComfyNewWorkflowClick);
7034 $('#sd_comfy_rename_workflow').on('click', onComfyRenameWorkflowClick);
7035 $('#sd_comfy_delete_workflow').on('click', onComfyDeleteWorkflowClick);
7036 $('#sd_style').on('change', onStyleSelect);
7037 $('#sd_save_style').on('click', onSaveStyleClick);
7038 $('#sd_rename_style').on('click', onRenameStyleClick);
7039 $('#sd_delete_style').on('click', onDeleteStyleClick);
7040 $('#sd_preset_chain_add').on('click', onPresetChainAddClick);
7041 $('#sd_fallback_enabled').on('change', onFallbackEnabledChange);
7042 $('#sd_ref_images_enabled').on('change', onRefImagesEnabledChange);
7043 $('#sd_ref_images_add').on('click', () => $('#sd_ref_images_file').trigger('click'));
7044 $('#sd_ref_images_file').on('change', onRefImagesFileChange);
7045 $('#sd_runpod_lazy_url').on('input', onRunpodLazyUrlInput);
7046 $('#sd_runpod_warmup').on('click', () => runpodControl('warmup'));
7047 $('#sd_runpod_shutdown').on('click', () => runpodControl('shutdown'));
7048 $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
7049 $('#sd_custom_entries_list').on('click', '[data-action]', function () {
7050 const id = $(this).attr('data-entry-id');
7051 const action = $(this).attr('data-action');
7052 if (action === 'edit') {
7053 onEditCustomEntryClick(id);
7054 } else if (action === 'delete') {
7055 onDeleteCustomEntryClick(id);
7056 }
7057 });
7058 $('#sd_character_prompt_block').hide();
7059 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
7060 $('#sd_openai_style').on('change', onOpenAiStyleSelect);
7061 $('#sd_openai_quality').on('change', onOpenAiQualitySelect);
7062 $('#sd_openai_duration').on('input', onOpenAiDurationSelect);
7063 $('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput);
7064 $('#sd_snap').on('input', onSnapInput);
7065 $('#sd_minimal_prompt_processing').on('input', onMinimalPromptProcessing);
7066 $('#sd_clip_skip').on('input', onClipSkipInput);
7067 $('#sd_seed').on('input', onSeedInput);
7068 $('#sd_character_prompt_share').on('input', onCharacterPromptShareInput);
7069 $('#sd_free_extend').on('input', onFreeExtendInput);
7070 $('#sd_wand_visible').on('input', onWandVisibleInput);
7071 $('#sd_command_visible').on('input', onCommandVisibleInput);
7072 $('#sd_interactive_visible').on('input', onInteractiveVisibleInput);
7073 $('#sd_tool_visible').on('input', onToolVisibleInput);
7074 $('#sd_swap_dimensions').on('click', onSwapDimensionsClick);
7075 $('#sd_stability_style_preset').on('change', onStabilityStylePresetChange);
7076 $('#sd_huggingface_model_id').on('input', onHFModelInput);
7077 $('#sd_function_tool').on('input', onFunctionToolInput);
7078 $('#sd_bfl_upsampling').on('input', onBflUpsamplingInput);
7079
7080 $('#sd_google_api').on('input', function () {
7081 extension_settings.sd.google_api = String($(this).val());
7082 saveSettingsDebounced();
7083 });
7084 $('#sd_google_enhance').on('input', function () {
7085 extension_settings.sd.google_enhance = $(this).prop('checked');
7086 saveSettingsDebounced();
7087 });
7088 $('#sd_google_duration').on('input', function () {
7089 extension_settings.sd.google_duration = Number($(this).val());
7090 saveSettingsDebounced();
7091 });
7092 $('#sd_models_refresh').on('click', async () => {
7093 await loadModels();
7094 });
7095 $('#sd_electronhub_quality').on('change', function () {
7096 extension_settings.sd.electronhub_quality = String($(this).val());
7097 saveSettingsDebounced();
7098 });
7099 $('#sd_openai_quality_gpt').on('input', function () {
7100 extension_settings.sd.openai_quality_gpt = String($(this).val());
7101 saveSettingsDebounced();
7102 });
7103
7104 if (!CSS.supports('field-sizing', 'content')) {
7105 $('.sd_settings .inline-drawer-toggle').on('click', function () {
7106 initScrollHeight($('#sd_prompt_prefix'));
7107 initScrollHeight($('#sd_negative_prompt'));
7108 initScrollHeight($('#sd_character_prompt'));
7109 initScrollHeight($('#sd_character_negative_prompt'));
7110 });
7111 }
7112
7113 for (const [key, value] of Object.entries(resolutionOptions)) {
7114 const option = document.createElement('option');
7115 option.value = key;
7116 option.text = value.name;
7117 $('#sd_resolution').append(option);
7118 }
7119
7120 eventSource.on(event_types.EXTRAS_CONNECTED, async () => {
7121 if (extension_settings.sd.source === sources.extras) {
7122 await loadSettingOptions();
7123 }
7124 });
7125
7126 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
7127 eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped);
7128
7129 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
7130 eventSource.on(event, async (/** @type {string} */ key) => {
7131 const keySourceMap = {
7132 [sources.bfl]: SECRET_KEYS.BFL,
7133 [sources.falai]: SECRET_KEYS.FALAI,
7134 [sources.stability]: SECRET_KEYS.STABILITY,
7135 [sources.aimlapi]: SECRET_KEYS.AIMLAPI,
7136 [sources.comfy]: SECRET_KEYS.COMFY_RUNPOD,
7137 [sources.pollinations]: SECRET_KEYS.POLLINATIONS,
7138 [sources.workersai]: SECRET_KEYS.WORKERS_AI,
7139 };
7140 const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key);
7141 if (!shouldReloadOptions) {
7142 return;
7143 }
7144 await loadSettingOptions();
7145 });
7146 });
7147
7148 await loadSettings();
7149 $('body').addClass('sd');
7150
7151 const getMacroValue = ({ isNegative }) => {
7152 if (selected_group || this_chid === undefined) {
7153 return '';
7154 }
7155
7156 const key = getCharaFilename(this_chid);
7157 let characterPrompt = key ? (extension_settings.sd.character_prompts[key] || '') : '';
7158 let negativePrompt = key ? (extension_settings.sd.character_negative_prompts[key] || '') : '';
7159
7160 const context = getContext();
7161 const sharedPromptData = context?.characters[this_chid]?.data?.extensions?.sd_character_prompt;
7162
7163 if (typeof sharedPromptData?.positive === 'string' && !characterPrompt && sharedPromptData.positive) {
7164 characterPrompt = sharedPromptData.positive || '';
7165 }
7166 if (typeof sharedPromptData?.negative === 'string' && !negativePrompt && sharedPromptData.negative) {
7167 negativePrompt = sharedPromptData.negative || '';
7168 }
7169
7170 return isNegative ? negativePrompt : characterPrompt;
7171 };
7172
7173 if (power_user.experimental_macro_engine) {
7174 macros.register('charPrefix', {
7175 category: MacroCategory.PROMPTS,
7176 description: t`Character's positive Image Generation prompt prefix`,
7177 handler: () => getMacroValue({ isNegative: false }),
7178 });
7179 macros.register('charNegativePrefix', {
7180 category: MacroCategory.PROMPTS,
7181 description: t`Character's negative Image Generation prompt prefix`,
7182 handler: () => getMacroValue({ isNegative: true }),
7183 });
7184 } else {
7185 MacrosParser.registerMacro('charPrefix',
7186 () => getMacroValue({ isNegative: false }),
7187 t`Character's positive Image Generation prompt prefix`,
7188 );
7189 MacrosParser.registerMacro('charNegativePrefix',
7190 () => getMacroValue({ isNegative: true }),
7191 t`Character's negative Image Generation prompt prefix`,
7192 );
7193 }
7194}