Stable Diffusion: user-defined custom wand dropdown entries Reviewed-by: auto-review
| @@ -125,6 +125,7 @@ const generationMode = { | ||
| 125 | 125 | USER_MULTIMODAL: 9, |
| 126 | 126 | FACE_MULTIMODAL: 10, |
| 127 | 127 | FREE_EXTENDED: 11, |
| 128 | + CUSTOM: 12, | |
| 128 | 129 | }; |
| 129 | 130 | |
| 130 | 131 | const multimodalMap = { |
| @@ -147,6 +148,7 @@ const modeLabels = { | ||
| 147 | 148 | [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)', |
| 148 | 149 | [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)', |
| 149 | 150 | [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)', |
| 151 | + [generationMode.CUSTOM]: 'Custom', | |
| 150 | 152 | }; |
| 151 | 153 | |
| 152 | 154 | const triggerWords = { |
| @@ -368,6 +370,9 @@ const defaultSettings = { | ||
| 368 | 370 | |
| 369 | 371 | // Dedicated LLM connection profile for image-prompt generation ('' = use active model) |
| 370 | 372 | prompt_generation_profile: '', |
| 373 | + | |
| 374 | + // User-defined custom wand dropdown entries ({ id, title, prompt }) | |
| 375 | + custom_entries: [], | |
| 371 | 376 | }; |
| 372 | 377 | |
| 373 | 378 | /** |
| @@ -614,6 +619,10 @@ async function loadSettings() { | ||
| 614 | 619 | extension_settings.sd.settings_fallback_enabled = false; |
| 615 | 620 | } |
| 616 | 621 | |
| 622 | + if (!Array.isArray(extension_settings.sd.custom_entries)) { | |
| 623 | + extension_settings.sd.custom_entries = []; | |
| 624 | + } | |
| 625 | + | |
| 617 | 626 | // Preserve an original seed if exists |
| 618 | 627 | if (extension_settings.sd.original_seed >= 0) { |
| 619 | 628 | extension_settings.sd.seed = extension_settings.sd.original_seed; |
| @@ -696,6 +705,8 @@ async function loadSettings() { | ||
| 696 | 705 | |
| 697 | 706 | toggleSourceControls(); |
| 698 | 707 | addPromptTemplates(); |
| 708 | + renderCustomEntriesList(); | |
| 709 | + renderCustomDropdownEntries(); | |
| 699 | 710 | registerFunctionTool(); |
| 700 | 711 | |
| 701 | 712 | await loadSettingOptions(); |
| @@ -967,6 +978,176 @@ function onFallbackEnabledChange() { | ||
| 967 | 978 | } |
| 968 | 979 | |
| 969 | 980 | /** |
| 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 | +/** | |
| 970 | 1151 | * Modifies prompt based on user inputs. |
| 971 | 1152 | * @param {string} prompt Prompt to refine |
| 972 | 1153 | * @param {object} [args] Additional arguments for refinement |
| @@ -3021,6 +3202,16 @@ async function loadComfyWorkflows() { | ||
| 3021 | 3202 | } |
| 3022 | 3203 | |
| 3023 | 3204 | function 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 | 3215 | let mode = generationMode.FREE; |
| 3025 | 3216 | |
| 3026 | 3217 | for (const [key, values] of Object.entries(triggerWords)) { |
| @@ -3044,6 +3235,13 @@ function getGenerationType(prompt) { | ||
| 3044 | 3235 | } |
| 3045 | 3236 | |
| 3046 | 3237 | function 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 | 3245 | if (mode === generationMode.FREE) { |
| 3048 | 3246 | return trigger; |
| 3049 | 3247 | } |
| @@ -5268,7 +5466,10 @@ async function addSDGenButtons() { | ||
| 5268 | 5466 | } |
| 5269 | 5467 | }); |
| 5270 | 5468 | |
| 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 | 5473 | dropdown.fadeOut(animation_duration); |
| 5273 | 5474 | const id = $(this).attr('id'); |
| 5274 | 5475 | const idParamMap = { |
| @@ -5286,10 +5487,39 @@ async function addSDGenButtons() { | ||
| 5286 | 5487 | if (param) { |
| 5287 | 5488 | console.log('doing /sd ' + param); |
| 5288 | 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 | } |
| 5292 | 5500 | |
| 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 | + | |
| 5293 | 5523 | function isValidState() { |
| 5294 | 5524 | switch (extension_settings.sd.source) { |
| 5295 | 5525 | case sources.extras: |
| @@ -6081,6 +6311,16 @@ export async function init() { | ||
| 6081 | 6311 | $('#sd_load_preset_primary').on('click', onLoadPresetPrimaryClick); |
| 6082 | 6312 | $('#sd_load_preset_secondary').on('click', onLoadPresetSecondaryClick); |
| 6083 | 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 | 6324 | $('#sd_character_prompt_block').hide(); |
| 6085 | 6325 | $('#sd_interactive_mode').on('input', onInteractiveModeInput); |
| 6086 | 6326 | $('#sd_openai_style').on('change', onOpenAiStyleSelect); |
| @@ -688,4 +688,20 @@ | ||
| 688 | 688 | <div id="sd_prompt_templates" class="inline-drawer-content"> |
| 689 | 689 | </div> |
| 690 | 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 | 707 | </div> |
| @@ -87,3 +87,17 @@ | ||
| 87 | 87 | top: 0; |
| 88 | 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 | +} | |