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%.

bf2d62d4344ae665c234fac4ede52872d9dfe890

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

3 files changed, +611 -77Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+569 -66
@@ -365,11 +365,14 @@ const defaultSettings = {
365 google_enhance: true,365 google_enhance: true,
366 google_duration: 6,366 google_duration: 6,
367367
368 // Settings presets & auto-fallback368 // 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,
372371
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: '',
375378
@@ -384,9 +387,11 @@ const defaultSettings = {
384 * @type {string[]}387 * @type {string[]}
385 */388 */
386const PRESET_EXCLUDE_KEYS = [389const 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 must395 // 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}
446451
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 */
456function 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 */
465const 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 */
473async 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 }
621683
622 // Settings presets & auto-fallback684 // 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 = [];
626688 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 }
630698
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 }
634702
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();
705785
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}
9511031
952function onSavePresetPrimaryClick() {1032/**
953 extension_settings.sd.settings_preset_primary = snapshotSdSettings();1033 * Rebuilds the provider fallback chain list in the settings UI.
1034 */
1035function 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
1116function 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}
9571126
958function onSavePresetSecondaryClick() {1127function 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}
9631131
964async 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 */
1137const 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 */
1145let 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 */
1151function 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 */
1159function renderRefImages() {
1160 const container = $('#sd_ref_images_list');
1161 if (!container.length) {
967 return;1162 return;
968 }1163 }
9691164
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}
9751166
976async 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 }
9811176
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
1223function 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}
9871227
988function onFallbackEnabledChange() {1228async 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 */
1256function 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 */
1277async 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 */
1305async 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 */
1325function 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 */
1342function 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 */
1359function 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 */
1377async 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}
9921394
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 */
1402async 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 */
1431async 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 */
996function renderCustomEntriesList() {1451function 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); };
34233878
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 image3882 // 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;
36704128
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 = profileId4141 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 }
36784147
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);
36804157
3681 if (!processedReply) {4158 if (!processedReply) {
@@ -3878,38 +4355,58 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
3878 }4355 }
38794356
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 }
38914360
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');
public/scripts/extensions/stable-diffusion/settings.html+28 -11
@@ -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 &amp; 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 &quot;%reference_image%&quot; 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">
public/scripts/extensions/stable-diffusion/style.css+14 -0
@@ -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}