Stable Diffusion: user-defined custom wand dropdown entries Reviewed-by: auto-review
| @@ -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 | }; |
| 129 | 130 | ||
| 130 | const multimodalMap = { | 131 | const 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 | }; |
| 151 | 153 | ||
| 152 | const triggerWords = { | 154 | const triggerWords = { |
| @@ -368,6 +370,9 @@ const defaultSettings = { | |||
| 368 | 370 | ||
| 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 | }; |
| 372 | 377 | ||
| 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 | } |
| 616 | 621 | ||
| 622 | if (!Array.isArray(extension_settings.sd.custom_entries)) { | ||
| 623 | extension_settings.sd.custom_entries = []; | ||
| 624 | } | ||
| 625 | |||
| 617 | // Preserve an original seed if exists | 626 | // 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() { | |||
| 696 | 705 | ||
| 697 | toggleSourceControls(); | 706 | toggleSourceControls(); |
| 698 | addPromptTemplates(); | 707 | addPromptTemplates(); |
| 708 | renderCustomEntriesList(); | ||
| 709 | renderCustomDropdownEntries(); | ||
| 699 | registerFunctionTool(); | 710 | registerFunctionTool(); |
| 700 | 711 | ||
| 701 | await loadSettingOptions(); | 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 | * Modifies prompt based on user inputs. | 1151 | * Modifies prompt based on user inputs. |
| 971 | * @param {string} prompt Prompt to refine | 1152 | * @param {string} prompt Prompt to refine |
| 972 | * @param {object} [args] Additional arguments for refinement | 1153 | * @param {object} [args] Additional arguments for refinement |
| @@ -3021,6 +3202,16 @@ async function loadComfyWorkflows() { | |||
| 3021 | } | 3202 | } |
| 3022 | 3203 | ||
| 3023 | function getGenerationType(prompt) { | 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 | let mode = generationMode.FREE; | 3215 | let mode = generationMode.FREE; |
| 3025 | 3216 | ||
| 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 | } |
| 3045 | 3236 | ||
| 3046 | function getQuietPrompt(mode, trigger) { | 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 | 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 | }); |
| 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 | 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 | } |
| 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 | function isValidState() { | 5523 | function 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); |
| @@ -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> |
| @@ -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 | } | ||