Merge feat/multi-feature-pack: provider fallback chain + reference image library
| @@ -365,11 +365,14 @@ const defaultSettings = { | ||
| 365 | 365 | google_enhance: true, |
| 366 | 366 | google_duration: 6, |
| 367 | 367 | |
| 368 | 368 | // Settings presets & auto-fallback ({ name, preset } entries, tried in order) |
| 369 | 369 | settings_preset_primarysettings_preset_chain: null[], |
| 370 | - settings_preset_secondary: null, | |
| 371 | 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 | 376 | // Dedicated LLM connection profile for image-prompt generation ('' = use active model) |
| 374 | 377 | prompt_generation_profile: '', |
| 375 | 378 | |
| @@ -384,9 +387,11 @@ const defaultSettings = { | ||
| 384 | 387 | * @type {string[]} |
| 385 | 388 | */ |
| 386 | 389 | const PRESET_EXCLUDE_KEYS = [ |
| 387 | 390 | 'settings_preset_primarysettings_preset_chain', |
| 388 | - 'settings_preset_secondary', | |
| 389 | 391 | 'settings_fallback_enabled', |
| 392 | + // The reference image library is global, not a per-backend setting. | |
| 393 | + 'ref_images_enabled', | |
| 394 | + 'ref_images', | |
| 390 | 395 | // The image-prompt LLM profile is independent of the image backend, so it must |
| 391 | 396 | // never be captured/swapped by image-generation presets or the fallback retry. |
| 392 | 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 | 510 | * Refreshes all settings UI controls to reflect the current extension_settings.sd values. |
| 449 | 511 | * Used after loading a settings preset. |
| 450 | 512 | * @returns {Promise<void>} |
| @@ -620,18 +682,33 @@ async function loadSettings() { | ||
| 620 | 682 | } |
| 621 | 683 | |
| 622 | 684 | // Settings presets & auto-fallback |
| 623 | 685 | if (!Array.isArray(extension_settings.sd.settings_preset_primary === undefinedsettings_preset_chain)) { |
| 624 | - extension_settings.sd.settings_preset_primary = null; | |
| 686 | + // Migrate the old two-slot primary/secondary presets into a chain. | |
| 687 | + const chain = []; | |
| 688 | + if (isPresetConfigured(extension_settings.sd.settings_preset_primary)) { | |
| 689 | + chain.push({ name: 'Primary', preset: extension_settings.sd.settings_preset_primary }); | |
| 625 | 690 | } |
| 626 | - | |
| 691 | + if (isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | |
| 627 | - if (extension_settings.sd.settings_preset_secondary === undefined) { | |
| 692 | + chain.push({ name: 'Secondary', preset: extension_settings.sd.settings_preset_secondary }); | |
| 628 | - extension_settings.sd.settings_preset_secondary = null; | |
| 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 | 699 | if (extension_settings.sd.settings_fallback_enabled === undefined) { |
| 632 | 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 | 712 | if (!Array.isArray(extension_settings.sd.custom_entries)) { |
| 636 | 713 | extension_settings.sd.custom_entries = []; |
| 637 | 714 | } |
| @@ -702,6 +779,9 @@ async function loadSettings() { | ||
| 702 | 779 | $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance); |
| 703 | 780 | $('#sd_google_duration').val(extension_settings.sd.google_duration); |
| 704 | 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 | 786 | for (const style of extension_settings.sd.styles) { |
| 707 | 787 | const option = document.createElement('option'); |
| @@ -949,40 +1029,99 @@ async function onRenameStyleClick() { | ||
| 949 | 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. | |
| 954 | - saveSettingsDebounced(); | |
| 1034 | + */ | |
| 955 | - toastr.success(t`Primary settings preset saved.`, t`Image Generation`); | |
| 1035 | +function renderPresetChain() { | |
| 1036 | + const container = $('#sd_preset_chain_list'); | |
| 1037 | + if (!container.length) { | |
| 1038 | + return; | |
| 956 | 1039 | } |
| 957 | 1040 | |
| 958 | -function onSavePresetSecondaryClick() { | |
| 1041 | + container.empty(); | |
| 959 | - extension_settings.sd.settings_preset_secondary = snapshotSdSettings(); | |
| 1042 | + | |
| 960 | - saveSettingsDebounced(); | |
| 1043 | + const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : []; | |
| 961 | - toastr.success(t`Secondary settings preset saved.`, t`Image Generation`); | |
| 962 | -} | |
| 963 | 1044 | |
| 964 | -async function onLoadPresetPrimaryClick() { | |
| 1045 | + if (chain.length === 0) { | |
| 965 | - if (!isPresetConfigured(extension_settings.sd.settings_preset_primary)) { | |
| 1046 | + const empty = $('<small></small>') | |
| 966 | - toastr.info(t`No primary settings preset has been saved yet.`, t`Image Generation`); | |
| 1047 | + .attr('data-i18n', 'No presets in the chain yet.') | |
| 1048 | + .text('No presets in the chain yet.'); | |
| 1049 | + container.append(empty); | |
| 967 | 1050 | return; |
| 968 | 1051 | } |
| 969 | 1052 | |
| 970 | - applySdSettingsSnapshot(extension_settings.sd.settings_preset_primary); | |
| 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); | |
| 971 | 1086 | saveSettingsDebounced(); |
| 972 | 1087 | await refreshSettingsUi(); |
| 973 | 1088 | toastr.success(t`Primary settingsSettings preset loaded.`, t`Image Generation`); |
| 974 | -} | |
| 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 | + }); | |
| 975 | 1101 | |
| 976 | -async function onLoadPresetSecondaryClick() { | |
| 1102 | + const row = $('<div></div>') | |
| 977 | - if (!isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | |
| 1103 | + .addClass('flex-container alignItemsCenter marginTopBot5') | |
| 978 | - toastr.info(t`No secondary settings preset has been saved yet.`, t`Image Generation`); | |
| 1104 | + .append(orderEl) | |
| 979 | - return; | |
| 1105 | + .append(nameInput) | |
| 1106 | + .append(upButton) | |
| 1107 | + .append(downButton) | |
| 1108 | + .append(loadButton) | |
| 1109 | + .append(saveButton) | |
| 1110 | + .append(deleteButton); | |
| 1111 | + | |
| 1112 | + container.append(row); | |
| 1113 | + }); | |
| 980 | 1114 | } |
| 981 | 1115 | |
| 982 | - applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary); | |
| 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(''); | |
| 983 | 1122 | saveSettingsDebounced(); |
| 984 | 1123 | await refreshSettingsUirenderPresetChain(); |
| 985 | 1124 | toastr.success(t`SecondaryCurrent settings presetadded loadedto the fallback chain.`, t`Image Generation`); |
| 986 | 1125 | } |
| 987 | 1126 | |
| 988 | 1127 | function onFallbackEnabledChange() { |
| @@ -990,6 +1129,322 @@ function onFallbackEnabledChange() { | ||
| 990 | 1129 | saveSettingsDebounced(); |
| 991 | 1130 | } |
| 992 | 1131 | |
| 1132 | +// #region Reference image library | |
| 1133 | + | |
| 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) { | |
| 1162 | + return; | |
| 1163 | + } | |
| 1164 | + | |
| 1165 | + container.empty(); | |
| 1166 | + | |
| 1167 | + const images = Array.isArray(extension_settings.sd.ref_images) ? extension_settings.sd.ref_images : []; | |
| 1168 | + | |
| 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); | |
| 1174 | + return; | |
| 1175 | + } | |
| 1176 | + | |
| 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'); | |
| 1225 | + saveSettingsDebounced(); | |
| 1226 | +} | |
| 1227 | + | |
| 1228 | +async function onRefImagesFileChange() { | |
| 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 | + | |
| 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); | |
| 1393 | +} | |
| 1394 | + | |
| 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 | + | |
| 993 | 1448 | /** |
| 994 | 1449 | * Rebuilds the custom wand entries list in the settings UI. |
| 995 | 1450 | */ |
| @@ -3421,6 +3876,9 @@ async function generatePicture(initiator, args, trigger, message, callback) { | ||
| 3421 | 3876 | try { |
| 3422 | 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 | 3882 | // generate the text prompt for the image |
| 3425 | 3883 | let prompt = await getPrompt(generationType, message, trigger, quietPrompt, combineNegatives); |
| 3426 | 3884 | console.log('Processed image prompt:', prompt); |
| @@ -3668,14 +4126,33 @@ async function generatePrompt(quietPrompt) { | ||
| 3668 | 4126 | const profileId = extension_settings.sd.prompt_generation_profile; |
| 3669 | 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 | 4140 | try { |
| 3672 | 4141 | reply = profileId |
| 3673 | 4142 | ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt: effectiveQuietPrompt })) |
| 3674 | 4143 | : await generateQuietPrompt({ quietPrompt: effectiveQuietPrompt }); |
| 3675 | 4144 | } finally { |
| 3676 | 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 | 4156 | const processedReply = processReply(reply); |
| 3680 | 4157 | |
| 3681 | 4158 | if (!processedReply) { |
| @@ -3878,38 +4355,58 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3878 | 4355 | } |
| 3879 | 4356 | |
| 3880 | 4357 | const currentChatId = getCurrentChatId(); |
| 4358 | + const fallbackChain = extension_settings.sd.settings_fallback_enabled ? getConfiguredPresetChain() : []; | |
| 3881 | 4359 | let genOutput; |
| 4360 | + | |
| 4361 | + if (fallbackChain.length > 0) { | |
| 4362 | + // Chain mode: try every preset in order. Locally-hosted backends are probed | |
| 4363 | + // first so a powered-off server is skipped after ~1.5s instead of stalling | |
| 4364 | + // the attempt. The live settings are restored afterward either way. | |
| 4365 | + const restore = snapshotSdSettings(); | |
| 4366 | + let lastError = new Error('No provider in the fallback chain was reachable.'); | |
| 4367 | + try { | |
| 4368 | + for (const entry of fallbackChain) { | |
| 4369 | + applySdSettingsSnapshot(entry.preset); | |
| 4370 | + | |
| 4371 | + if (!(await isCurrentSourceReachable())) { | |
| 4372 | + console.warn(`SD: chain entry "${entry.name}" is not reachable, skipping`); | |
| 4373 | + toastr.warning(`Provider "${entry.name}" is not reachable, trying the next one…`, 'Image Generation'); | |
| 4374 | + continue; | |
| 4375 | + } | |
| 4376 | + | |
| 3882 | 4377 | try { |
| 3883 | 4378 | genOutput = await attemptImageGeneration(signal); |
| 4379 | + break; | |
| 3884 | 4380 | } catch (err) { |
| 3885 | - // Check if this was an intentional abort by user | |
| 3886 | 4381 | if (signal?.aborted) { |
| 3887 | 4382 | console.log('SD: Image generation aborted by user'); |
| 3888 | 4383 | toastr.info('Image generation stopped.', 'Image Generation'); |
| 3889 | 4384 | return; |
| 3890 | 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'); | |
| 4389 | + } | |
| 4390 | + } | |
| 4391 | + } finally { | |
| 4392 | + applySdSettingsSnapshot(restore); | |
| 4393 | + } | |
| 3891 | 4394 | |
| 3892 | - if (extension_settings.sd.settings_fallback_enabled && isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | |
| 4395 | + if (!genOutput) { | |
| 3893 | - console.warn('SD: primary generation failed, falling back to secondary preset', err); | |
| 4396 | + toastr.error('Image generation failed for every provider in the fallback chain.' + '\n\n' + String(lastError), 'Image Generation'); | |
| 3894 | - toastr.warning('Primary image generation failed. Retrying with secondary settings…', 'Image Generation'); | |
| 4397 | + return; | |
| 3895 | - const restore = snapshotSdSettings(); | |
| 4398 | + } | |
| 4399 | + } else { | |
| 3896 | 4400 | try { |
| 3897 | - applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary); | |
| 3898 | 4401 | genOutput = await attemptImageGeneration(signal); |
| 3899 | 4402 | } catch (err2err) { |
| 3900 | - applySdSettingsSnapshot(restore); | |
| 4403 | + // Check if this was an intentional abort by user | |
| 3901 | 4404 | if (signal?.aborted) { |
| 3902 | 4405 | console.log('SD: Image generation aborted by user'); |
| 3903 | 4406 | toastr.info('Image generation stopped.', 'Image Generation'); |
| 3904 | 4407 | return; |
| 3905 | 4408 | } |
| 3906 | - console.error('SD: secondary generation also failed', err2); | |
| 4409 | + | |
| 3907 | - toastr.error('Image generation failed (primary and secondary).' + '\n\n' + String(err2), 'Image Generation'); | |
| 3908 | - return; | |
| 3909 | - } | |
| 3910 | - // Restore live (primary) settings after a successful fallback attempt. | |
| 3911 | - applySdSettingsSnapshot(restore); | |
| 3912 | - } else { | |
| 3913 | 4410 | console.error('Image generation request error: ', err); |
| 3914 | 4411 | toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); |
| 3915 | 4412 | return; |
| @@ -4752,6 +5249,12 @@ async function generateComfyImageCommon(prompt, negativePrompt, signal, basePath | ||
| 4752 | 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 | 5258 | console.log(`{ |
| 4756 | 5259 | "prompt": ${workflow} |
| 4757 | 5260 | }`); |
| @@ -6373,11 +6876,11 @@ export async function init() { | ||
| 6373 | 6876 | $('#sd_save_style').on('click', onSaveStyleClick); |
| 6374 | 6877 | $('#sd_rename_style').on('click', onRenameStyleClick); |
| 6375 | 6878 | $('#sd_delete_style').on('click', onDeleteStyleClick); |
| 6376 | 6879 | $('#sd_save_preset_primarysd_preset_chain_add').on('click', onSavePresetPrimaryClickonPresetChainAddClick); |
| 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 | 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 | 6884 | $('#sd_custom_entry_add').on('click', onAddCustomEntryClick); |
| 6382 | 6885 | $('#sd_custom_entries_list').on('click', '[data-action]', function () { |
| 6383 | 6886 | const id = $(this).attr('data-entry-id'); |
| @@ -43,19 +43,36 @@ | ||
| 43 | 43 | <select id="sd_prompt_generation_profile" class="text_pole"></select> |
| 44 | 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 | 45 | <hr> |
| 46 | 46 | <h4 data-i18n="Settings Presets &Provider Fallback Chain">Settings Presets &Provider Fallback Chain</h4> |
| 47 | 47 | <small data-i18n="sd_settings_presets_small">Save the current backend/connection settings (source, model, sampler, dimensions, etc.) as Primary or Secondarynamed presets and loadarrange them backinto lateran ordered chain. Prompt templates, styles, and custom entries, and the reference image library are not included.</small> |
| 48 | 48 | <div id="sd_preset_chain_list" class="flex-container marginTopBot5flexFlowColumn flexWrapmarginTopBot5"></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 | 50 | <divinput id="sd_load_preset_primarysd_preset_chain_name" type="text" class="menu_buttontext_pole flex1" placeholder="New preset name" data-i18n="Load[placeholder]New Primarypreset name">Load Primary</div> |
| 51 | 51 | <div id="sd_save_preset_secondarysd_preset_chain_add" class="menu_button" data-i18n="Save as Secondarymenu_button_icon">Save as Secondary</div> |
| 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 | 57 | <input id="sd_fallback_enabled" type="checkbox" /> |
| 56 | 58 | <span data-i18n="sd_fallback_enabled_txt">Auto-fallbackGenerate tothrough Secondarythe onchain failure(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 | 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 | 76 | <hr> |
| 60 | 77 | <label for="sd_source" data-i18n="Source">Source</label> |
| 61 | 78 | <select id="sd_source" class="text_pole"> |
| @@ -101,3 +101,17 @@ | ||
| 101 | 101 | text-overflow: ellipsis; |
| 102 | 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 | +} | |