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 = {
125 USER_MULTIMODAL: 9,125 USER_MULTIMODAL: 9,
126 FACE_MULTIMODAL: 10,126 FACE_MULTIMODAL: 10,
127 FREE_EXTENDED: 11,127 FREE_EXTENDED: 11,
128 CUSTOM: 12,
128};129};
129130
130const multimodalMap = {131const multimodalMap = {
@@ -147,6 +148,7 @@ const modeLabels = {
147 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',148 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',
148 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',149 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',
149 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',150 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',
151 [generationMode.CUSTOM]: 'Custom',
150};152};
151153
152const triggerWords = {154const triggerWords = {
@@ -368,6 +370,9 @@ const defaultSettings = {
368370
369 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)371 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
370 prompt_generation_profile: '',372 prompt_generation_profile: '',
373
374 // User-defined custom wand dropdown entries ({ id, title, prompt })
375 custom_entries: [],
371};376};
372377
373/**378/**
@@ -614,6 +619,10 @@ async function loadSettings() {
614 extension_settings.sd.settings_fallback_enabled = false;619 extension_settings.sd.settings_fallback_enabled = false;
615 }620 }
616621
622 if (!Array.isArray(extension_settings.sd.custom_entries)) {
623 extension_settings.sd.custom_entries = [];
624 }
625
617 // Preserve an original seed if exists626 // Preserve an original seed if exists
618 if (extension_settings.sd.original_seed >= 0) {627 if (extension_settings.sd.original_seed >= 0) {
619 extension_settings.sd.seed = extension_settings.sd.original_seed;628 extension_settings.sd.seed = extension_settings.sd.original_seed;
@@ -696,6 +705,8 @@ async function loadSettings() {
696705
697 toggleSourceControls();706 toggleSourceControls();
698 addPromptTemplates();707 addPromptTemplates();
708 renderCustomEntriesList();
709 renderCustomDropdownEntries();
699 registerFunctionTool();710 registerFunctionTool();
700711
701 await loadSettingOptions();712 await loadSettingOptions();
@@ -967,6 +978,176 @@ function onFallbackEnabledChange() {
967}978}
968979
969/**980/**
981 * Rebuilds the custom wand entries list in the settings UI.
982 */
983function 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 */
1036function 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 */
1046async 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
1076async 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
1103async 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
1130async 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/**
970 * Modifies prompt based on user inputs.1151 * Modifies prompt based on user inputs.
971 * @param {string} prompt Prompt to refine1152 * @param {string} prompt Prompt to refine
972 * @param {object} [args] Additional arguments for refinement1153 * @param {object} [args] Additional arguments for refinement
@@ -3021,6 +3202,16 @@ async function loadComfyWorkflows() {
3021}3202}
30223203
3023function getGenerationType(prompt) {3204function 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
3024 let mode = generationMode.FREE;3215 let mode = generationMode.FREE;
30253216
3026 for (const [key, values] of Object.entries(triggerWords)) {3217 for (const [key, values] of Object.entries(triggerWords)) {
@@ -3044,6 +3235,13 @@ function getGenerationType(prompt) {
3044}3235}
30453236
3046function getQuietPrompt(mode, trigger) {3237function 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
3047 if (mode === generationMode.FREE) {3245 if (mode === generationMode.FREE) {
3048 return trigger;3246 return trigger;
3049 }3247 }
@@ -5268,7 +5466,10 @@ async function addSDGenButtons() {
5268 }5466 }
5269 });5467 });
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 () {
5272 dropdown.fadeOut(animation_duration);5473 dropdown.fadeOut(animation_duration);
5273 const id = $(this).attr('id');5474 const id = $(this).attr('id');
5274 const idParamMap = {5475 const idParamMap = {
@@ -5286,10 +5487,39 @@ async function addSDGenButtons() {
5286 if (param) {5487 if (param) {
5287 console.log('doing /sd ' + param);5488 console.log('doing /sd ' + param);
5288 generatePicture(initiators.wand, {}, param);5489 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);
5289 }5497 }
5290 });5498 });
5291}5499}
52925500
5501/**
5502 * Renders the user-defined custom entries into the wand dropdown.
5503 * Removes any previously rendered custom entries first.
5504 */
5505function 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
5293function isValidState() {5523function isValidState() {
5294 switch (extension_settings.sd.source) {5524 switch (extension_settings.sd.source) {
5295 case sources.extras:5525 case sources.extras:
@@ -6081,6 +6311,16 @@ export async function init() {
6081 $('#sd_load_preset_primary').on('click', onLoadPresetPrimaryClick);6311 $('#sd_load_preset_primary').on('click', onLoadPresetPrimaryClick);
6082 $('#sd_load_preset_secondary').on('click', onLoadPresetSecondaryClick);6312 $('#sd_load_preset_secondary').on('click', onLoadPresetSecondaryClick);
6083 $('#sd_fallback_enabled').on('change', onFallbackEnabledChange);6313 $('#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 });
6084 $('#sd_character_prompt_block').hide();6324 $('#sd_character_prompt_block').hide();
6085 $('#sd_interactive_mode').on('input', onInteractiveModeInput);6325 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
6086 $('#sd_openai_style').on('change', onOpenAiStyleSelect);6326 $('#sd_openai_style').on('change', onOpenAiStyleSelect);
public/scripts/extensions/stable-diffusion/settings.html+16 -0
@@ -688,4 +688,20 @@
688 <div id="sd_prompt_templates" class="inline-drawer-content">688 <div id="sd_prompt_templates" class="inline-drawer-content">
689 </div>689 </div>
690 </div>690 </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>
691</div>707</div>
public/scripts/extensions/stable-diffusion/style.css+14 -0
@@ -87,3 +87,17 @@
87 top: 0;87 top: 0;
88 transform: translateX(-50%);88 transform: translateX(-50%);
89}89}
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}