Stable Diffusion: provider fallback chain + tagged reference image library - Generalize the primary/secondary settings presets into an ordered chain of named presets (arbitrary length, reorderable; old settings migrate automatically). With auto-fallback enabled, generation walks the chain: locally-hosted backends (ComfyUI, A1111, SD.Next, DrawThings, sd.cpp) are probed first and skipped after ~1.5s when the server is down, and failed attempts fall through to the next preset. Live settings are restored afterward. - Reference image library: upload images tagged with a name/description. When a ComfyUI workflow this generation could use contains the %reference_image% placeholder, the image-prompt LLM also picks the best-fitting image via a structured JSON line appended to the same request (with a dedicated second call as fallback), and the image is injected into the workflow as base64 like %user_avatar%/%char_avatar%.
| @@ -365,11 +365,14 @@ const defaultSettings = { | |||
| 365 | google_enhance: true, | 365 | google_enhance: true, |
| 366 | google_duration: 6, | 366 | google_duration: 6, |
| 367 | 367 | ||
| 368 | // Settings presets & auto-fallback | 368 | // Settings presets & auto-fallback ({ name, preset } entries, tried in order) |
| 369 | settings_preset_primary: null, | 369 | settings_preset_chain: [], |
| 370 | settings_preset_secondary: null, | ||
| 371 | settings_fallback_enabled: false, | 370 | settings_fallback_enabled: false, |
| 372 | 371 | ||
| 372 | // Reference image library ({ tag, description, path } entries) | ||
| 373 | ref_images_enabled: false, | ||
| 374 | ref_images: [], | ||
| 375 | |||
| 373 | // Dedicated LLM connection profile for image-prompt generation ('' = use active model) | 376 | // Dedicated LLM connection profile for image-prompt generation ('' = use active model) |
| 374 | prompt_generation_profile: '', | 377 | prompt_generation_profile: '', |
| 375 | 378 | ||
| @@ -384,9 +387,11 @@ const defaultSettings = { | |||
| 384 | * @type {string[]} | 387 | * @type {string[]} |
| 385 | */ | 388 | */ |
| 386 | const PRESET_EXCLUDE_KEYS = [ | 389 | const PRESET_EXCLUDE_KEYS = [ |
| 387 | 'settings_preset_primary', | 390 | 'settings_preset_chain', |
| 388 | 'settings_preset_secondary', | ||
| 389 | 'settings_fallback_enabled', | 391 | 'settings_fallback_enabled', |
| 392 | // The reference image library is global, not a per-backend setting. | ||
| 393 | 'ref_images_enabled', | ||
| 394 | 'ref_images', | ||
| 390 | // The image-prompt LLM profile is independent of the image backend, so it must | 395 | // The image-prompt LLM profile is independent of the image backend, so it must |
| 391 | // never be captured/swapped by image-generation presets or the fallback retry. | 396 | // never be captured/swapped by image-generation presets or the fallback retry. |
| 392 | 'prompt_generation_profile', | 397 | 'prompt_generation_profile', |
| @@ -445,6 +450,63 @@ function isPresetConfigured(preset) { | |||
| 445 | } | 450 | } |
| 446 | 451 | ||
| 447 | /** | 452 | /** |
| 453 | * Returns the fallback chain entries that hold a usable preset snapshot, in order. | ||
| 454 | * @returns {{name: string, preset: object}[]} Ordered list of configured chain entries. | ||
| 455 | */ | ||
| 456 | function getConfiguredPresetChain() { | ||
| 457 | const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : []; | ||
| 458 | return chain.filter(entry => entry && isPresetConfigured(entry.preset)); | ||
| 459 | } | ||
| 460 | |||
| 461 | /** | ||
| 462 | * How long to wait for a locally-hosted backend's status endpoint before treating | ||
| 463 | * the server as down and moving on to the next entry in the fallback chain. | ||
| 464 | */ | ||
| 465 | const SOURCE_PROBE_TIMEOUT_MS = 1500; | ||
| 466 | |||
| 467 | /** | ||
| 468 | * Quickly checks whether the currently configured source is up. Only locally-hosted | ||
| 469 | * backends with a status endpoint are probed (ComfyUI, A1111, SD.Next, DrawThings, | ||
| 470 | * stable-diffusion.cpp); sources without a probe are assumed reachable. | ||
| 471 | * @returns {Promise<boolean>} False when the backend has a probe and it failed. | ||
| 472 | */ | ||
| 473 | async function isCurrentSourceReachable() { | ||
| 474 | /** | ||
| 475 | * @param {string} endpoint ST server ping route for the backend. | ||
| 476 | * @param {object} body Request body identifying the backend server. | ||
| 477 | * @returns {Promise<boolean>} Whether the ping succeeded within the timeout. | ||
| 478 | */ | ||
| 479 | const probe = async (endpoint, body) => { | ||
| 480 | try { | ||
| 481 | const result = await fetch(endpoint, { | ||
| 482 | method: 'POST', | ||
| 483 | headers: getRequestHeaders(), | ||
| 484 | signal: AbortSignal.timeout(SOURCE_PROBE_TIMEOUT_MS), | ||
| 485 | body: JSON.stringify(body), | ||
| 486 | }); | ||
| 487 | return result.ok; | ||
| 488 | } catch { | ||
| 489 | return false; | ||
| 490 | } | ||
| 491 | }; | ||
| 492 | |||
| 493 | switch (extension_settings.sd.source) { | ||
| 494 | case sources.comfy: | ||
| 495 | return extension_settings.sd.comfy_type === comfyTypes.standard | ||
| 496 | ? probe('/api/sd/comfy/ping', { url: extension_settings.sd.comfy_url }) | ||
| 497 | : true; | ||
| 498 | case sources.auto: | ||
| 499 | case sources.vlad: | ||
| 500 | case sources.drawthings: | ||
| 501 | return probe(extension_settings.sd.source === sources.drawthings ? '/api/sd/drawthings/ping' : '/api/sd/ping', getSdRequestBody()); | ||
| 502 | case sources.sdcpp: | ||
| 503 | return probe('/api/sd/sdcpp/ping', { url: extension_settings.sd.sdcpp_url }); | ||
| 504 | default: | ||
| 505 | return true; | ||
| 506 | } | ||
| 507 | } | ||
| 508 | |||
| 509 | /** | ||
| 448 | * Refreshes all settings UI controls to reflect the current extension_settings.sd values. | 510 | * Refreshes all settings UI controls to reflect the current extension_settings.sd values. |
| 449 | * Used after loading a settings preset. | 511 | * Used after loading a settings preset. |
| 450 | * @returns {Promise<void>} | 512 | * @returns {Promise<void>} |
| @@ -620,18 +682,33 @@ async function loadSettings() { | |||
| 620 | } | 682 | } |
| 621 | 683 | ||
| 622 | // Settings presets & auto-fallback | 684 | // Settings presets & auto-fallback |
| 623 | if (extension_settings.sd.settings_preset_primary === undefined) { | 685 | if (!Array.isArray(extension_settings.sd.settings_preset_chain)) { |
| 624 | extension_settings.sd.settings_preset_primary = null; | 686 | // Migrate the old two-slot primary/secondary presets into a chain. |
| 625 | } | 687 | const chain = []; |
| 626 | 688 | if (isPresetConfigured(extension_settings.sd.settings_preset_primary)) { | |
| 627 | if (extension_settings.sd.settings_preset_secondary === undefined) { | 689 | chain.push({ name: 'Primary', preset: extension_settings.sd.settings_preset_primary }); |
| 628 | extension_settings.sd.settings_preset_secondary = null; | 690 | } |
| 691 | if (isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | ||
| 692 | chain.push({ name: 'Secondary', preset: extension_settings.sd.settings_preset_secondary }); | ||
| 693 | } | ||
| 694 | extension_settings.sd.settings_preset_chain = chain; | ||
| 695 | delete extension_settings.sd.settings_preset_primary; | ||
| 696 | delete extension_settings.sd.settings_preset_secondary; | ||
| 629 | } | 697 | } |
| 630 | 698 | ||
| 631 | if (extension_settings.sd.settings_fallback_enabled === undefined) { | 699 | if (extension_settings.sd.settings_fallback_enabled === undefined) { |
| 632 | extension_settings.sd.settings_fallback_enabled = false; | 700 | extension_settings.sd.settings_fallback_enabled = false; |
| 633 | } | 701 | } |
| 634 | 702 | ||
| 703 | // Reference image library | ||
| 704 | if (extension_settings.sd.ref_images_enabled === undefined) { | ||
| 705 | extension_settings.sd.ref_images_enabled = false; | ||
| 706 | } | ||
| 707 | |||
| 708 | if (!Array.isArray(extension_settings.sd.ref_images)) { | ||
| 709 | extension_settings.sd.ref_images = []; | ||
| 710 | } | ||
| 711 | |||
| 635 | if (!Array.isArray(extension_settings.sd.custom_entries)) { | 712 | if (!Array.isArray(extension_settings.sd.custom_entries)) { |
| 636 | extension_settings.sd.custom_entries = []; | 713 | extension_settings.sd.custom_entries = []; |
| 637 | } | 714 | } |
| @@ -702,6 +779,9 @@ async function loadSettings() { | |||
| 702 | $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance); | 779 | $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance); |
| 703 | $('#sd_google_duration').val(extension_settings.sd.google_duration); | 780 | $('#sd_google_duration').val(extension_settings.sd.google_duration); |
| 704 | $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled); | 781 | $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled); |
| 782 | $('#sd_ref_images_enabled').prop('checked', extension_settings.sd.ref_images_enabled); | ||
| 783 | renderPresetChain(); | ||
| 784 | renderRefImages(); | ||
| 705 | 785 | ||
| 706 | for (const style of extension_settings.sd.styles) { | 786 | for (const style of extension_settings.sd.styles) { |
| 707 | const option = document.createElement('option'); | 787 | const option = document.createElement('option'); |
| @@ -949,48 +1029,423 @@ async function onRenameStyleClick() { | |||
| 949 | saveSettingsDebounced(); | 1029 | saveSettingsDebounced(); |
| 950 | } | 1030 | } |
| 951 | 1031 | ||
| 952 | function onSavePresetPrimaryClick() { | 1032 | /** |
| 953 | extension_settings.sd.settings_preset_primary = snapshotSdSettings(); | 1033 | * Rebuilds the provider fallback chain list in the settings UI. |
| 1034 | */ | ||
| 1035 | function renderPresetChain() { | ||
| 1036 | const container = $('#sd_preset_chain_list'); | ||
| 1037 | if (!container.length) { | ||
| 1038 | return; | ||
| 1039 | } | ||
| 1040 | |||
| 1041 | container.empty(); | ||
| 1042 | |||
| 1043 | const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : []; | ||
| 1044 | |||
| 1045 | if (chain.length === 0) { | ||
| 1046 | const empty = $('<small></small>') | ||
| 1047 | .attr('data-i18n', 'No presets in the chain yet.') | ||
| 1048 | .text('No presets in the chain yet.'); | ||
| 1049 | container.append(empty); | ||
| 1050 | return; | ||
| 1051 | } | ||
| 1052 | |||
| 1053 | chain.forEach((entry, index) => { | ||
| 1054 | const orderEl = $('<div></div>').addClass('sd_preset_chain_order').text(`${index + 1}.`); | ||
| 1055 | const sourceHint = String(entry.preset?.source ?? ''); | ||
| 1056 | const nameInput = $('<input>') | ||
| 1057 | .addClass('text_pole flex1') | ||
| 1058 | .attr('type', 'text') | ||
| 1059 | .attr('title', sourceHint ? `Source: ${sourceHint}` : '') | ||
| 1060 | .val(entry.name || '') | ||
| 1061 | .on('change', function () { | ||
| 1062 | entry.name = String($(this).val() ?? '').trim() || `Preset ${index + 1}`; | ||
| 1063 | saveSettingsDebounced(); | ||
| 1064 | }); | ||
| 1065 | |||
| 1066 | const makeButton = (icon, title, handler) => $('<div></div>') | ||
| 1067 | .addClass(`menu_button menu_button_icon fa-solid ${icon}`) | ||
| 1068 | .attr('title', title) | ||
| 1069 | .attr('data-i18n', `[title]${title}`) | ||
| 1070 | .on('click', handler); | ||
| 1071 | |||
| 1072 | const upButton = makeButton('fa-chevron-up', 'Move up', () => { | ||
| 1073 | if (index === 0) return; | ||
| 1074 | [chain[index - 1], chain[index]] = [chain[index], chain[index - 1]]; | ||
| 1075 | saveSettingsDebounced(); | ||
| 1076 | renderPresetChain(); | ||
| 1077 | }); | ||
| 1078 | const downButton = makeButton('fa-chevron-down', 'Move down', () => { | ||
| 1079 | if (index === chain.length - 1) return; | ||
| 1080 | [chain[index + 1], chain[index]] = [chain[index], chain[index + 1]]; | ||
| 1081 | saveSettingsDebounced(); | ||
| 1082 | renderPresetChain(); | ||
| 1083 | }); | ||
| 1084 | const loadButton = makeButton('fa-file-import', 'Load this preset into the current settings', async () => { | ||
| 1085 | applySdSettingsSnapshot(entry.preset); | ||
| 1086 | saveSettingsDebounced(); | ||
| 1087 | await refreshSettingsUi(); | ||
| 1088 | toastr.success(t`Settings preset loaded.`, t`Image Generation`); | ||
| 1089 | }); | ||
| 1090 | const saveButton = makeButton('fa-floppy-disk', 'Overwrite this preset with the current settings', () => { | ||
| 1091 | entry.preset = snapshotSdSettings(); | ||
| 1092 | saveSettingsDebounced(); | ||
| 1093 | renderPresetChain(); | ||
| 1094 | toastr.success(t`Settings preset updated.`, t`Image Generation`); | ||
| 1095 | }); | ||
| 1096 | const deleteButton = makeButton('fa-trash-can', 'Remove from the chain', () => { | ||
| 1097 | chain.splice(index, 1); | ||
| 1098 | saveSettingsDebounced(); | ||
| 1099 | renderPresetChain(); | ||
| 1100 | }); | ||
| 1101 | |||
| 1102 | const row = $('<div></div>') | ||
| 1103 | .addClass('flex-container alignItemsCenter marginTopBot5') | ||
| 1104 | .append(orderEl) | ||
| 1105 | .append(nameInput) | ||
| 1106 | .append(upButton) | ||
| 1107 | .append(downButton) | ||
| 1108 | .append(loadButton) | ||
| 1109 | .append(saveButton) | ||
| 1110 | .append(deleteButton); | ||
| 1111 | |||
| 1112 | container.append(row); | ||
| 1113 | }); | ||
| 1114 | } | ||
| 1115 | |||
| 1116 | function onPresetChainAddClick() { | ||
| 1117 | const nameInput = $('#sd_preset_chain_name'); | ||
| 1118 | const name = String(nameInput.val() ?? '').trim(); | ||
| 1119 | const chain = extension_settings.sd.settings_preset_chain; | ||
| 1120 | chain.push({ name: name || `Preset ${chain.length + 1}`, preset: snapshotSdSettings() }); | ||
| 1121 | nameInput.val(''); | ||
| 954 | saveSettingsDebounced(); | 1122 | saveSettingsDebounced(); |
| 955 | toastr.success(t`Primary settings preset saved.`, t`Image Generation`); | 1123 | renderPresetChain(); |
| 1124 | toastr.success(t`Current settings added to the fallback chain.`, t`Image Generation`); | ||
| 956 | } | 1125 | } |
| 957 | 1126 | ||
| 958 | function onSavePresetSecondaryClick() { | 1127 | function onFallbackEnabledChange() { |
| 959 | extension_settings.sd.settings_preset_secondary = snapshotSdSettings(); | 1128 | extension_settings.sd.settings_fallback_enabled = !!$(this).prop('checked'); |
| 960 | saveSettingsDebounced(); | 1129 | saveSettingsDebounced(); |
| 961 | toastr.success(t`Secondary settings preset saved.`, t`Image Generation`); | ||
| 962 | } | 1130 | } |
| 963 | 1131 | ||
| 964 | async function onLoadPresetPrimaryClick() { | 1132 | // #region Reference image library |
| 965 | if (!isPresetConfigured(extension_settings.sd.settings_preset_primary)) { | 1133 | |
| 966 | toastr.info(t`No primary settings preset has been saved yet.`, t`Image Generation`); | 1134 | /** |
| 1135 | * Matches the reference image placeholder in a raw ComfyUI workflow ("%reference_image%" or "%reference-image%"). | ||
| 1136 | */ | ||
| 1137 | const REFERENCE_IMAGE_PLACEHOLDER = /"%reference[-_]image%"/i; | ||
| 1138 | |||
| 1139 | /** | ||
| 1140 | * Reference image chosen for the in-flight generation. Set at prompt-generation time | ||
| 1141 | * (or lazily by the workflow builder) and read when the ComfyUI workflow is assembled. | ||
| 1142 | * Deliberately kept across swipe regenerations so a swipe reuses the same reference. | ||
| 1143 | * @type {{tag: string, description: string, path: string} | null} | ||
| 1144 | */ | ||
| 1145 | let pendingReferenceImage = null; | ||
| 1146 | |||
| 1147 | /** | ||
| 1148 | * Returns library entries that have an uploaded image. | ||
| 1149 | * @returns {{tag: string, description: string, path: string}[]} Valid reference images. | ||
| 1150 | */ | ||
| 1151 | function getValidRefImages() { | ||
| 1152 | const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : []; | ||
| 1153 | return images.filter(image => image && typeof image.path === 'string' && image.path.length > 0); | ||
| 1154 | } | ||
| 1155 | |||
| 1156 | /** | ||
| 1157 | * Rebuilds the reference image library list in the settings UI. | ||
| 1158 | */ | ||
| 1159 | function renderRefImages() { | ||
| 1160 | const container = $('#sd_ref_images_list'); | ||
| 1161 | if (!container.length) { | ||
| 967 | return; | 1162 | return; |
| 968 | } | 1163 | } |
| 969 | 1164 | ||
| 970 | applySdSettingsSnapshot(extension_settings.sd.settings_preset_primary); | 1165 | container.empty(); |
| 971 | saveSettingsDebounced(); | ||
| 972 | await refreshSettingsUi(); | ||
| 973 | toastr.success(t`Primary settings preset loaded.`, t`Image Generation`); | ||
| 974 | } | ||
| 975 | 1166 | ||
| 976 | async function onLoadPresetSecondaryClick() { | 1167 | const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : []; |
| 977 | if (!isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | 1168 | |
| 978 | toastr.info(t`No secondary settings preset has been saved yet.`, t`Image Generation`); | 1169 | if (images.length === 0) { |
| 1170 | const empty = $('<small></small>') | ||
| 1171 | .attr('data-i18n', 'No reference images yet.') | ||
| 1172 | .text('No reference images yet.'); | ||
| 1173 | container.append(empty); | ||
| 979 | return; | 1174 | return; |
| 980 | } | 1175 | } |
| 981 | 1176 | ||
| 982 | applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary); | 1177 | images.forEach((image, index) => { |
| 1178 | const thumb = $('<img>') | ||
| 1179 | .addClass('sd_ref_image_thumb') | ||
| 1180 | .attr('src', image.path) | ||
| 1181 | .attr('alt', image.tag || ''); | ||
| 1182 | const tagInput = $('<input>') | ||
| 1183 | .addClass('text_pole') | ||
| 1184 | .attr('type', 'text') | ||
| 1185 | .attr('placeholder', 'Tag') | ||
| 1186 | .attr('data-i18n', '[placeholder]Tag') | ||
| 1187 | .val(image.tag || '') | ||
| 1188 | .on('change', function () { | ||
| 1189 | image.tag = String($(this).val() ?? '').trim(); | ||
| 1190 | saveSettingsDebounced(); | ||
| 1191 | }); | ||
| 1192 | const descriptionInput = $('<input>') | ||
| 1193 | .addClass('text_pole flex1') | ||
| 1194 | .attr('type', 'text') | ||
| 1195 | .attr('placeholder', 'Description (used to pick the best fit)') | ||
| 1196 | .attr('data-i18n', '[placeholder]Description (used to pick the best fit)') | ||
| 1197 | .val(image.description || '') | ||
| 1198 | .on('change', function () { | ||
| 1199 | image.description = String($(this).val() ?? '').trim(); | ||
| 1200 | saveSettingsDebounced(); | ||
| 1201 | }); | ||
| 1202 | const deleteButton = $('<div></div>') | ||
| 1203 | .addClass('menu_button menu_button_icon fa-solid fa-trash-can') | ||
| 1204 | .attr('title', 'Remove reference image') | ||
| 1205 | .attr('data-i18n', '[title]Remove reference image') | ||
| 1206 | .on('click', () => { | ||
| 1207 | images.splice(index, 1); | ||
| 1208 | saveSettingsDebounced(); | ||
| 1209 | renderRefImages(); | ||
| 1210 | }); | ||
| 1211 | |||
| 1212 | const row = $('<div></div>') | ||
| 1213 | .addClass('flex-container alignItemsCenter marginTopBot5') | ||
| 1214 | .append(thumb) | ||
| 1215 | .append(tagInput) | ||
| 1216 | .append(descriptionInput) | ||
| 1217 | .append(deleteButton); | ||
| 1218 | |||
| 1219 | container.append(row); | ||
| 1220 | }); | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | function onRefImagesEnabledChange() { | ||
| 1224 | extension_settings.sd.ref_images_enabled = !!$(this).prop('checked'); | ||
| 983 | saveSettingsDebounced(); | 1225 | saveSettingsDebounced(); |
| 984 | await refreshSettingsUi(); | ||
| 985 | toastr.success(t`Secondary settings preset loaded.`, t`Image Generation`); | ||
| 986 | } | 1226 | } |
| 987 | 1227 | ||
| 988 | function onFallbackEnabledChange() { | 1228 | async function onRefImagesFileChange() { |
| 989 | extension_settings.sd.settings_fallback_enabled = !!$(this).prop('checked'); | 1229 | const files = Array.from(this.files ?? []); |
| 1230 | this.value = ''; | ||
| 1231 | |||
| 1232 | for (const file of files) { | ||
| 1233 | try { | ||
| 1234 | const dataUrl = await getBase64Async(file); | ||
| 1235 | const base64 = String(dataUrl).split(',')[1]; | ||
| 1236 | const extension = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg'); | ||
| 1237 | const baseName = file.name.replace(/\.[^/.]+$/, ''); | ||
| 1238 | const path = await saveBase64AsFile(base64, 'reference-images', baseName, extension); | ||
| 1239 | extension_settings.sd.ref_images.push({ tag: baseName, description: '', path }); | ||
| 1240 | } catch (error) { | ||
| 1241 | console.error('SD: failed to add reference image', error); | ||
| 1242 | toastr.error(String(error), t`Image Generation`); | ||
| 1243 | } | ||
| 1244 | } | ||
| 1245 | |||
| 990 | saveSettingsDebounced(); | 1246 | saveSettingsDebounced(); |
| 1247 | renderRefImages(); | ||
| 1248 | } | ||
| 1249 | |||
| 1250 | /** | ||
| 1251 | * Collects the ComfyUI workflow file names that could be used by this generation: | ||
| 1252 | * the live settings' workflow plus, when the fallback chain is enabled, the workflow | ||
| 1253 | * of every comfy preset in the chain. | ||
| 1254 | * @returns {string[]} Unique workflow file names. | ||
| 1255 | */ | ||
| 1256 | function collectCandidateComfyWorkflows() { | ||
| 1257 | const names = new Set(); | ||
| 1258 | /** @param {object} config A settings-shaped object (live settings or a preset snapshot). */ | ||
| 1259 | const consider = (config) => { | ||
| 1260 | if (config && config.source === sources.comfy && config.comfy_type === comfyTypes.standard && config.comfy_workflow) { | ||
| 1261 | names.add(config.comfy_workflow); | ||
| 1262 | } | ||
| 1263 | }; | ||
| 1264 | consider(extension_settings.sd); | ||
| 1265 | if (extension_settings.sd.settings_fallback_enabled) { | ||
| 1266 | for (const entry of getConfiguredPresetChain()) { | ||
| 1267 | consider(entry.preset); | ||
| 1268 | } | ||
| 1269 | } | ||
| 1270 | return [...names]; | ||
| 1271 | } | ||
| 1272 | |||
| 1273 | /** | ||
| 1274 | * Checks whether any workflow this generation could run contains the reference image placeholder. | ||
| 1275 | * @returns {Promise<boolean>} True when a candidate workflow uses the placeholder. | ||
| 1276 | */ | ||
| 1277 | async function anyCandidateWorkflowUsesReferenceImage() { | ||
| 1278 | for (const fileName of collectCandidateComfyWorkflows()) { | ||
| 1279 | try { | ||
| 1280 | const result = await fetch('/api/sd/comfy/workflow', { | ||
| 1281 | method: 'POST', | ||
| 1282 | headers: getRequestHeaders(), | ||
| 1283 | body: JSON.stringify({ file_name: fileName }), | ||
| 1284 | }); | ||
| 1285 | if (!result.ok) { | ||
| 1286 | continue; | ||
| 1287 | } | ||
| 1288 | const workflow = await result.json(); | ||
| 1289 | if (REFERENCE_IMAGE_PLACEHOLDER.test(String(workflow))) { | ||
| 1290 | return true; | ||
| 1291 | } | ||
| 1292 | } catch (error) { | ||
| 1293 | console.warn('SD: could not inspect workflow for reference image placeholder', fileName, error); | ||
| 1294 | } | ||
| 1295 | } | ||
| 1296 | return false; | ||
| 1297 | } | ||
| 1298 | |||
| 1299 | /** | ||
| 1300 | * Returns the reference images to choose from for this generation, or an empty array | ||
| 1301 | * when the feature is disabled, the library is empty, or no candidate workflow uses | ||
| 1302 | * the placeholder. | ||
| 1303 | * @returns {Promise<{tag: string, description: string, path: string}[]>} Selectable reference images. | ||
| 1304 | */ | ||
| 1305 | async function getEligibleReferenceImages() { | ||
| 1306 | if (!extension_settings.sd.ref_images_enabled) { | ||
| 1307 | return []; | ||
| 1308 | } | ||
| 1309 | const images = getValidRefImages(); | ||
| 1310 | if (images.length === 0) { | ||
| 1311 | return []; | ||
| 1312 | } | ||
| 1313 | if (!(await anyCandidateWorkflowUsesReferenceImage())) { | ||
| 1314 | return []; | ||
| 1315 | } | ||
| 1316 | return images; | ||
| 1317 | } | ||
| 1318 | |||
| 1319 | /** | ||
| 1320 | * Builds the instruction appended to the image-prompt request that makes the LLM | ||
| 1321 | * also pick a reference image, as a machine-readable JSON line. | ||
| 1322 | * @param {{tag: string, description: string}[]} candidates Reference images to choose from. | ||
| 1323 | * @returns {string} Instruction text. | ||
| 1324 | */ | ||
| 1325 | function buildReferenceSelectionAddendum(candidates) { | ||
| 1326 | const list = candidates.map(x => `- "${x.tag}": ${x.description || 'no description'}`).join('\n'); | ||
| 1327 | return [ | ||
| 1328 | '', | ||
| 1329 | 'After the image prompt, append one final line containing exactly this JSON and nothing else:', | ||
| 1330 | '{"reference_image": "<tag>"}', | ||
| 1331 | 'where <tag> is the tag of the reference image whose description best fits the requested scene. Available reference images:', | ||
| 1332 | list, | ||
| 1333 | ].join('\n'); | ||
| 1334 | } | ||
| 1335 | |||
| 1336 | /** | ||
| 1337 | * Finds the library entry whose tag matches the given text. | ||
| 1338 | * @param {string} text Tag text returned by the LLM. | ||
| 1339 | * @param {{tag: string}[]} candidates Reference images to match against. | ||
| 1340 | * @returns {object|null} The matching entry, or null. | ||
| 1341 | */ | ||
| 1342 | function matchReferenceTag(text, candidates) { | ||
| 1343 | const needle = String(text ?? '').trim().toLowerCase(); | ||
| 1344 | if (!needle) { | ||
| 1345 | return null; | ||
| 1346 | } | ||
| 1347 | const tagged = candidates.filter(x => String(x.tag ?? '').trim().length > 0); | ||
| 1348 | return tagged.find(x => x.tag.trim().toLowerCase() === needle) | ||
| 1349 | ?? tagged.find(x => needle.includes(x.tag.trim().toLowerCase())) | ||
| 1350 | ?? null; | ||
| 1351 | } | ||
| 1352 | |||
| 1353 | /** | ||
| 1354 | * Extracts the {"reference_image": "..."} selection from a combined prompt+selection reply. | ||
| 1355 | * @param {string} reply Raw LLM reply. | ||
| 1356 | * @param {{tag: string}[]} candidates Reference images to match against. | ||
| 1357 | * @returns {{cleaned: string, selected: object|null}} Reply without the JSON line, and the matched entry. | ||
| 1358 | */ | ||
| 1359 | function extractReferenceSelection(reply, candidates) { | ||
| 1360 | const pattern = /\{\s*"?reference_image"?\s*:\s*"([^"]*)"\s*\}/gi; | ||
| 1361 | let match; | ||
| 1362 | let lastTag = null; | ||
| 1363 | while ((match = pattern.exec(reply)) !== null) { | ||
| 1364 | lastTag = match[1]; | ||
| 1365 | } | ||
| 1366 | const cleaned = reply.replace(/\{\s*"?reference_image"?\s*:\s*"[^"]*"\s*\}/gi, ' '); | ||
| 1367 | return { cleaned, selected: lastTag ? matchReferenceTag(lastTag, candidates) : null }; | ||
| 1368 | } | ||
| 1369 | |||
| 1370 | /** | ||
| 1371 | * Asks the image-prompt LLM to pick the best-fitting reference image for a scene | ||
| 1372 | * in a dedicated (second) request. | ||
| 1373 | * @param {string} prompt The final image prompt describing the scene. | ||
| 1374 | * @param {{tag: string, description: string}[]} candidates Reference images to choose from. | ||
| 1375 | * @returns {Promise<object|null>} The matched entry, or null. | ||
| 1376 | */ | ||
| 1377 | async function selectReferenceImageWithLlm(prompt, candidates) { | ||
| 1378 | const list = candidates.map(x => `- "${x.tag}": ${x.description || 'no description'}`).join('\n'); | ||
| 1379 | const quietPrompt = [ | ||
| 1380 | 'Pause your roleplay. An image is being generated for the current scene from this prompt:', | ||
| 1381 | prompt, | ||
| 1382 | '', | ||
| 1383 | 'Pick the reference image whose description best fits that scene:', | ||
| 1384 | list, | ||
| 1385 | '', | ||
| 1386 | 'Reply with ONLY the tag of the chosen reference image and nothing else.', | ||
| 1387 | ].join('\n'); | ||
| 1388 | const profileId = extension_settings.sd.prompt_generation_profile; | ||
| 1389 | const reply = profileId | ||
| 1390 | ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt })) | ||
| 1391 | : await generateQuietPrompt({ quietPrompt }); | ||
| 1392 | return matchReferenceTag(String(reply ?? '').trim(), candidates); | ||
| 991 | } | 1393 | } |
| 992 | 1394 | ||
| 993 | /** | 1395 | /** |
| 1396 | * Resolves which reference image the current ComfyUI generation should use. | ||
| 1397 | * Prefers the selection made together with the image prompt; falls back to a | ||
| 1398 | * dedicated LLM call, and finally to the first library image. | ||
| 1399 | * @param {string} prompt The image prompt describing the scene. | ||
| 1400 | * @returns {Promise<object|null>} The reference image to use, or null when the feature is off/empty. | ||
| 1401 | */ | ||
| 1402 | async function resolveReferenceImageForGeneration(prompt) { | ||
| 1403 | if (!extension_settings.sd.ref_images_enabled) { | ||
| 1404 | return null; | ||
| 1405 | } | ||
| 1406 | const images = getValidRefImages(); | ||
| 1407 | if (images.length === 0) { | ||
| 1408 | return null; | ||
| 1409 | } | ||
| 1410 | if (pendingReferenceImage && images.some(x => x.path === pendingReferenceImage.path)) { | ||
| 1411 | return pendingReferenceImage; | ||
| 1412 | } | ||
| 1413 | if (images.length === 1) { | ||
| 1414 | pendingReferenceImage = images[0]; | ||
| 1415 | return pendingReferenceImage; | ||
| 1416 | } | ||
| 1417 | try { | ||
| 1418 | pendingReferenceImage = await selectReferenceImageWithLlm(prompt, images) ?? images[0]; | ||
| 1419 | } catch (error) { | ||
| 1420 | console.error('SD: reference image selection failed, using the first library image', error); | ||
| 1421 | pendingReferenceImage = images[0]; | ||
| 1422 | } | ||
| 1423 | return pendingReferenceImage; | ||
| 1424 | } | ||
| 1425 | |||
| 1426 | /** | ||
| 1427 | * Loads a reference image and returns it as a raw base64 string (no data URL header). | ||
| 1428 | * @param {{path: string}} refImage Reference image entry. | ||
| 1429 | * @returns {Promise<string|null>} Base64 image data, or null on failure. | ||
| 1430 | */ | ||
| 1431 | async function fetchReferenceImageBase64(refImage) { | ||
| 1432 | try { | ||
| 1433 | const response = await fetch(refImage.path); | ||
| 1434 | if (!response.ok) { | ||
| 1435 | throw new Error(`HTTP ${response.status}`); | ||
| 1436 | } | ||
| 1437 | const blob = await response.blob(); | ||
| 1438 | const dataUrl = await getBase64Async(blob); | ||
| 1439 | return String(dataUrl).split(',')[1] ?? null; | ||
| 1440 | } catch (error) { | ||
| 1441 | console.error('SD: could not load reference image', refImage.path, error); | ||
| 1442 | return null; | ||
| 1443 | } | ||
| 1444 | } | ||
| 1445 | |||
| 1446 | // #endregion | ||
| 1447 | |||
| 1448 | /** | ||
| 994 | * Rebuilds the custom wand entries list in the settings UI. | 1449 | * Rebuilds the custom wand entries list in the settings UI. |
| 995 | */ | 1450 | */ |
| 996 | function renderCustomEntriesList() { | 1451 | function renderCustomEntriesList() { |
| @@ -3421,6 +3876,9 @@ async function generatePicture(initiator, args, trigger, message, callback) { | |||
| 3421 | try { | 3876 | try { |
| 3422 | const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); }; | 3877 | const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); }; |
| 3423 | 3878 | ||
| 3879 | // Each new generation picks its own reference image (swipes reuse the last one). | ||
| 3880 | pendingReferenceImage = null; | ||
| 3881 | |||
| 3424 | // generate the text prompt for the image | 3882 | // generate the text prompt for the image |
| 3425 | let prompt = await getPrompt(generationType, message, trigger, quietPrompt, combineNegatives); | 3883 | let prompt = await getPrompt(generationType, message, trigger, quietPrompt, combineNegatives); |
| 3426 | console.log('Processed image prompt:', prompt); | 3884 | console.log('Processed image prompt:', prompt); |
| @@ -3668,14 +4126,33 @@ async function generatePrompt(quietPrompt) { | |||
| 3668 | const profileId = extension_settings.sd.prompt_generation_profile; | 4126 | const profileId = extension_settings.sd.prompt_generation_profile; |
| 3669 | let reply; | 4127 | let reply; |
| 3670 | 4128 | ||
| 4129 | // When the workflow uses a reference image and there is more than one to choose | ||
| 4130 | // from, have the same LLM request return the selection along with the prompt. | ||
| 4131 | const refCandidates = await getEligibleReferenceImages(); | ||
| 4132 | if (refCandidates.length === 1) { | ||
| 4133 | pendingReferenceImage = refCandidates[0]; | ||
| 4134 | } | ||
| 4135 | const combineReferenceSelection = refCandidates.length > 1; | ||
| 4136 | const effectiveQuietPrompt = combineReferenceSelection | ||
| 4137 | ? quietPrompt + '\n' + buildReferenceSelectionAddendum(refCandidates) | ||
| 4138 | : quietPrompt; | ||
| 4139 | |||
| 3671 | try { | 4140 | try { |
| 3672 | reply = profileId | 4141 | reply = profileId |
| 3673 | ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt })) | 4142 | ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt: effectiveQuietPrompt })) |
| 3674 | : await generateQuietPrompt({ quietPrompt }); | 4143 | : await generateQuietPrompt({ quietPrompt: effectiveQuietPrompt }); |
| 3675 | } finally { | 4144 | } finally { |
| 3676 | toastr.clear(toast); | 4145 | toastr.clear(toast); |
| 3677 | } | 4146 | } |
| 3678 | 4147 | ||
| 4148 | if (combineReferenceSelection) { | ||
| 4149 | const { cleaned, selected } = extractReferenceSelection(String(reply ?? ''), refCandidates); | ||
| 4150 | reply = cleaned; | ||
| 4151 | // No/invalid selection -> leave unset; the workflow builder retries with a dedicated call. | ||
| 4152 | pendingReferenceImage = selected; | ||
| 4153 | console.log('SD: reference image selected with the image prompt:', selected?.tag ?? '(none)'); | ||
| 4154 | } | ||
| 4155 | |||
| 3679 | const processedReply = processReply(reply); | 4156 | const processedReply = processReply(reply); |
| 3680 | 4157 | ||
| 3681 | if (!processedReply) { | 4158 | if (!processedReply) { |
| @@ -3878,38 +4355,58 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | |||
| 3878 | } | 4355 | } |
| 3879 | 4356 | ||
| 3880 | const currentChatId = getCurrentChatId(); | 4357 | const currentChatId = getCurrentChatId(); |
| 4358 | const fallbackChain = extension_settings.sd.settings_fallback_enabled ? getConfiguredPresetChain() : []; | ||
| 3881 | let genOutput; | 4359 | let genOutput; |
| 3882 | try { | ||
| 3883 | genOutput = await attemptImageGeneration(signal); | ||
| 3884 | } catch (err) { | ||
| 3885 | // Check if this was an intentional abort by user | ||
| 3886 | if (signal?.aborted) { | ||
| 3887 | console.log('SD: Image generation aborted by user'); | ||
| 3888 | toastr.info('Image generation stopped.', 'Image Generation'); | ||
| 3889 | return; | ||
| 3890 | } | ||
| 3891 | 4360 | ||
| 3892 | if (extension_settings.sd.settings_fallback_enabled && isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | 4361 | if (fallbackChain.length > 0) { |
| 3893 | console.warn('SD: primary generation failed, falling back to secondary preset', err); | 4362 | // Chain mode: try every preset in order. Locally-hosted backends are probed |
| 3894 | toastr.warning('Primary image generation failed. Retrying with secondary settings…', 'Image Generation'); | 4363 | // first so a powered-off server is skipped after ~1.5s instead of stalling |
| 3895 | const restore = snapshotSdSettings(); | 4364 | // the attempt. The live settings are restored afterward either way. |
| 3896 | try { | 4365 | const restore = snapshotSdSettings(); |
| 3897 | applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary); | 4366 | let lastError = new Error('No provider in the fallback chain was reachable.'); |
| 3898 | genOutput = await attemptImageGeneration(signal); | 4367 | try { |
| 3899 | } catch (err2) { | 4368 | for (const entry of fallbackChain) { |
| 3900 | applySdSettingsSnapshot(restore); | 4369 | applySdSettingsSnapshot(entry.preset); |
| 3901 | if (signal?.aborted) { | 4370 | |
| 3902 | console.log('SD: Image generation aborted by user'); | 4371 | if (!(await isCurrentSourceReachable())) { |
| 3903 | toastr.info('Image generation stopped.', 'Image Generation'); | 4372 | console.warn(`SD: chain entry "${entry.name}" is not reachable, skipping`); |
| 3904 | return; | 4373 | toastr.warning(`Provider "${entry.name}" is not reachable, trying the next one…`, 'Image Generation'); |
| 4374 | continue; | ||
| 4375 | } | ||
| 4376 | |||
| 4377 | try { | ||
| 4378 | genOutput = await attemptImageGeneration(signal); | ||
| 4379 | break; | ||
| 4380 | } catch (err) { | ||
| 4381 | if (signal?.aborted) { | ||
| 4382 | console.log('SD: Image generation aborted by user'); | ||
| 4383 | toastr.info('Image generation stopped.', 'Image Generation'); | ||
| 4384 | return; | ||
| 4385 | } | ||
| 4386 | lastError = err; | ||
| 4387 | console.error(`SD: generation with chain entry "${entry.name}" failed`, err); | ||
| 4388 | toastr.warning(`Provider "${entry.name}" failed, trying the next one…`, 'Image Generation'); | ||
| 3905 | } | 4389 | } |
| 3906 | console.error('SD: secondary generation also failed', err2); | ||
| 3907 | toastr.error('Image generation failed (primary and secondary).' + '\n\n' + String(err2), 'Image Generation'); | ||
| 3908 | return; | ||
| 3909 | } | 4390 | } |
| 3910 | // Restore live (primary) settings after a successful fallback attempt. | 4391 | } finally { |
| 3911 | applySdSettingsSnapshot(restore); | 4392 | applySdSettingsSnapshot(restore); |
| 3912 | } else { | 4393 | } |
| 4394 | |||
| 4395 | if (!genOutput) { | ||
| 4396 | toastr.error('Image generation failed for every provider in the fallback chain.' + '\n\n' + String(lastError), 'Image Generation'); | ||
| 4397 | return; | ||
| 4398 | } | ||
| 4399 | } else { | ||
| 4400 | try { | ||
| 4401 | genOutput = await attemptImageGeneration(signal); | ||
| 4402 | } catch (err) { | ||
| 4403 | // Check if this was an intentional abort by user | ||
| 4404 | if (signal?.aborted) { | ||
| 4405 | console.log('SD: Image generation aborted by user'); | ||
| 4406 | toastr.info('Image generation stopped.', 'Image Generation'); | ||
| 4407 | return; | ||
| 4408 | } | ||
| 4409 | |||
| 3913 | console.error('Image generation request error: ', err); | 4410 | console.error('Image generation request error: ', err); |
| 3914 | toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); | 4411 | toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); |
| 3915 | return; | 4412 | return; |
| @@ -4752,6 +5249,12 @@ async function generateComfyImageCommon(prompt, negativePrompt, signal, basePath | |||
| 4752 | workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL)); | 5249 | workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL)); |
| 4753 | } | 5250 | } |
| 4754 | } | 5251 | } |
| 5252 | if (REFERENCE_IMAGE_PLACEHOLDER.test(workflow)) { | ||
| 5253 | const refImage = await resolveReferenceImageForGeneration(prompt); | ||
| 5254 | const refBase64 = (refImage && await fetchReferenceImageBase64(refImage)) || PNG_PIXEL; | ||
| 5255 | workflow = workflow.replaceAll('"%reference_image%"', JSON.stringify(refBase64)); | ||
| 5256 | workflow = workflow.replaceAll('"%reference-image%"', JSON.stringify(refBase64)); | ||
| 5257 | } | ||
| 4755 | console.log(`{ | 5258 | console.log(`{ |
| 4756 | "prompt": ${workflow} | 5259 | "prompt": ${workflow} |
| 4757 | }`); | 5260 | }`); |
| @@ -6373,11 +6876,11 @@ export async function init() { | |||
| 6373 | $('#sd_save_style').on('click', onSaveStyleClick); | 6876 | $('#sd_save_style').on('click', onSaveStyleClick); |
| 6374 | $('#sd_rename_style').on('click', onRenameStyleClick); | 6877 | $('#sd_rename_style').on('click', onRenameStyleClick); |
| 6375 | $('#sd_delete_style').on('click', onDeleteStyleClick); | 6878 | $('#sd_delete_style').on('click', onDeleteStyleClick); |
| 6376 | $('#sd_save_preset_primary').on('click', onSavePresetPrimaryClick); | 6879 | $('#sd_preset_chain_add').on('click', onPresetChainAddClick); |
| 6377 | $('#sd_save_preset_secondary').on('click', onSavePresetSecondaryClick); | ||
| 6378 | $('#sd_load_preset_primary').on('click', onLoadPresetPrimaryClick); | ||
| 6379 | $('#sd_load_preset_secondary').on('click', onLoadPresetSecondaryClick); | ||
| 6380 | $('#sd_fallback_enabled').on('change', onFallbackEnabledChange); | 6880 | $('#sd_fallback_enabled').on('change', onFallbackEnabledChange); |
| 6881 | $('#sd_ref_images_enabled').on('change', onRefImagesEnabledChange); | ||
| 6882 | $('#sd_ref_images_add').on('click', () => $('#sd_ref_images_file').trigger('click')); | ||
| 6883 | $('#sd_ref_images_file').on('change', onRefImagesFileChange); | ||
| 6381 | $('#sd_custom_entry_add').on('click', onAddCustomEntryClick); | 6884 | $('#sd_custom_entry_add').on('click', onAddCustomEntryClick); |
| 6382 | $('#sd_custom_entries_list').on('click', '[data-action]', function () { | 6885 | $('#sd_custom_entries_list').on('click', '[data-action]', function () { |
| 6383 | const id = $(this).attr('data-entry-id'); | 6886 | const id = $(this).attr('data-entry-id'); |
| @@ -43,19 +43,36 @@ | |||
| 43 | <select id="sd_prompt_generation_profile" class="text_pole"></select> | 43 | <select id="sd_prompt_generation_profile" class="text_pole"></select> |
| 44 | <small data-i18n="sd_prompt_generation_profile_small">Connection profile always used to generate the image prompt text. Leave empty to use the currently active model.</small> | 44 | <small data-i18n="sd_prompt_generation_profile_small">Connection profile always used to generate the image prompt text. Leave empty to use the currently active model.</small> |
| 45 | <hr> | 45 | <hr> |
| 46 | <h4 data-i18n="Settings Presets & Fallback">Settings Presets & Fallback</h4> | 46 | <h4 data-i18n="Provider Fallback Chain">Provider Fallback Chain</h4> |
| 47 | <small data-i18n="sd_settings_presets_small">Save the current backend/connection settings (source, model, sampler, dimensions, etc.) as Primary or Secondary presets and load them back later. Prompt templates, styles, and custom entries are not included.</small> | 47 | <small data-i18n="sd_settings_presets_small">Save the current backend/connection settings (source, model, sampler, dimensions, etc.) as named presets and arrange them into an ordered chain. Prompt templates, styles, custom entries, and the reference image library are not included.</small> |
| 48 | <div class="flex-container marginTopBot5 flexWrap"> | 48 | <div id="sd_preset_chain_list" class="flex-container flexFlowColumn marginTopBot5"></div> |
| 49 | <div id="sd_save_preset_primary" class="menu_button" data-i18n="Save as Primary">Save as Primary</div> | 49 | <div class="flex-container marginTopBot5"> |
| 50 | <div id="sd_load_preset_primary" class="menu_button" data-i18n="Load Primary">Load Primary</div> | 50 | <input id="sd_preset_chain_name" type="text" class="text_pole flex1" placeholder="New preset name" data-i18n="[placeholder]New preset name" /> |
| 51 | <div id="sd_save_preset_secondary" class="menu_button" data-i18n="Save as Secondary">Save as Secondary</div> | 51 | <div id="sd_preset_chain_add" class="menu_button menu_button_icon"> |
| 52 | <div id="sd_load_preset_secondary" class="menu_button" data-i18n="Load Secondary">Load Secondary</div> | 52 | <i class="fa-solid fa-plus"></i> |
| 53 | </div> | 53 | <span data-i18n="Add current settings">Add current settings</span> |
| 54 | <label for="sd_fallback_enabled" class="checkbox_label" data-i18n="[title]sd_fallback_enabled" title="If an image generation fails, automatically retry the same prompt using the Secondary preset's settings."> | 54 | </div> |
| 55 | </div> | ||
| 56 | <label for="sd_fallback_enabled" class="checkbox_label" data-i18n="[title]sd_fallback_enabled" title="Generate using the chain presets in order: unreachable local servers are skipped after a short probe, and failed attempts fall through to the next preset. Your live settings are restored afterward."> | ||
| 55 | <input id="sd_fallback_enabled" type="checkbox" /> | 57 | <input id="sd_fallback_enabled" type="checkbox" /> |
| 56 | <span data-i18n="sd_fallback_enabled_txt">Auto-fallback to Secondary on failure</span> | 58 | <span data-i18n="sd_fallback_enabled_txt">Generate through the chain (auto-fallback)</span> |
| 59 | </label> | ||
| 60 | <small data-i18n="sd_fallback_enabled_small">When enabled, each generation tries the chain presets top to bottom until one succeeds. Unreachable local backends (e.g. a powered-off ComfyUI machine) are skipped after about a second.</small> | ||
| 61 | <hr> | ||
| 62 | <h4 data-i18n="Reference Image Library">Reference Image Library</h4> | ||
| 63 | <label for="sd_ref_images_enabled" class="checkbox_label" data-i18n="[title]sd_ref_images_enabled" title="When the ComfyUI workflow contains a %reference_image% placeholder, the image-prompt LLM picks the best-fitting image from this library and it is passed to the workflow as Base64."> | ||
| 64 | <input id="sd_ref_images_enabled" type="checkbox" /> | ||
| 65 | <span data-i18n="sd_ref_images_enabled_txt">Use tagged reference images</span> | ||
| 57 | </label> | 66 | </label> |
| 58 | <small data-i18n="sd_fallback_enabled_small">When enabled, a failed generation is retried once with the Secondary preset before reporting an error.</small> | 67 | <small data-i18n="sd_ref_images_small">Upload images and tag them with a short name plus a description. If the active ComfyUI workflow uses the "%reference_image%" placeholder, the best-fitting image for the scene is selected automatically and injected like %user_avatar% / %char_avatar%.</small> |
| 68 | <div id="sd_ref_images_list" class="flex-container flexFlowColumn marginTopBot5"></div> | ||
| 69 | <div class="flex-container marginTopBot5"> | ||
| 70 | <div id="sd_ref_images_add" class="menu_button menu_button_icon"> | ||
| 71 | <i class="fa-solid fa-plus"></i> | ||
| 72 | <span data-i18n="Add reference image">Add reference image</span> | ||
| 73 | </div> | ||
| 74 | <input id="sd_ref_images_file" type="file" accept="image/png, image/jpeg, image/webp" multiple hidden /> | ||
| 75 | </div> | ||
| 59 | <hr> | 76 | <hr> |
| 60 | <label for="sd_source" data-i18n="Source">Source</label> | 77 | <label for="sd_source" data-i18n="Source">Source</label> |
| 61 | <select id="sd_source" class="text_pole"> | 78 | <select id="sd_source" class="text_pole"> |
| @@ -101,3 +101,17 @@ | |||
| 101 | text-overflow: ellipsis; | 101 | text-overflow: ellipsis; |
| 102 | white-space: nowrap; | 102 | white-space: nowrap; |
| 103 | } | 103 | } |
| 104 | |||
| 105 | .sd_ref_image_thumb { | ||
| 106 | width: 42px; | ||
| 107 | height: 42px; | ||
| 108 | object-fit: cover; | ||
| 109 | border-radius: 5px; | ||
| 110 | flex-shrink: 0; | ||
| 111 | } | ||
| 112 | |||
| 113 | .sd_preset_chain_order { | ||
| 114 | min-width: 1.5em; | ||
| 115 | text-align: right; | ||
| 116 | opacity: 0.7; | ||
| 117 | } | ||