Stable Diffusion: user-defined custom wand dropdown entries Reviewed-by: auto-review

61b7e8368dd7e9a28043ea22c8ad6180e1f0e00d

permissionBRICK <permissionBRICK@gmail.com>

3 files changed, +271 -1Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+241 -1
@@ -125,6 +125,7 @@ const generationMode = {
125125 USER_MULTIMODAL: 9,
126126 FACE_MULTIMODAL: 10,
127127 FREE_EXTENDED: 11,
128+ CUSTOM: 12,
128129};
129130
130131const multimodalMap = {
@@ -147,6 +148,7 @@ const modeLabels = {
147148 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',
148149 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',
149150 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',
151+ [generationMode.CUSTOM]: 'Custom',
150152};
151153
152154const triggerWords = {
@@ -368,6 +370,9 @@ const defaultSettings = {
368370
369371 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
370372 prompt_generation_profile: '',
373+
374+ // User-defined custom wand dropdown entries ({ id, title, prompt })
375+ custom_entries: [],
371376};
372377
373378/**
@@ -614,6 +619,10 @@ async function loadSettings() {
614619 extension_settings.sd.settings_fallback_enabled = false;
615620 }
616621
622+ if (!Array.isArray(extension_settings.sd.custom_entries)) {
623+ extension_settings.sd.custom_entries = [];
624+ }
625+
617626 // Preserve an original seed if exists
618627 if (extension_settings.sd.original_seed >= 0) {
619628 extension_settings.sd.seed = extension_settings.sd.original_seed;
@@ -696,6 +705,8 @@ async function loadSettings() {
696705
697706 toggleSourceControls();
698707 addPromptTemplates();
708+ renderCustomEntriesList();
709+ renderCustomDropdownEntries();
699710 registerFunctionTool();
700711
701712 await loadSettingOptions();
@@ -967,6 +978,176 @@ function onFallbackEnabledChange() {
967978}
968979
969980/**
981+ * Rebuilds the custom wand entries list in the settings UI.
982+ */
983+function renderCustomEntriesList() {
984+ const container = $('#sd_custom_entries_list');
985+ if (!container.length) {
986+ return;
987+ }
988+
989+ container.empty();
990+
991+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
992+
993+ if (entries.length === 0) {
994+ const empty = $('<small></small>')
995+ .attr('data-i18n', 'No custom entries yet.')
996+ .text('No custom entries yet.');
997+ container.append(empty);
998+ return;
999+ }
1000+
1001+ for (const entry of entries) {
1002+ const preview = String(entry.prompt || '').replace(/\s+/g, ' ').trim();
1003+ const truncated = preview.length > 80 ? preview.slice(0, 80) + '…' : preview;
1004+
1005+ const titleEl = $('<div></div>').addClass('sd_custom_entry_title').text(entry.title);
1006+ const previewEl = $('<small></small>').addClass('sd_custom_entry_preview').text(truncated);
1007+ const textBlock = $('<div></div>').addClass('flex1 flexFlowColumn').append(titleEl).append(previewEl);
1008+
1009+ const editButton = $('<div></div>')
1010+ .addClass('menu_button menu_button_icon fa-solid fa-pencil')
1011+ .attr('data-entry-id', entry.id)
1012+ .attr('data-action', 'edit')
1013+ .attr('title', 'Edit')
1014+ .attr('data-i18n', '[title]Edit');
1015+ const deleteButton = $('<div></div>')
1016+ .addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1017+ .attr('data-entry-id', entry.id)
1018+ .attr('data-action', 'delete')
1019+ .attr('title', 'Delete')
1020+ .attr('data-i18n', '[title]Delete');
1021+
1022+ const row = $('<div></div>')
1023+ .addClass('flex-container alignItemsCenter marginTopBot5 sd_custom_entry_row')
1024+ .append(textBlock)
1025+ .append(editButton)
1026+ .append(deleteButton);
1027+
1028+ container.append(row);
1029+ }
1030+}
1031+
1032+/**
1033+ * Generates a unique id for a custom entry.
1034+ * @returns {string} A unique identifier.
1035+ */
1036+function getUniqueCustomEntryId() {
1037+ return crypto.randomUUID?.() ?? ('ce_' + Date.now() + '_' + Math.floor(Math.random() * 1e6));
1038+}
1039+
1040+/**
1041+ * Opens a popup to create/edit a custom entry and returns the entered values.
1042+ * @param {string} title Initial title value.
1043+ * @param {string} prompt Initial prompt value.
1044+ * @returns {Promise<{title: string, prompt: string} | null>} Entered values, or null if cancelled.
1045+ */
1046+async function showCustomEntryPopup(title, prompt) {
1047+ const form = $('<div></div>').addClass('flex-container flexFlowColumn');
1048+ const titleLabel = $('<label></label>').attr('data-i18n', 'Title').text('Title');
1049+ const titleInput = $('<input>')
1050+ .addClass('text_pole')
1051+ .attr('type', 'text')
1052+ .attr('id', 'sd_custom_entry_title_input')
1053+ .val(title || '');
1054+ const promptLabel = $('<label></label>').attr('data-i18n', 'Prompt').text('Prompt');
1055+ const promptInput = $('<textarea></textarea>')
1056+ .addClass('text_pole textarea_compact')
1057+ .attr('id', 'sd_custom_entry_prompt_input')
1058+ .attr('rows', 5)
1059+ .val(prompt || '');
1060+
1061+ form.append(titleLabel).append(titleInput).append(promptLabel).append(promptInput);
1062+
1063+ const popup = new Popup(form, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1064+ const result = await popup.show();
1065+
1066+ if (!result) {
1067+ return null;
1068+ }
1069+
1070+ return {
1071+ title: String(titleInput.val() ?? '').trim(),
1072+ prompt: String(promptInput.val() ?? '').trim(),
1073+ };
1074+}
1075+
1076+async function onAddCustomEntryClick() {
1077+ const values = await showCustomEntryPopup('', '');
1078+
1079+ if (!values) {
1080+ return;
1081+ }
1082+
1083+ if (!values.title || !values.prompt) {
1084+ toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1085+ return;
1086+ }
1087+
1088+ if (!Array.isArray(extension_settings.sd.custom_entries)) {
1089+ extension_settings.sd.custom_entries = [];
1090+ }
1091+
1092+ extension_settings.sd.custom_entries.push({
1093+ id: getUniqueCustomEntryId(),
1094+ title: values.title,
1095+ prompt: values.prompt,
1096+ });
1097+
1098+ saveSettingsDebounced();
1099+ renderCustomEntriesList();
1100+ renderCustomDropdownEntries();
1101+}
1102+
1103+async function onEditCustomEntryClick(id) {
1104+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1105+ const entry = entries.find(e => e.id === id);
1106+
1107+ if (!entry) {
1108+ return;
1109+ }
1110+
1111+ const values = await showCustomEntryPopup(entry.title, entry.prompt);
1112+
1113+ if (!values) {
1114+ return;
1115+ }
1116+
1117+ if (!values.title || !values.prompt) {
1118+ toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1119+ return;
1120+ }
1121+
1122+ entry.title = values.title;
1123+ entry.prompt = values.prompt;
1124+
1125+ saveSettingsDebounced();
1126+ renderCustomEntriesList();
1127+ renderCustomDropdownEntries();
1128+}
1129+
1130+async function onDeleteCustomEntryClick(id) {
1131+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1132+ const index = entries.findIndex(e => e.id === id);
1133+
1134+ if (index === -1) {
1135+ return;
1136+ }
1137+
1138+ 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` });
1139+
1140+ if (!confirmed) {
1141+ return;
1142+ }
1143+
1144+ entries.splice(index, 1);
1145+ saveSettingsDebounced();
1146+ renderCustomEntriesList();
1147+ renderCustomDropdownEntries();
1148+}
1149+
1150+/**
9701151 * Modifies prompt based on user inputs.
9711152 * @param {string} prompt Prompt to refine
9721153 * @param {object} [args] Additional arguments for refinement
@@ -3021,6 +3202,16 @@ async function loadComfyWorkflows() {
30213202}
30223203
30233204function getGenerationType(prompt) {
3205+ // Custom wand entries use the trigger convention 'custom_<id>' and bypass
3206+ // the multimodal/free_extend transforms applied to the built-in triggers.
3207+ const trimmedPrompt = String(prompt).trim();
3208+ if (Array.isArray(extension_settings.sd.custom_entries)) {
3209+ const customEntry = extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === trimmedPrompt);
3210+ if (customEntry) {
3211+ return generationMode.CUSTOM;
3212+ }
3213+ }
3214+
30243215 let mode = generationMode.FREE;
30253216
30263217 for (const [key, values] of Object.entries(triggerWords)) {
@@ -3044,6 +3235,13 @@ function getGenerationType(prompt) {
30443235}
30453236
30463237function getQuietPrompt(mode, trigger) {
3238+ if (mode === generationMode.CUSTOM) {
3239+ const entry = Array.isArray(extension_settings.sd.custom_entries)
3240+ ? extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === String(trigger).trim())
3241+ : undefined;
3242+ return entry ? entry.prompt : trigger;
3243+ }
3244+
30473245 if (mode === generationMode.FREE) {
30483246 return trigger;
30493247 }
@@ -5268,7 +5466,10 @@ async function addSDGenButtons() {
52685466 }
52695467 });
52705468
5271- $('#sd_dropdown [id]').on('click', function () {
5469+ renderCustomDropdownEntries();
5470+
5471+ // Use event delegation so dynamically-added custom entries also respond to clicks.
5472+ $('#sd_dropdown').on('click', 'li[id]', function () {
52725473 dropdown.fadeOut(animation_duration);
52735474 const id = $(this).attr('id');
52745475 const idParamMap = {
@@ -5286,10 +5487,39 @@ async function addSDGenButtons() {
52865487 if (param) {
52875488 console.log('doing /sd ' + param);
52885489 generatePicture(initiators.wand, {}, param);
5490+ return;
5491+ }
5492+
5493+ if (id && id.startsWith('sd_custom_')) {
5494+ const entryId = id.slice('sd_custom_'.length);
5495+ console.log('doing /sd custom_' + entryId);
5496+ generatePicture(initiators.wand, {}, 'custom_' + entryId);
52895497 }
52905498 });
52915499}
52925500
5501+/**
5502+ * Renders the user-defined custom entries into the wand dropdown.
5503+ * Removes any previously rendered custom entries first.
5504+ */
5505+function renderCustomDropdownEntries() {
5506+ const list = $('#sd_dropdown ul.list-group');
5507+ if (!list.length) {
5508+ return;
5509+ }
5510+
5511+ list.find('li.sd_custom_entry').remove();
5512+
5513+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
5514+ for (const entry of entries) {
5515+ const li = $('<li></li>')
5516+ .addClass('list-group-item sd_custom_entry')
5517+ .attr('id', 'sd_custom_' + entry.id)
5518+ .text(entry.title);
5519+ list.append(li);
5520+ }
5521+}
5522+
52935523function isValidState() {
52945524 switch (extension_settings.sd.source) {
52955525 case sources.extras:
@@ -6081,6 +6311,16 @@ export async function init() {
60816311 $('#sd_load_preset_primary').on('click', onLoadPresetPrimaryClick);
60826312 $('#sd_load_preset_secondary').on('click', onLoadPresetSecondaryClick);
60836313 $('#sd_fallback_enabled').on('change', onFallbackEnabledChange);
6314+ $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
6315+ $('#sd_custom_entries_list').on('click', '[data-action]', function () {
6316+ const id = $(this).attr('data-entry-id');
6317+ const action = $(this).attr('data-action');
6318+ if (action === 'edit') {
6319+ onEditCustomEntryClick(id);
6320+ } else if (action === 'delete') {
6321+ onDeleteCustomEntryClick(id);
6322+ }
6323+ });
60846324 $('#sd_character_prompt_block').hide();
60856325 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
60866326 $('#sd_openai_style').on('change', onOpenAiStyleSelect);
public/scripts/extensions/stable-diffusion/settings.html+16 -0
@@ -688,4 +688,20 @@
688688 <div id="sd_prompt_templates" class="inline-drawer-content">
689689 </div>
690690 </div>
691+ <div class="inline-drawer">
692+ <div class="inline-drawer-toggle inline-drawer-header">
693+ <b data-i18n="Custom Wand Entries">Custom Wand Entries</b>
694+ <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
695+ </div>
696+ <div class="inline-drawer-content">
697+ <small data-i18n="sd_custom_entries_small">Add your own entries to the image generation wand dropdown. Clicking an entry uses its prompt as the LLM instruction, just like the built-in entries.</small>
698+ <div class="flex-container marginTopBot5">
699+ <div id="sd_custom_entry_add" class="menu_button menu_button_icon">
700+ <i class="fa-solid fa-plus"></i>
701+ <span data-i18n="Add entry">Add entry</span>
702+ </div>
703+ </div>
704+ <div id="sd_custom_entries_list"></div>
705+ </div>
706+ </div>
691707</div>
public/scripts/extensions/stable-diffusion/style.css+14 -0
@@ -87,3 +87,17 @@
8787 top: 0;
8888 transform: translateX(-50%);
8989}
90+
91+.sd_custom_entry_row .sd_custom_entry_title {
92+ font-weight: bold;
93+ overflow: hidden;
94+ text-overflow: ellipsis;
95+ white-space: nowrap;
96+}
97+
98+.sd_custom_entry_row .sd_custom_entry_preview {
99+ opacity: 0.7;
100+ overflow: hidden;
101+ text-overflow: ellipsis;
102+ white-space: nowrap;
103+}