Merge feat/multi-feature-pack: provider fallback chain + reference image library

f972659ec59a28707ea35ac94173f131c6d2ab8b

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 = {
365365 google_enhance: true,
366366 google_duration: 6,
367367
368368 // Settings presets & auto-fallback ({ name, preset } entries, tried in order)
369369 settings_preset_primarysettings_preset_chain: null[],
370- settings_preset_secondary: null,
371370 settings_fallback_enabled: false,
372371
372+ // Reference image library ({ tag, description, path } entries)
373+ ref_images_enabled: false,
374+ ref_images: [],
375+
373376 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
374377 prompt_generation_profile: '',
375378
@@ -384,9 +387,11 @@ const defaultSettings = {
384387 * @type {string[]}
385388 */
386389const PRESET_EXCLUDE_KEYS = [
387390 'settings_preset_primarysettings_preset_chain',
388- 'settings_preset_secondary',
389391 'settings_fallback_enabled',
392+ // The reference image library is global, not a per-backend setting.
393+ 'ref_images_enabled',
394+ 'ref_images',
390395 // The image-prompt LLM profile is independent of the image backend, so it must
391396 // never be captured/swapped by image-generation presets or the fallback retry.
392397 'prompt_generation_profile',
@@ -445,6 +450,63 @@ function isPresetConfigured(preset) {
445450}
446451
447452/**
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+/**
448510 * Refreshes all settings UI controls to reflect the current extension_settings.sd values.
449511 * Used after loading a settings preset.
450512 * @returns {Promise<void>}
@@ -620,18 +682,33 @@ async function loadSettings() {
620682 }
621683
622684 // Settings presets & auto-fallback
623685 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.
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;
629697 }
630698
631699 if (extension_settings.sd.settings_fallback_enabled === undefined) {
632700 extension_settings.sd.settings_fallback_enabled = false;
633701 }
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+
635712 if (!Array.isArray(extension_settings.sd.custom_entries)) {
636713 extension_settings.sd.custom_entries = [];
637714 }
@@ -702,6 +779,9 @@ async function loadSettings() {
702779 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
703780 $('#sd_google_duration').val(extension_settings.sd.google_duration);
704781 $('#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
706786 for (const style of extension_settings.sd.styles) {
707787 const option = document.createElement('option');
@@ -949,48 +1029,423 @@ async function onRenameStyleClick() {
9491029 saveSettingsDebounced();
9501030}
9511031
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('');
9541122 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`);
9561125}
9571126
9581127function onSavePresetSecondaryClickonFallbackEnabledChange() {
9591128 extension_settings.sd.settings_preset_secondarysettings_fallback_enabled = snapshotSdSettings!!$(this).prop('checked');
9601129 saveSettingsDebounced();
961- toastr.success(t`Secondary settings preset saved.`, t`Image Generation`);
9621130}
9631131
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) {
9671162 return;
9681163 }
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
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);
9791174 return;
9801175 }
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+
1223+function onRefImagesEnabledChange() {
1224+ extension_settings.sd.ref_images_enabled = !!$(this).prop('checked');
9831225 saveSettingsDebounced();
984- await refreshSettingsUi();
985- toastr.success(t`Secondary settings preset loaded.`, t`Image Generation`);
9861226}
9871227
9881228async function onFallbackEnabledChangeonRefImagesFileChange() {
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+
9901246 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);
9911393}
9921394
9931395/**
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+/**
9941449 * Rebuilds the custom wand entries list in the settings UI.
9951450 */
9961451function renderCustomEntriesList() {
@@ -3421,6 +3876,9 @@ async function generatePicture(initiator, args, trigger, message, callback) {
34213876 try {
34223877 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+
34243882 // generate the text prompt for the image
34253883 let prompt = await getPrompt(generationType, message, trigger, quietPrompt, combineNegatives);
34263884 console.log('Processed image prompt:', prompt);
@@ -3668,14 +4126,33 @@ async function generatePrompt(quietPrompt) {
36684126 const profileId = extension_settings.sd.prompt_generation_profile;
36694127 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+
36714140 try {
36724141 reply = profileId
36734142 ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt: effectiveQuietPrompt }))
36744143 : await generateQuietPrompt({ quietPrompt: effectiveQuietPrompt });
36754144 } finally {
36764145 toastr.clear(toast);
36774146 }
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+
36794156 const processedReply = processReply(reply);
36804157
36814158 if (!processedReply) {
@@ -3878,38 +4355,58 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
38784355 }
38794356
38804357 const currentChatId = getCurrentChatId();
4358+ const fallbackChain = extension_settings.sd.settings_fallback_enabled ? getConfiguredPresetChain() : [];
38814359 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 {
38994368 } catchfor (err2const entry of fallbackChain) {
39004369 applySdSettingsSnapshot(restoreentry.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');
39054389 }
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;
39094390 }
3910- // Restore live (primary) settings after a successful fallback attempt.
4391+ } finally {
39114392 applySdSettingsSnapshot(restore);
39124393 } else {
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+
39134410 console.error('Image generation request error: ', err);
39144411 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
39154412 return;
@@ -4752,6 +5249,12 @@ async function generateComfyImageCommon(prompt, negativePrompt, signal, basePath
47525249 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
47535250 }
47545251 }
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+ }
47555258 console.log(`{
47565259 "prompt": ${workflow}
47575260 }`);
@@ -6373,11 +6876,11 @@ export async function init() {
63736876 $('#sd_save_style').on('click', onSaveStyleClick);
63746877 $('#sd_rename_style').on('click', onRenameStyleClick);
63756878 $('#sd_delete_style').on('click', onDeleteStyleClick);
63766879 $('#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);
63806880 $('#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);
63816884 $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
63826885 $('#sd_custom_entries_list').on('click', '[data-action]', function () {
63836886 const id = $(this).attr('data-entry-id');
public/scripts/extensions/stable-diffusion/settings.html+28 -11
@@ -43,19 +43,36 @@
4343 <select id="sd_prompt_generation_profile" class="text_pole"></select>
4444 <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>
4545 <hr>
4646 <h4 data-i18n="Settings Presets &Provider Fallback Chain">Settings Presets &amp;Provider Fallback Chain</h4>
4747 <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>
4848 <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">
5050 <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>
5151 <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.">
5557 <input id="sd_fallback_enabled" type="checkbox" />
5658 <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>
5766 </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>
5976 <hr>
6077 <label for="sd_source" data-i18n="Source">Source</label>
6178 <select id="sd_source" class="text_pole">
public/scripts/extensions/stable-diffusion/style.css+14 -0
@@ -101,3 +101,17 @@
101101 text-overflow: ellipsis;
102102 white-space: nowrap;
103103}
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+}