Merge feat/multi-feature-pack into release Multi-feature enhancement pack: - SD settings presets & fallback with LLM profile switching - Custom wand entries for image generation - Connection manager quick-switch button - Summarize extension: connection profile + prompt role selection - Cache keepalive extension (per-profile, per-chat arming, drift guard) - SD/Summarize profile-switch reconnect-wait fix

75e9eadad17755ba15bd56be82054788ed0687d0

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

10 files changed, +1409 -93Ignore whitespace
public/scripts/extensions/connection-manager/index.js+136 -2
@@ -1,6 +1,6 @@
11import { DOMPurify, Fuse, Popper } from '../../../lib.js';
22
33import { activateSendButtons, animation_duration, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
44import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
55import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
66import { SlashCommand } from '../../slash-commands/SlashCommand.js';
@@ -695,6 +695,138 @@ async function generateStreamCallback(args, value) {
695695 }
696696}
697697
698+/**
699+ * Adds a quick-switch connection profile button to the bottom-left of the chat bar.
700+ * Clicking it opens a small popup listing all saved connection profiles; clicking a
701+ * profile switches to it by driving the Connection Manager's #connection_profiles select.
702+ */
703+function addQuickSwitchButton() {
704+ const leftSendForm = document.getElementById('leftSendForm');
705+ if (!leftSendForm) {
706+ console.warn('[Connection Manager] #leftSendForm not found, skipping quick-switch button');
707+ return;
708+ }
709+
710+ // Avoid adding the button twice (e.g. on extension re-activation)
711+ if (document.getElementById('connection_profile_switcher')) {
712+ return;
713+ }
714+
715+ const button = document.createElement('div');
716+ button.id = 'connection_profile_switcher';
717+ button.className = 'fa-solid fa-plug interactable';
718+ button.tabIndex = 0;
719+ button.title = t`Quick-switch connection profile`;
720+ button.setAttribute('data-i18n', '[title]Quick-switch connection profile');
721+ leftSendForm.appendChild(button);
722+
723+ const menu = document.createElement('div');
724+ menu.id = 'connection_profile_switcher_menu';
725+ menu.style.display = 'none';
726+ const list = document.createElement('ul');
727+ list.id = 'connection_profile_switcher_list';
728+ list.className = 'list-group';
729+ menu.appendChild(list);
730+ document.body.appendChild(menu);
731+
732+ const $menu = $(menu);
733+ const popper = Popper.createPopper(button, menu, {
734+ placement: 'top-start',
735+ });
736+
737+ /**
738+ * Rebuilds the list of profiles from the current settings.
739+ */
740+ function rebuildList() {
741+ list.innerHTML = '';
742+ const profiles = extension_settings.connectionManager.profiles;
743+ const selectedProfile = extension_settings.connectionManager.selectedProfile;
744+
745+ if (!Array.isArray(profiles) || profiles.length === 0) {
746+ const emptyItem = document.createElement('li');
747+ emptyItem.className = 'list-group-item disabled';
748+ emptyItem.textContent = t`No connection profiles saved`;
749+ emptyItem.setAttribute('data-i18n', 'No connection profiles saved');
750+ list.appendChild(emptyItem);
751+ return;
752+ }
753+
754+ const sortedProfiles = profiles.slice().sort((a, b) => a.name.localeCompare(b.name));
755+ for (const profile of sortedProfiles) {
756+ const item = document.createElement('li');
757+ item.className = 'list-group-item interactable';
758+ item.dataset.profileId = profile.id;
759+ if (profile.id === selectedProfile) {
760+ item.classList.add('selected');
761+ }
762+ const icon = document.createElement('i');
763+ icon.className = profile.id === selectedProfile ? 'fa-fw fa-solid fa-check' : 'fa-fw fa-solid';
764+ const label = document.createElement('span');
765+ label.textContent = profile.name;
766+ item.appendChild(icon);
767+ item.appendChild(label);
768+ list.appendChild(item);
769+ }
770+ }
771+
772+ button.addEventListener('click', (e) => {
773+ e.preventDefault();
774+ if ($menu.is(':visible')) {
775+ $menu.fadeOut(animation_duration);
776+ return;
777+ }
778+ rebuildList();
779+ $menu.fadeIn(animation_duration);
780+ popper.update();
781+ });
782+
783+ // Switch profile when a list item is clicked (event delegation).
784+ list.addEventListener('click', (e) => {
785+ const item = e.target instanceof Element ? e.target.closest('li[data-profile-id]') : null;
786+ if (!item) {
787+ return;
788+ }
789+ const profileId = item.dataset.profileId;
790+ $menu.fadeOut(animation_duration);
791+ if (!profileId) {
792+ return;
793+ }
794+ /** @type {HTMLSelectElement} */
795+ // @ts-ignore
796+ const select = document.getElementById('connection_profiles');
797+ if (!select) {
798+ console.warn('[Connection Manager] #connection_profiles select not found');
799+ return;
800+ }
801+ if (!Array.from(select.options).some(o => o.value === profileId)) {
802+ console.warn(`[Connection Manager] Profile option not found in select: ${profileId}`);
803+ return;
804+ }
805+ select.value = profileId;
806+ select.dispatchEvent(new Event('change'));
807+ });
808+
809+ // Close the menu when clicking outside of it or the button.
810+ document.addEventListener('click', (e) => {
811+ if (!$menu.is(':visible')) {
812+ return;
813+ }
814+ const target = e.target;
815+ if (target instanceof Node && (menu.contains(target) || button.contains(target) || button === target)) {
816+ return;
817+ }
818+ $menu.fadeOut(animation_duration);
819+ });
820+
821+ // Keep the highlighted item up to date while the menu is open.
822+ eventSource.on(event_types.CONNECTION_PROFILE_LOADED, () => {
823+ if ($menu.is(':visible')) {
824+ rebuildList();
825+ popper.update();
826+ }
827+ });
828+}
829+
698830export async function init() {
699831 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
700832
@@ -713,6 +845,8 @@ export async function init() {
713845 const profiles = document.getElementById('connection_profiles');
714846 renderConnectionProfiles(profiles);
715847
848+ addQuickSwitchButton();
849+
716850 function toggleProfileSpecificButtons() {
717851 const profileId = extension_settings.connectionManager.selectedProfile;
718852 const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
public/scripts/extensions/connection-manager/style.css+26 -0
@@ -9,3 +9,29 @@
99#connection_profile_spinner {
1010 margin-left: 5px;
1111}
12+
13+#connection_profile_switcher {
14+ font-size: var(--bottomFormIconSize);
15+}
16+
17+#connection_profile_switcher_menu {
18+ z-index: 30000;
19+ backdrop-filter: blur(var(--SmartThemeBlurStrength));
20+ -webkit-backdrop-filter: blur(var(--SmartThemeBlurStrength));
21+}
22+
23+#connection_profile_switcher_list {
24+ max-height: calc(100vh - calc(2 * var(--bottomFormBlockSize) + 10px));
25+ max-height: calc(100dvh - calc(2 * var(--bottomFormBlockSize) + 10px));
26+ overflow-y: auto;
27+ min-width: 150px;
28+}
29+
30+#connection_profile_switcher_list .list-group-item.selected {
31+ opacity: 1;
32+}
33+
34+#connection_profile_switcher_list .list-group-item.disabled {
35+ cursor: default;
36+ opacity: 0.5;
37+}
public/scripts/extensions/keepalive/index.js+360 -0
@@ -0,0 +1,360 @@
1+import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
2+import {
3+ activateSendButtons,
4+ deactivateSendButtons,
5+ eventSource,
6+ event_types,
7+ extension_prompt_roles,
8+ extension_prompt_types,
9+ generateQuietPrompt,
10+ isGenerating,
11+ saveSettingsDebounced,
12+ setExtensionPrompt,
13+ setSendButtonState,
14+ streamingProcessor,
15+} from '../../../script.js';
16+
17+export { init };
18+
19+const MODULE = 'keepalive';
20+
21+// Cap the keepalive completion to a single token. The full prompt prefix is
22+// still processed by the backend (which is what refreshes the server-side
23+// cache), but we never pay for a full completion.
24+const KEEPALIVE_RESPONSE_LENGTH = 1;
25+
26+// Extension-prompt key used to inject the keepalive message as a USER message at depth 0.
27+const KEEPALIVE_INJECT_ID = 'keepalive_ping';
28+
29+// Original default message; used to migrate untouched installs to the current default wording.
30+const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.';
31+
32+// Idle-tracking key used when no connection profile is active (manual API panel).
33+const NONE_KEY = '__none__';
34+
35+// When the active profile is already past its timeout (e.g. you just switched back
36+// to a profile that went cold), wait this short grace before pinging so an imminent
37+// user message can pre-empt the keepalive.
38+const EXPIRED_GRACE_MS = 3000;
39+
40+// If the idle timer fires more than this many ms later than it was scheduled to, assume the
41+// machine slept / the tab was throttled — far more wall-clock time passed than the interval,
42+// so the cache almost certainly lapsed. Skip the ping and go dormant rather than guess.
43+const MAX_TIMER_DRIFT_MS = 5000;
44+
45+const defaultSettings = {
46+ enabled: false,
47+ timeoutSeconds: 295,
48+ message: '[Note: just reply with an empty response to keep the session alive]',
49+ // Per-profile enable map: { [profileId]: boolean }. Unlisted profiles default to enabled.
50+ profiles: {},
51+};
52+
53+let idleTimer = null;
54+let keepaliveActive = false;
55+// Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new
56+// message in the open chat — on a freshly loaded or switched-to chat (even with history already
57+// present) we have no way to know whether its prompt is still cached server-side, so pinging would
58+// be pointless. Reset on page reload and whenever the chat changes.
59+let armed = false;
60+// Wall-clock time (Date.now() + delay) the pending idle timer is expected to fire, or null when
61+// none is scheduled. Used to detect a timer that fired far too late (sleep/throttle).
62+let scheduledFireTime = null;
63+// Per-profile "time since last message", keyed by connection profile id (or NONE_KEY).
64+// In-memory only: on reload every profile is treated as freshly active.
65+const lastActivity = {};
66+
67+/**
68+ * @returns {string} The active connection profile id, or NONE_KEY for a manual connection.
69+ */
70+function getActiveProfileKey() {
71+ return extension_settings.connectionManager?.selectedProfile || NONE_KEY;
72+}
73+
74+/**
75+ * Whether keepalive should run for the CURRENTLY ACTIVE connection. Requires the global
76+ * master switch; for a real profile it also requires that profile's per-profile toggle
77+ * (default on). A manual connection (no profile selected) is governed by the master switch alone.
78+ * @returns {boolean}
79+ */
80+function isKeepaliveEnabledForActive() {
81+ const s = extension_settings[MODULE];
82+ if (!s.enabled) {
83+ return false;
84+ }
85+ const profileId = extension_settings.connectionManager?.selectedProfile;
86+ if (!profileId) {
87+ return true;
88+ }
89+ const perProfile = s.profiles?.[profileId];
90+ return perProfile === undefined ? true : !!perProfile;
91+}
92+
93+/**
94+ * Clears the pending idle timer, if any.
95+ */
96+function clearIdleTimer() {
97+ if (idleTimer) {
98+ clearTimeout(idleTimer);
99+ idleTimer = null;
100+ }
101+}
102+
103+/**
104+ * (Re)schedules the keepalive for the ACTIVE profile based on how long it has been since
105+ * that profile's last message. Does nothing (and leaves the timer cleared) until keepalive
106+ * has been armed for the current chat, or when it is disabled for the active connection.
107+ * Called on activity AND whenever the active profile changes, so each profile keeps its own
108+ * independent idle clock.
109+ */
110+function scheduleIdleTimer() {
111+ clearIdleTimer();
112+ if (!armed || !isKeepaliveEnabledForActive()) {
113+ scheduledFireTime = null;
114+ return;
115+ }
116+ const timeoutMs = Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000;
117+ const last = lastActivity[getActiveProfileKey()] ?? Date.now();
118+ const remaining = timeoutMs - (Date.now() - last);
119+ const delay = remaining > 0 ? remaining : EXPIRED_GRACE_MS;
120+ scheduledFireTime = Date.now() + delay;
121+ idleTimer = setTimeout(fireKeepalive, delay);
122+}
123+
124+/**
125+ * Marks the active profile as just-used (resets its idle clock) and reschedules its keepalive.
126+ */
127+function markActivity() {
128+ lastActivity[getActiveProfileKey()] = Date.now();
129+ scheduleIdleTimer();
130+}
131+
132+/**
133+ * The user sent a new message in the current chat — the ONLY thing that arms keepalive.
134+ * Background/quiet generations, swipes, regenerations, and loading chat history deliberately do
135+ * NOT arm it: none of those prove the user is actively continuing THIS chat from a known-cached
136+ * prompt (GENERATION_STARTED in particular fires for every quiet/background generation too).
137+ */
138+function onMessageSent() {
139+ armed = true;
140+ markActivity();
141+}
142+
143+/**
144+ * Opening or switching chats disarms keepalive: a freshly loaded chat is not proof its prompt is
145+ * still cached server-side, so we wait for a new sent message in that chat before running again.
146+ */
147+function onChatChanged() {
148+ armed = false;
149+ scheduleIdleTimer();
150+}
151+
152+/**
153+ * Fires a background ("quiet") generation against the active model to keep the
154+ * server-side prompt cache warm. The full prompt prefix (current chat context +
155+ * the keepalive message) is sent — which is what refreshes the cache — while the
156+ * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never
157+ * pay for a real response.
158+ *
159+ * Only the CURRENTLY ACTIVE profile is ever pinged — keepalive never switches the
160+ * connection in the background. Each profile's idle clock is tracked separately, so
161+ * switching profiles re-targets the timer at whichever profile is now active.
162+ *
163+ * Quiet generations are forced non-streaming by the core (Generate excludes
164+ * type === 'quiet' from the StreamingProcessor) and are never saved to chat, so the
165+ * output is simply discarded. generateQuietPrompt's responseLength applies a *global*
166+ * temporary response-length override for the duration of the request, so we hold the
167+ * same generation lock the blocking summary path uses — setSendButtonState(true) flips
168+ * `is_send_press` (which every user send entry point checks and bails on) and
169+ * deactivateSendButtons() reflects the busy state — and release both in `finally`.
170+ */
171+async function fireKeepalive() {
172+ if (!armed || !isKeepaliveEnabledForActive()) {
173+ return;
174+ }
175+
176+ // If the timer fired much later than scheduled (machine sleep, background-tab throttling,
177+ // etc.), far more wall-clock time has elapsed than the idle interval, so the cache has
178+ // almost certainly expired. Don't ping on a stale assumption — go dormant until the next
179+ // sent message re-arms keepalive, exactly like a freshly opened chat.
180+ if (scheduledFireTime !== null && (Date.now() - scheduledFireTime) > MAX_TIMER_DRIFT_MS) {
181+ console.debug('[Keepalive] Idle timer fired', Date.now() - scheduledFireTime, 'ms late; skipping and disarming until next activity.');
182+ armed = false;
183+ clearIdleTimer();
184+ scheduledFireTime = null;
185+ return;
186+ }
187+
188+ // Never start while any generation is in progress, or while a previous
189+ // keepalive is still running. Reschedule and bail.
190+ if (keepaliveActive || isGenerating() || streamingProcessor) {
191+ scheduleIdleTimer();
192+ return;
193+ }
194+
195+ keepaliveActive = true;
196+ setSendButtonState(true);
197+ deactivateSendButtons();
198+
199+ try {
200+ // Deliver the keepalive as a USER message at depth 0 (the quiet-prompt path forces a
201+ // system role — openai.js hardcodes role:'system' for it), using the same depth+role
202+ // injection Author's Note uses. The quiet prompt itself stays empty so the full chat
203+ // context is still assembled (which is what keeps the cached prefix warm).
204+ setExtensionPrompt(KEEPALIVE_INJECT_ID, extension_settings[MODULE].message, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
205+ await generateQuietPrompt({ quietPrompt: '', responseLength: KEEPALIVE_RESPONSE_LENGTH });
206+ // The ping kept this profile warm; treat it as fresh activity so the next
207+ // keepalive for it is a full timeout away.
208+ lastActivity[getActiveProfileKey()] = Date.now();
209+ } catch (error) {
210+ // Best-effort: any failure here is harmless and intentionally swallowed.
211+ console.debug('[Keepalive] Background generation ended:', error);
212+ } finally {
213+ setExtensionPrompt(KEEPALIVE_INJECT_ID, '', extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
214+ setSendButtonState(false);
215+ activateSendButtons();
216+ keepaliveActive = false;
217+ scheduleIdleTimer();
218+ }
219+}
220+
221+/**
222+ * Renders the per-profile enable checkboxes from the current Connection Manager profiles.
223+ */
224+function renderProfileToggles() {
225+ const container = $('#keepalive_profiles_list');
226+ if (!container.length) {
227+ return;
228+ }
229+ container.empty();
230+
231+ const profiles = extension_settings.connectionManager?.profiles;
232+ if (!Array.isArray(profiles) || profiles.length === 0) {
233+ container.append(
234+ $('<small>')
235+ .attr('data-i18n', 'ext_keepalive_no_profiles')
236+ .text('No connection profiles found. Keepalive uses the active model, governed by the master toggle above.'),
237+ );
238+ return;
239+ }
240+
241+ const perProfile = extension_settings[MODULE].profiles || {};
242+ const sorted = profiles.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
243+ for (const profile of sorted) {
244+ const enabled = perProfile[profile.id] === undefined ? true : !!perProfile[profile.id];
245+ const label = $('<label>').addClass('checkbox_label');
246+ const input = $('<input>')
247+ .attr('type', 'checkbox')
248+ .addClass('keepalive_profile_toggle')
249+ .attr('data-profile-id', profile.id)
250+ .prop('checked', enabled);
251+ const span = $('<span>').text(profile.name);
252+ label.append(input, span);
253+ container.append(label);
254+ }
255+}
256+
257+/**
258+ * Loads settings into the global store and hydrates the UI controls.
259+ */
260+function loadSettings() {
261+ if (extension_settings[MODULE] === undefined) {
262+ extension_settings[MODULE] = {};
263+ }
264+
265+ for (const key of Object.keys(defaultSettings)) {
266+ if (extension_settings[MODULE][key] === undefined) {
267+ extension_settings[MODULE][key] = structuredClone(defaultSettings[key]);
268+ }
269+ }
270+
271+ // Guard against a corrupted/legacy value for the per-profile map.
272+ if (typeof extension_settings[MODULE].profiles !== 'object' || extension_settings[MODULE].profiles === null) {
273+ extension_settings[MODULE].profiles = {};
274+ }
275+
276+ // Migrate the original default message to the current default so installs that never
277+ // customized it pick up the improved wording (custom messages are left untouched).
278+ if (extension_settings[MODULE].message === LEGACY_DEFAULT_MESSAGE) {
279+ extension_settings[MODULE].message = defaultSettings.message;
280+ saveSettingsDebounced();
281+ }
282+
283+ $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled);
284+ $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds);
285+ $('#keepalive_message').val(extension_settings[MODULE].message);
286+ renderProfileToggles();
287+}
288+
289+/**
290+ * Wires up the settings UI control handlers.
291+ */
292+function setupListeners() {
293+ $('#keepalive_enabled').on('change', function () {
294+ extension_settings[MODULE].enabled = !!$(this).prop('checked');
295+ saveSettingsDebounced();
296+ scheduleIdleTimer();
297+ });
298+
299+ $('#keepalive_timeout').on('input', function () {
300+ extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295;
301+ saveSettingsDebounced();
302+ scheduleIdleTimer();
303+ });
304+
305+ $('#keepalive_message').on('input', function () {
306+ extension_settings[MODULE].message = String($(this).val());
307+ saveSettingsDebounced();
308+ });
309+
310+ // Per-profile toggles are rendered dynamically; use a delegated handler.
311+ $('#keepalive_profiles_list').on('change', '.keepalive_profile_toggle', function () {
312+ const profileId = $(this).attr('data-profile-id');
313+ if (!profileId) {
314+ return;
315+ }
316+ extension_settings[MODULE].profiles[profileId] = !!$(this).prop('checked');
317+ saveSettingsDebounced();
318+ // If the toggled profile is the one currently active, reschedule immediately.
319+ if (profileId === extension_settings.connectionManager?.selectedProfile) {
320+ scheduleIdleTimer();
321+ }
322+ });
323+}
324+
325+async function init() {
326+ const settingsHtml = await renderExtensionTemplateAsync(MODULE, 'settings');
327+ $('#extensions_settings2').append(settingsHtml);
328+
329+ loadSettings();
330+ setupListeners();
331+
332+ // Only the user sending a new message arms keepalive (for the current chat).
333+ eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
334+
335+ // These reset the active profile's idle clock (so generation time isn't counted as idle) but
336+ // never arm keepalive on their own — they also fire for background/quiet generations and while
337+ // a chat is loading. They only re-arm the timer if a sent message already armed it.
338+ const activityEvents = [
339+ event_types.MESSAGE_RECEIVED,
340+ event_types.GENERATION_STARTED,
341+ event_types.GENERATION_ENDED,
342+ ];
343+ for (const event of activityEvents) {
344+ eventSource.on(event, markActivity);
345+ }
346+
347+ // Opening/switching a chat disarms keepalive — it must be re-armed by a new sent message.
348+ eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
349+
350+ // When the active profile changes, re-target the idle timer at the new profile using
351+ // ITS own last-activity time (do NOT mark activity — switching is not using it).
352+ eventSource.on(event_types.CONNECTION_PROFILE_LOADED, scheduleIdleTimer);
353+
354+ // Keep the per-profile toggle list in sync with Connection Manager.
355+ eventSource.on(event_types.CONNECTION_PROFILE_CREATED, renderProfileToggles);
356+ eventSource.on(event_types.CONNECTION_PROFILE_UPDATED, renderProfileToggles);
357+ eventSource.on(event_types.CONNECTION_PROFILE_DELETED, renderProfileToggles);
358+
359+ scheduleIdleTimer();
360+}
public/scripts/extensions/keepalive/manifest.json+14 -0
@@ -0,0 +1,14 @@
1+{
2+ "display_name": "Cache Keepalive",
3+ "loading_order": 100,
4+ "requires": [],
5+ "optional": [],
6+ "js": "index.js",
7+ "css": "",
8+ "author": "SillyTavern",
9+ "version": "1.0.0",
10+ "homePage": "https://github.com/SillyTavern/SillyTavern",
11+ "hooks": {
12+ "activate": "init"
13+ }
14+}
public/scripts/extensions/keepalive/settings.html+28 -0
@@ -0,0 +1,28 @@
1+<div id="keepalive_settings">
2+ <div class="inline-drawer">
3+ <div class="inline-drawer-toggle inline-drawer-header">
4+ <b data-i18n="ext_keepalive_title">Cache Keepalive</b>
5+ <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
6+ </div>
7+ <div class="inline-drawer-content">
8+ <label class="checkbox_label" for="keepalive_enabled">
9+ <input id="keepalive_enabled" type="checkbox" />
10+ <span data-i18n="ext_keepalive_enable">Enable cache keepalive (master switch)</span>
11+ </label>
12+
13+ <label for="keepalive_timeout" data-i18n="ext_keepalive_timeout">Idle timeout (seconds)</label>
14+ <input id="keepalive_timeout" class="text_pole" type="number" min="60" step="1" />
15+
16+ <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label>
17+ <input id="keepalive_message" class="text_pole" type="text" />
18+
19+ <small data-i18n="ext_keepalive_help">After this many seconds without activity on the active connection profile, the current chat plus this message (sent as a hidden user message) is submitted to that profile to keep its server-side prompt cache warm. The completion is capped to a single token and is never saved to the chat. Sending is briefly locked while it runs. Only the profile you're currently using is pinged; each profile has its own idle timer. Keepalive stays off until you send a new message in the current chat (opening, switching to, or reloading a chat isn't proof its prompt is still cached), and it also skips a ping if its timer fired far later than scheduled (e.g. after the machine slept).</small>
20+
21+ <hr>
22+
23+ <label data-i18n="ext_keepalive_profiles_label">Keepalive per connection profile</label>
24+ <small data-i18n="ext_keepalive_profiles_desc">Turn keepalive on only for the profiles whose providers support prompt caching (leave the others off so you don't waste requests). The master switch above must be enabled. Profiles default to on.</small>
25+ <div id="keepalive_profiles_list" class="keepalive_profiles_list"></div>
26+ </div>
27+ </div>
28+</div>
public/scripts/extensions/memory/index.js+215 -47
@@ -10,6 +10,7 @@ import {
1010 extension_prompt_types,
1111 generateQuietPrompt,
1212 is_send_press,
13+ online_status,
1314 saveSettingsDebounced,
1415 substituteParamsExtended,
1516 generateRaw,
@@ -27,7 +28,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
2728import { SlashCommand } from '../../slash-commands/SlashCommand.js';
2829import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
2930import { macros, MacroCategory } from '../../macros/macro-system.js';
3031import { ConnectionManagerRequestService, countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';
3132import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
3233import { removeReasoningFromString } from '../../reasoning.js';
3334import { MacrosParser } from '/scripts/macros.js';
@@ -136,8 +137,49 @@ const defaultSettings = {
136137 maxMessagesPerRequestMax: 250,
137138 maxMessagesPerRequestStep: 1,
138139 prompt_builder: prompt_builders.DEFAULT,
140+ summaryPromptRole: extension_prompt_roles.SYSTEM,
141+ summaryConnectionProfile: '',
139142};
140143
144+/**
145+ * Resolve the configured role for the summarization request prompt.
146+ * Only SYSTEM and USER are supported; anything else falls back to SYSTEM.
147+ * @returns {number} One of extension_prompt_roles.SYSTEM or extension_prompt_roles.USER
148+ */
149+function getSummaryPromptRole() {
150+ return Number(extension_settings.memory.summaryPromptRole) === extension_prompt_roles.USER
151+ ? extension_prompt_roles.USER
152+ : extension_prompt_roles.SYSTEM;
153+}
154+
155+let summaryConnectionProfileDropdownInitialized = false;
156+
157+/**
158+ * Populates the dedicated summarization connection profile dropdown.
159+ * Only initializes once to avoid attaching duplicate Connection Manager event listeners
160+ * when loadSettings() is called again (e.g. after loading a settings preset).
161+ */
162+function initSummaryConnectionProfileDropdown() {
163+ if (summaryConnectionProfileDropdownInitialized) {
164+ return;
165+ }
166+
167+ try {
168+ ConnectionManagerRequestService.handleDropdown(
169+ '#memory_summary_connection_profile',
170+ extension_settings.memory.summaryConnectionProfile,
171+ (profile) => {
172+ extension_settings.memory.summaryConnectionProfile = profile?.id ?? '';
173+ saveSettingsDebounced();
174+ },
175+ );
176+ summaryConnectionProfileDropdownInitialized = true;
177+ } catch (error) {
178+ // Connection Manager may be unavailable/disabled; leave the dropdown empty in that case.
179+ console.warn('Summarize: could not populate summary connection profile dropdown', error);
180+ }
181+}
182+
141183function loadSettings() {
142184 if (Object.keys(extension_settings.memory).length === 0) {
143185 Object.assign(extension_settings.memory, defaultSettings);
@@ -158,6 +200,8 @@ function loadSettings() {
158200 $('#memory_template').val(extension_settings.memory.template).trigger('input');
159201 $('#memory_depth').val(extension_settings.memory.depth).trigger('input');
160202 $('#memory_role').val(extension_settings.memory.role).trigger('input');
203+ $('#memory_summary_prompt_role').val(extension_settings.memory.summaryPromptRole).trigger('input');
204+ initSummaryConnectionProfileDropdown();
161205 $(`input[name="memory_position"][value="${extension_settings.memory.position}"]`).prop('checked', true).trigger('input');
162206 $('#memory_prompt_words_force').val(extension_settings.memory.promptForceWords).trigger('input');
163207 $(`input[name="memory_prompt_builder"][value="${extension_settings.memory.prompt_builder}"]`).prop('checked', true).trigger('input');
@@ -314,6 +358,12 @@ function onMemoryRoleInput() {
314358 saveSettingsDebounced();
315359}
316360
361+function onMemorySummaryPromptRoleInput() {
362+ const value = $(this).val();
363+ extension_settings.memory.summaryPromptRole = Number(value);
364+ saveSettingsDebounced();
365+}
366+
317367function onMemoryPositionChange(e) {
318368 const value = e.target.value;
319369 extension_settings.memory.position = value;
@@ -515,15 +565,22 @@ async function summarizeCallback(args, text) {
515565
516566 const source = args.source || extension_settings.memory.source;
517567 const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords });
568+ const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER;
518569
519570 try {
520571 switch (source) {
521572 case summary_sources.extras:
522573 return await callExtrasSummarizeAPI(text);
523574 case summary_sources.main: {
524- return removeReasoningFromString(await generateRaw({ prompt: text, systemPrompt: prompt, responseLength: extension_settings.memory.overrideResponseLength }));
575+ // When the instruction should be a USER message, combine it into the
576+ // user content and clear the system prompt. Otherwise keep it as system.
577+ const rawPrompt = useUserRole ? [prompt, text].filter(x => x).join('\n\n') : text;
578+ const systemPrompt = useUserRole ? '' : prompt;
579+ return removeReasoningFromString(await generateRaw({ prompt: rawPrompt, systemPrompt: systemPrompt, responseLength: extension_settings.memory.overrideResponseLength }));
580+ }
525581 case summary_sources.webllm: {
526- const messages = [{ role: 'system', content: prompt }, { role: 'user', content: text }].filter(m => m.content);
582+ const promptRole = useUserRole ? 'user' : 'system';
583+ const messages = [{ role: promptRole, content: prompt }, { role: 'user', content: text }].filter(m => m.content);
527584 const params = extension_settings.memory.overrideResponseLength > 0 ? { max_tokens: extension_settings.memory.overrideResponseLength } : {};
528585 return await generateWebLlmChatPrompt(messages, params);
529586 }
@@ -646,8 +703,9 @@ async function summarizeChatWebLLM(context, force) {
646703 return null;
647704 }
648705
706+ const promptRole = getSummaryPromptRole() === extension_prompt_roles.USER ? 'user' : 'system';
649707 const messages = [
650708 { role: 'system'promptRole, content: prompt },
651709 { role: 'user', content: rawPrompt },
652710 ];
653711
@@ -678,6 +736,73 @@ async function summarizeChatWebLLM(context, force) {
678736 }
679737}
680738
739+// Warn at most once per session if a summary connection profile is selected but cannot be
740+// applied because there is no active connection profile to restore afterward.
741+let summaryProfileWarnedNoBaseProfile = false;
742+
743+/**
744+ * Runs a callback with a specific Connection Manager profile temporarily active, then restores
745+ * the previously active profile. This lets the summary be generated by the chosen LLM using the
746+ * full summarization pipeline (generateQuietPrompt/generateRaw) under that connection, instead of
747+ * a context-free Connection Manager request that some providers run with the wrong model.
748+ *
749+ * The switch is only performed when a real connection profile is currently active (so it can be
750+ * reliably restored). When none is active — i.e. the user drives the API panel manually —
751+ * switching to a profile could not be undone without clobbering those manual settings, so we leave
752+ * the active model in place and warn once.
753+ *
754+ * @param {string} targetProfileId Profile to activate for the duration of the callback.
755+ * @param {() => Promise<any>} callback Work to run while the target profile is active.
756+ * @returns {Promise<any>} The callback's result.
757+ */
758+async function withConnectionProfile(targetProfileId, callback) {
759+ const select = /** @type {HTMLSelectElement} */ (document.getElementById('connection_profiles'));
760+ const connectionManager = extension_settings.connectionManager;
761+ const currentProfileId = connectionManager?.selectedProfile;
762+
763+ const canSwitch = !!select
764+ && !!connectionManager
765+ && Array.isArray(connectionManager.profiles)
766+ && connectionManager.profiles.some(p => p.id === targetProfileId)
767+ && Array.from(select.options).some(o => o.value === targetProfileId)
768+ && !!currentProfileId // a real profile is active, so it can be restored afterwards
769+ && currentProfileId !== targetProfileId;
770+
771+ if (!canSwitch) {
772+ // Selected but no base profile to restore from -> use the active model and warn once.
773+ if (targetProfileId && !currentProfileId && !summaryProfileWarnedNoBaseProfile) {
774+ summaryProfileWarnedNoBaseProfile = true;
775+ toastr.info('The summary connection profile is only applied while a connection profile is active (so the original can be restored). Using the current model.', 'Summarize');
776+ }
777+ return await callback();
778+ }
779+
780+ const switchToProfile = async (profileId) => {
781+ const loaded = new Promise(resolve => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
782+ const timeout = new Promise(resolve => setTimeout(resolve, 10000));
783+ const index = Array.from(select.options).findIndex(o => o.value === profileId);
784+ select.selectedIndex = index >= 0 ? index : 0;
785+ select.dispatchEvent(new Event('change'));
786+ // Wait for the profile's commands to finish applying (don't hang forever if the event never fires).
787+ await Promise.race([loaded, timeout]);
788+ // Applying a profile reconnects the API asynchronously; generating before it is
789+ // re-established fails instantly, so wait for the connection to come back up
790+ // (mirrors the built-in /profile command). rejectOnTimeout:false -> proceed anyway after the timeout.
791+ await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100, { rejectOnTimeout: false });
792+ };
793+
794+ await switchToProfile(targetProfileId);
795+ try {
796+ return await callback();
797+ } finally {
798+ try {
799+ await switchToProfile(currentProfileId);
800+ } catch (err) {
801+ console.error('Summarize: failed to restore the previous connection profile after summarization', err);
802+ }
803+ }
804+}
805+
681806async function summarizeChatMain(context, force, skipWIAN) {
682807 const prompt = await getSummaryPromptForNow(context, force);
683808
@@ -686,59 +811,101 @@ async function summarizeChatMain(context, force, skipWIAN) {
686811 }
687812
688813 console.log('sending summary prompt');
689- let summary = '';
690- let index = null;
691814
692- if (prompt_builders.DEFAULT === extension_settings.memory.prompt_builder) {
815+ // Runs the configured summary builder (DEFAULT or RAW) against whatever connection is
693- try {
816+ // currently active. Returns { summary, index }, or null when there is nothing to summarize.
694- inApiCall = true;
817+ const runConfiguredSummary = async () => {
695- /** @type {import('../../../script.js').GenerateQuietPromptParams} */
818+ let summary = '';
696- const params = {
819+ let index = null;
697- quietPrompt: prompt,
820+
698- skipWIAN: skipWIAN,
821+ if (prompt_builders.DEFAULT === extension_settings.memory.prompt_builder) {
699- responseLength: extension_settings.memory.overrideResponseLength,
822+ // generateQuietPrompt always injects the instruction as a SYSTEM message
700- };
823+ // (the QUIET_PROMPT extension prompt has no exposed role parameter).
701- summary = await generateQuietPrompt(params);
824+ // When a USER role is requested, assemble the chat transcript ourselves
702- } finally {
825+ // (same source as the raw builder) and deliver the instruction as USER
703- inApiCall = false;
826+ // content via generateRaw, so the model still receives the chat to
827+ // summarize instead of just the bare instruction.
828+ if (getSummaryPromptRole() === extension_prompt_roles.USER) {
829+ const { rawPrompt } = await getRawSummaryPrompt(context, prompt);
830+ /** @type {import('../../../script.js').GenerateRawParams} */
831+ const params = {
832+ prompt: [prompt, rawPrompt].filter(x => x).join('\n\n'),
833+ systemPrompt: '',
834+ responseLength: extension_settings.memory.overrideResponseLength,
835+ };
836+ summary = removeReasoningFromString(await generateRaw(params));
837+ } else {
838+ /** @type {import('../../../script.js').GenerateQuietPromptParams} */
839+ const params = {
840+ quietPrompt: prompt,
841+ skipWIAN: skipWIAN,
842+ responseLength: extension_settings.memory.overrideResponseLength,
843+ };
844+ summary = await generateQuietPrompt(params);
845+ }
704846 }
705- }
706847
707848 if ([prompt_builders.RAW_BLOCKING, prompt_builders.RAW_NON_BLOCKING].includes(extension_settings.memory.prompt_builder)) {
708849 const lock = extension_settings.memory.prompt_builder === prompt_builders.RAW_BLOCKING;
709850 try {
710- inApiCall = true;
851+ if (lock) {
711- if (lock) {
852+ deactivateSendButtons();
712- deactivateSendButtons();
853+ }
713- }
714854
715855 const { rawPrompt, lastUsedIndex } = await getRawSummaryPrompt(context, prompt);
716856
717857 if (lastUsedIndex === null || lastUsedIndex === -1) {
718858 if (force) {
719859 toastr.info('To try again, remove the latest summary.', 'No messages found to summarize');
720860 }
721861
722862 return null;
723863 }
724864
725- /** @type {import('../../../script.js').GenerateRawParams} */
865+ // When the summary instruction should be a USER message, deliver it as
726- const params = {
866+ // user content and leave the system prompt empty. Otherwise keep the
727- prompt: rawPrompt,
867+ // existing behavior of sending it as the system prompt.
728- systemPrompt: prompt,
868+ const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER;
729- responseLength: extension_settings.memory.overrideResponseLength,
869+ /** @type {import('../../../script.js').GenerateRawParams} */
730- };
870+ const params = {
731- const rawSummary = await generateRaw(params);
871+ prompt: useUserRole ? [prompt, rawPrompt].filter(x => x).join('\n\n') : rawPrompt,
732- summary = removeReasoningFromString(rawSummary);
872+ systemPrompt: useUserRole ? '' : prompt,
733- index = lastUsedIndex;
873+ responseLength: extension_settings.memory.overrideResponseLength,
734- } finally {
874+ };
735- inApiCall = false;
875+ const rawSummary = await generateRaw(params);
736- if (lock) {
876+ summary = removeReasoningFromString(rawSummary);
737- activateSendButtons();
877+ index = lastUsedIndex;
878+ } finally {
879+ if (lock) {
880+ activateSendButtons();
881+ }
738882 }
739883 }
884+
885+ return { summary, index };
886+ };
887+
888+ // A dedicated connection profile generates the summary with the chosen LLM by temporarily
889+ // switching the active connection profile (so the full summarization pipeline runs under it),
890+ // then restoring the previous profile. Falls back to the active model when no profile is set
891+ // or no base profile is active to restore.
892+ const summaryConnectionProfile = extension_settings.memory.summaryConnectionProfile;
893+ let result;
894+ try {
895+ inApiCall = true;
896+ result = summaryConnectionProfile
897+ ? await withConnectionProfile(summaryConnectionProfile, runConfiguredSummary)
898+ : await runConfiguredSummary();
899+ } finally {
900+ inApiCall = false;
740901 }
741902
903+ if (result === null) {
904+ return null;
905+ }
906+
907+ const { summary, index } = result;
908+
742909 if (!summary) {
743910 console.warn('Empty summary received');
744911 return;
@@ -1047,6 +1214,7 @@ function setupListeners() {
10471214 $('#memory_template').off('input').on('input', onMemoryTemplateInput);
10481215 $('#memory_depth').off('input').on('input', onMemoryDepthInput);
10491216 $('#memory_role').off('input').on('input', onMemoryRoleInput);
1217+ $('#memory_summary_prompt_role').off('input').on('input', onMemorySummaryPromptRoleInput);
10501218 $('input[name="memory_position"]').off('change').on('change', onMemoryPositionChange);
10511219 $('#memory_prompt_words_force').off('input').on('input', onMemoryPromptWordsForceInput);
10521220 $('#memory_prompt_builder_default').off('input').on('input', onMemoryPromptBuilderInput);
public/scripts/extensions/memory/settings.html+9 -0
@@ -69,6 +69,15 @@
6969 </div>
7070 </label>
7171 <textarea id="memory_prompt" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_prompt_placeholder" placeholder="This prompt will be sent to AI to request the summary generation. &lcub;&lcub;words&rcub;&rcub; will resolve to the 'Number of words' parameter."></textarea>
72+ <label for="memory_summary_prompt_role" data-i18n="ext_sum_prompt_role">Summary Prompt Role</label>
73+ <select id="memory_summary_prompt_role" class="text_pole">
74+ <option value="0" data-i18n="System">System</option>
75+ <option value="1" data-i18n="User">User</option>
76+ </select>
77+ <small data-i18n="ext_sum_prompt_role_desc">The role used to send the summary request instruction to the model.</small>
78+ <label for="memory_summary_connection_profile" data-i18n="ext_sum_connection_profile">Summary Connection Profile</label>
79+ <select id="memory_summary_connection_profile" class="text_pole"></select>
80+ <small data-i18n="ext_sum_connection_profile_desc">Summarize with a specific connection profile instead of the active model. Leave empty to use the active model.</small>
7281 <label for="memory_prompt_words"><span data-i18n="ext_sum_target_length_1">Target summary length</span> <span data-i18n="ext_sum_target_length_2">(</span><span id="memory_prompt_words_value"></span><span data-i18n="ext_sum_target_length_3"> words)</span></label>
7382 <input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" />
7483 <label for="memory_override_response_length">
public/scripts/extensions/stable-diffusion/index.js+573 -44
@@ -10,6 +10,7 @@ import {
1010 getCurrentChatId,
1111 getRequestHeaders,
1212 getUserAvatar,
13+ online_status,
1314 saveSettingsDebounced,
1415 substituteParams,
1516 substituteParamsExtended,
@@ -40,11 +41,12 @@ import {
4041 resetScrollHeight,
4142 saveBase64AsFile,
4243 stringFormat,
44+ waitUntilCondition,
4345} from '../../utils.js';
4446import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';
4547import { SECRET_KEYS, secret_state } from '../../secrets.js';
4648import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js';
4749import { ConnectionManagerRequestService, getMultimodalCaption } from '../shared.js';
4850import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
4951import { SlashCommand } from '../../slash-commands/SlashCommand.js';
5052import {
@@ -125,6 +127,7 @@ const generationMode = {
125127 USER_MULTIMODAL: 9,
126128 FACE_MULTIMODAL: 10,
127129 FREE_EXTENDED: 11,
130+ CUSTOM: 12,
128131};
129132
130133const multimodalMap = {
@@ -147,6 +150,7 @@ const modeLabels = {
147150 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',
148151 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',
149152 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',
153+ [generationMode.CUSTOM]: 'Custom',
150154};
151155
152156const triggerWords = {
@@ -360,8 +364,97 @@ const defaultSettings = {
360364 google_api: 'makersuite',
361365 google_enhance: true,
362366 google_duration: 6,
367+
368+ // Settings presets & auto-fallback
369+ settings_preset_primary: null,
370+ settings_preset_secondary: null,
371+ settings_fallback_enabled: false,
372+
373+ // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
374+ prompt_generation_profile: '',
375+
376+ // User-defined custom wand dropdown entries ({ id, title, prompt })
377+ custom_entries: [],
363378};
364379
380+/**
381+ * Keys that are NOT part of a settings preset snapshot.
382+ * A preset captures backend/connection params (source, model, sampler, dimensions, etc.)
383+ * but not the prompt library, styles, custom entries, or UI prefs.
384+ * @type {string[]}
385+ */
386+const PRESET_EXCLUDE_KEYS = [
387+ 'settings_preset_primary',
388+ 'settings_preset_secondary',
389+ 'settings_fallback_enabled',
390+ // 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.
392+ 'prompt_generation_profile',
393+ 'prompts',
394+ 'character_prompts',
395+ 'character_negative_prompts',
396+ 'styles',
397+ 'custom_entries',
398+ 'interactive_visible',
399+ 'wand_visible',
400+ 'command_visible',
401+ 'tool_visible',
402+ 'expand',
403+];
404+
405+/**
406+ * Creates a deep clone snapshot of the settings keys that belong to a preset.
407+ * @returns {object} Snapshot of the included settings keys.
408+ */
409+function snapshotSdSettings() {
410+ const snapshot = {};
411+ for (const key of Object.keys(extension_settings.sd)) {
412+ if (PRESET_EXCLUDE_KEYS.includes(key)) {
413+ continue;
414+ }
415+ snapshot[key] = structuredClone(extension_settings.sd[key]);
416+ }
417+ return snapshot;
418+}
419+
420+/**
421+ * Applies a settings snapshot to the live settings object (in-memory only; does NOT touch the DOM).
422+ * @param {object} snapshot Snapshot previously created by snapshotSdSettings().
423+ */
424+function applySdSettingsSnapshot(snapshot) {
425+ if (!snapshot || typeof snapshot !== 'object') {
426+ return;
427+ }
428+ for (const key of Object.keys(snapshot)) {
429+ // Skip excluded keys so older presets that were saved before a key was
430+ // excluded (e.g. prompt_generation_profile) can't clobber it on load.
431+ if (PRESET_EXCLUDE_KEYS.includes(key)) {
432+ continue;
433+ }
434+ extension_settings.sd[key] = structuredClone(snapshot[key]);
435+ }
436+}
437+
438+/**
439+ * Checks whether a preset is configured (a non-null object with at least one key).
440+ * @param {object} preset Preset to check.
441+ * @returns {boolean} True if the preset is configured.
442+ */
443+function isPresetConfigured(preset) {
444+ return !!preset && typeof preset === 'object' && Object.keys(preset).length > 0;
445+}
446+
447+/**
448+ * Refreshes all settings UI controls to reflect the current extension_settings.sd values.
449+ * Used after loading a settings preset.
450+ * @returns {Promise<void>}
451+ */
452+async function refreshSettingsUi() {
453+ // loadSettings() appends to #sd_style without clearing it, so empty it first.
454+ $('#sd_style').empty();
455+ await loadSettings();
456+}
457+
365458const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
366459const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase());
367460
@@ -459,6 +552,37 @@ function toggleSourceControls() {
459552 });
460553}
461554
555+let promptGenerationProfileDropdownInitialized = false;
556+// Warn at most once per session if a prompt-gen profile is selected but cannot be
557+// applied because there is no active connection profile to restore afterward.
558+let promptProfileWarnedNoBaseProfile = false;
559+
560+/**
561+ * Populates the dedicated prompt-generation connection profile dropdown.
562+ * Only initializes once to avoid attaching duplicate Connection Manager event listeners
563+ * when loadSettings() is called again (e.g. after loading a settings preset).
564+ */
565+function initPromptGenerationProfileDropdown() {
566+ if (promptGenerationProfileDropdownInitialized) {
567+ return;
568+ }
569+
570+ try {
571+ ConnectionManagerRequestService.handleDropdown(
572+ '#sd_prompt_generation_profile',
573+ extension_settings.sd.prompt_generation_profile,
574+ (profile) => {
575+ extension_settings.sd.prompt_generation_profile = profile?.id ?? '';
576+ saveSettingsDebounced();
577+ },
578+ );
579+ promptGenerationProfileDropdownInitialized = true;
580+ } catch (error) {
581+ // Connection Manager may be unavailable/disabled; leave the dropdown empty in that case.
582+ console.warn('SD: could not populate prompt-generation profile dropdown', error);
583+ }
584+}
585+
462586async function loadSettings() {
463587 // Initialize settings
464588 if (Object.keys(extension_settings.sd).length === 0) {
@@ -495,6 +619,23 @@ async function loadSettings() {
495619 extension_settings.sd.styles = defaultStyles;
496620 }
497621
622+ // Settings presets & auto-fallback
623+ if (extension_settings.sd.settings_preset_primary === undefined) {
624+ extension_settings.sd.settings_preset_primary = null;
625+ }
626+
627+ if (extension_settings.sd.settings_preset_secondary === undefined) {
628+ extension_settings.sd.settings_preset_secondary = null;
629+ }
630+
631+ if (extension_settings.sd.settings_fallback_enabled === undefined) {
632+ extension_settings.sd.settings_fallback_enabled = false;
633+ }
634+
635+ if (!Array.isArray(extension_settings.sd.custom_entries)) {
636+ extension_settings.sd.custom_entries = [];
637+ }
638+
498639 // Preserve an original seed if exists
499640 if (extension_settings.sd.original_seed >= 0) {
500641 extension_settings.sd.seed = extension_settings.sd.original_seed;
@@ -560,6 +701,7 @@ async function loadSettings() {
560701 $('#sd_google_api').val(extension_settings.sd.google_api);
561702 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
562703 $('#sd_google_duration').val(extension_settings.sd.google_duration);
704+ $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);
563705
564706 for (const style of extension_settings.sd.styles) {
565707 const option = document.createElement('option');
@@ -572,8 +714,12 @@ async function loadSettings() {
572714 const resolutionId = getClosestKnownResolution();
573715 $('#sd_resolution').val(resolutionId);
574716
717+ initPromptGenerationProfileDropdown();
718+
575719 toggleSourceControls();
576720 addPromptTemplates();
721+ renderCustomEntriesList();
722+ renderCustomDropdownEntries();
577723 registerFunctionTool();
578724
579725 await loadSettingOptions();
@@ -803,6 +949,217 @@ async function onRenameStyleClick() {
803949 saveSettingsDebounced();
804950}
805951
952+function onSavePresetPrimaryClick() {
953+ extension_settings.sd.settings_preset_primary = snapshotSdSettings();
954+ saveSettingsDebounced();
955+ toastr.success(t`Primary settings preset saved.`, t`Image Generation`);
956+}
957+
958+function onSavePresetSecondaryClick() {
959+ extension_settings.sd.settings_preset_secondary = snapshotSdSettings();
960+ saveSettingsDebounced();
961+ toastr.success(t`Secondary settings preset saved.`, t`Image Generation`);
962+}
963+
964+async function onLoadPresetPrimaryClick() {
965+ if (!isPresetConfigured(extension_settings.sd.settings_preset_primary)) {
966+ toastr.info(t`No primary settings preset has been saved yet.`, t`Image Generation`);
967+ return;
968+ }
969+
970+ applySdSettingsSnapshot(extension_settings.sd.settings_preset_primary);
971+ saveSettingsDebounced();
972+ await refreshSettingsUi();
973+ toastr.success(t`Primary settings preset loaded.`, t`Image Generation`);
974+}
975+
976+async function onLoadPresetSecondaryClick() {
977+ if (!isPresetConfigured(extension_settings.sd.settings_preset_secondary)) {
978+ toastr.info(t`No secondary settings preset has been saved yet.`, t`Image Generation`);
979+ return;
980+ }
981+
982+ applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary);
983+ saveSettingsDebounced();
984+ await refreshSettingsUi();
985+ toastr.success(t`Secondary settings preset loaded.`, t`Image Generation`);
986+}
987+
988+function onFallbackEnabledChange() {
989+ extension_settings.sd.settings_fallback_enabled = !!$(this).prop('checked');
990+ saveSettingsDebounced();
991+}
992+
993+/**
994+ * Rebuilds the custom wand entries list in the settings UI.
995+ */
996+function renderCustomEntriesList() {
997+ const container = $('#sd_custom_entries_list');
998+ if (!container.length) {
999+ return;
1000+ }
1001+
1002+ container.empty();
1003+
1004+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1005+
1006+ if (entries.length === 0) {
1007+ const empty = $('<small></small>')
1008+ .attr('data-i18n', 'No custom entries yet.')
1009+ .text('No custom entries yet.');
1010+ container.append(empty);
1011+ return;
1012+ }
1013+
1014+ for (const entry of entries) {
1015+ const preview = String(entry.prompt || '').replace(/\s+/g, ' ').trim();
1016+ const truncated = preview.length > 80 ? preview.slice(0, 80) + '…' : preview;
1017+
1018+ const titleEl = $('<div></div>').addClass('sd_custom_entry_title').text(entry.title);
1019+ const previewEl = $('<small></small>').addClass('sd_custom_entry_preview').text(truncated);
1020+ const textBlock = $('<div></div>').addClass('flex1 flexFlowColumn').append(titleEl).append(previewEl);
1021+
1022+ const editButton = $('<div></div>')
1023+ .addClass('menu_button menu_button_icon fa-solid fa-pencil')
1024+ .attr('data-entry-id', entry.id)
1025+ .attr('data-action', 'edit')
1026+ .attr('title', 'Edit')
1027+ .attr('data-i18n', '[title]Edit');
1028+ const deleteButton = $('<div></div>')
1029+ .addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1030+ .attr('data-entry-id', entry.id)
1031+ .attr('data-action', 'delete')
1032+ .attr('title', 'Delete')
1033+ .attr('data-i18n', '[title]Delete');
1034+
1035+ const row = $('<div></div>')
1036+ .addClass('flex-container alignItemsCenter marginTopBot5 sd_custom_entry_row')
1037+ .append(textBlock)
1038+ .append(editButton)
1039+ .append(deleteButton);
1040+
1041+ container.append(row);
1042+ }
1043+}
1044+
1045+/**
1046+ * Generates a unique id for a custom entry.
1047+ * @returns {string} A unique identifier.
1048+ */
1049+function getUniqueCustomEntryId() {
1050+ return crypto.randomUUID?.() ?? ('ce_' + Date.now() + '_' + Math.floor(Math.random() * 1e6));
1051+}
1052+
1053+/**
1054+ * Opens a popup to create/edit a custom entry and returns the entered values.
1055+ * @param {string} title Initial title value.
1056+ * @param {string} prompt Initial prompt value.
1057+ * @returns {Promise<{title: string, prompt: string} | null>} Entered values, or null if cancelled.
1058+ */
1059+async function showCustomEntryPopup(title, prompt) {
1060+ const form = $('<div></div>').addClass('flex-container flexFlowColumn');
1061+ const titleLabel = $('<label></label>').attr('data-i18n', 'Title').text('Title');
1062+ const titleInput = $('<input>')
1063+ .addClass('text_pole')
1064+ .attr('type', 'text')
1065+ .attr('id', 'sd_custom_entry_title_input')
1066+ .val(title || '');
1067+ const promptLabel = $('<label></label>').attr('data-i18n', 'Prompt').text('Prompt');
1068+ const promptInput = $('<textarea></textarea>')
1069+ .addClass('text_pole textarea_compact')
1070+ .attr('id', 'sd_custom_entry_prompt_input')
1071+ .attr('rows', 5)
1072+ .val(prompt || '');
1073+
1074+ form.append(titleLabel).append(titleInput).append(promptLabel).append(promptInput);
1075+
1076+ const popup = new Popup(form, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1077+ const result = await popup.show();
1078+
1079+ if (!result) {
1080+ return null;
1081+ }
1082+
1083+ return {
1084+ title: String(titleInput.val() ?? '').trim(),
1085+ prompt: String(promptInput.val() ?? '').trim(),
1086+ };
1087+}
1088+
1089+async function onAddCustomEntryClick() {
1090+ const values = await showCustomEntryPopup('', '');
1091+
1092+ if (!values) {
1093+ return;
1094+ }
1095+
1096+ if (!values.title || !values.prompt) {
1097+ toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1098+ return;
1099+ }
1100+
1101+ if (!Array.isArray(extension_settings.sd.custom_entries)) {
1102+ extension_settings.sd.custom_entries = [];
1103+ }
1104+
1105+ extension_settings.sd.custom_entries.push({
1106+ id: getUniqueCustomEntryId(),
1107+ title: values.title,
1108+ prompt: values.prompt,
1109+ });
1110+
1111+ saveSettingsDebounced();
1112+ renderCustomEntriesList();
1113+ renderCustomDropdownEntries();
1114+}
1115+
1116+async function onEditCustomEntryClick(id) {
1117+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1118+ const entry = entries.find(e => e.id === id);
1119+
1120+ if (!entry) {
1121+ return;
1122+ }
1123+
1124+ const values = await showCustomEntryPopup(entry.title, entry.prompt);
1125+
1126+ if (!values) {
1127+ return;
1128+ }
1129+
1130+ if (!values.title || !values.prompt) {
1131+ toastr.warning(t`Both a title and a prompt are required.`, t`Image Generation`);
1132+ return;
1133+ }
1134+
1135+ entry.title = values.title;
1136+ entry.prompt = values.prompt;
1137+
1138+ saveSettingsDebounced();
1139+ renderCustomEntriesList();
1140+ renderCustomDropdownEntries();
1141+}
1142+
1143+async function onDeleteCustomEntryClick(id) {
1144+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
1145+ const index = entries.findIndex(e => e.id === id);
1146+
1147+ if (index === -1) {
1148+ return;
1149+ }
1150+
1151+ const confirmed = await callGenericPopup(t`Are you sure you want to delete the entry "${entries[index].title}"?`, POPUP_TYPE.CONFIRM, '', { okButton: t`Delete`, cancelButton: t`Cancel` });
1152+
1153+ if (!confirmed) {
1154+ return;
1155+ }
1156+
1157+ entries.splice(index, 1);
1158+ saveSettingsDebounced();
1159+ renderCustomEntriesList();
1160+ renderCustomDropdownEntries();
1161+}
1162+
8061163/**
8071164 * Modifies prompt based on user inputs.
8081165 * @param {string} prompt Prompt to refine
@@ -2858,6 +3215,16 @@ async function loadComfyWorkflows() {
28583215}
28593216
28603217function getGenerationType(prompt) {
3218+ // Custom wand entries use the trigger convention 'custom_<id>' and bypass
3219+ // the multimodal/free_extend transforms applied to the built-in triggers.
3220+ const trimmedPrompt = String(prompt).trim();
3221+ if (Array.isArray(extension_settings.sd.custom_entries)) {
3222+ const customEntry = extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === trimmedPrompt);
3223+ if (customEntry) {
3224+ return generationMode.CUSTOM;
3225+ }
3226+ }
3227+
28613228 let mode = generationMode.FREE;
28623229
28633230 for (const [key, values] of Object.entries(triggerWords)) {
@@ -2881,6 +3248,13 @@ function getGenerationType(prompt) {
28813248}
28823249
28833250function getQuietPrompt(mode, trigger) {
3251+ if (mode === generationMode.CUSTOM) {
3252+ const entry = Array.isArray(extension_settings.sd.custom_entries)
3253+ ? extension_settings.sd.custom_entries.find(e => ('custom_' + e.id) === String(trigger).trim())
3254+ : undefined;
3255+ return entry ? entry.prompt : trigger;
3256+ }
3257+
28843258 if (mode === generationMode.FREE) {
28853259 return trigger;
28863260 }
@@ -3291,9 +3665,18 @@ function getUserAvatarUrl() {
32913665 */
32923666async function generatePrompt(quietPrompt) {
32933667 const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
3294- const reply = await generateQuietPrompt({ quietPrompt });
3668+ const profileId = extension_settings.sd.prompt_generation_profile;
3669+ let reply;
3670+
3671+ try {
3672+ reply = profileId
3673+ ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt }))
3674+ : await generateQuietPrompt({ quietPrompt });
3675+ } finally {
3676+ toastr.clear(toast);
3677+ }
3678+
32953679 const processedReply = processReply(reply);
3296- toastr.clear(toast);
32973680
32983681 if (!processedReply) {
32993682 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');
@@ -3304,6 +3687,68 @@ async function generatePrompt(quietPrompt) {
33043687}
33053688
33063689/**
3690+ * Runs a callback with a specific Connection Manager profile temporarily active,
3691+ * then restores the previously active profile. This lets the image prompt be
3692+ * generated by the chosen LLM *with the full chat context* (via generateQuietPrompt),
3693+ * instead of a context-free one-off request.
3694+ *
3695+ * The switch is only performed when a real connection profile is currently active
3696+ * (so it can be reliably restored). When no profile is active — i.e. the user drives
3697+ * the API panel manually — switching to a profile could not be undone without
3698+ * clobbering those manual settings, so we leave the active model in place and warn once.
3699+ *
3700+ * @param {string} targetProfileId Profile to activate for the duration of the callback.
3701+ * @param {() => Promise<any>} callback Work to run while the target profile is active.
3702+ * @returns {Promise<any>} The callback's result.
3703+ */
3704+async function withConnectionProfile(targetProfileId, callback) {
3705+ const select = /** @type {HTMLSelectElement} */ (document.getElementById('connection_profiles'));
3706+ const connectionManager = extension_settings.connectionManager;
3707+ const currentProfileId = connectionManager?.selectedProfile;
3708+
3709+ const canSwitch = !!select
3710+ && !!connectionManager
3711+ && Array.isArray(connectionManager.profiles)
3712+ && connectionManager.profiles.some(p => p.id === targetProfileId)
3713+ && Array.from(select.options).some(o => o.value === targetProfileId)
3714+ && !!currentProfileId // a real profile is active, so it can be restored afterwards
3715+ && currentProfileId !== targetProfileId;
3716+
3717+ if (!canSwitch) {
3718+ // Selected but no base profile to restore from -> use the active model and warn once.
3719+ if (targetProfileId && !currentProfileId && !promptProfileWarnedNoBaseProfile) {
3720+ promptProfileWarnedNoBaseProfile = true;
3721+ toastr.info('The image-prompt LLM profile is only applied while a connection profile is active (so the original can be restored). Using the current model.', 'Image Generation');
3722+ }
3723+ return await callback();
3724+ }
3725+
3726+ const switchToProfile = async (profileId) => {
3727+ const loaded = new Promise(resolve => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
3728+ const index = Array.from(select.options).findIndex(o => o.value === profileId);
3729+ select.selectedIndex = index >= 0 ? index : 0;
3730+ select.dispatchEvent(new Event('change'));
3731+ // Wait for the profile's commands to finish applying (don't hang forever if the event never fires).
3732+ await Promise.race([loaded, delay(10000)]);
3733+ // Applying a profile reconnects the API, which is asynchronous. Generating before the
3734+ // connection is re-established fails instantly, so wait for it to come back up
3735+ // (mirrors the built-in /profile command). rejectOnTimeout:false -> proceed anyway after the timeout.
3736+ await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100, { rejectOnTimeout: false });
3737+ };
3738+
3739+ await switchToProfile(targetProfileId);
3740+ try {
3741+ return await callback();
3742+ } finally {
3743+ try {
3744+ await switchToProfile(currentProfileId);
3745+ } catch (err) {
3746+ console.error('SD: failed to restore the previous connection profile after image-prompt generation', err);
3747+ }
3748+ }
3749+}
3750+
3751+/**
33073752 * Sends a request to image generation endpoint and processes the result.
33083753 * @param {number} generationType Type of image generation
33093754 * @param {string} prompt Prompt to be used for image generation
@@ -3321,108 +3766,121 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
33213766
33223767 const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType);
33233768
3324- const prefix = skipCharPrefix
3769+ /**
3325- ? extension_settings.sd.prompt_prefix
3770+ * Performs a single image generation attempt against the live extension_settings.sd config.
3326- : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix());
3771+ * Reads settings live so it can be retried after applying a fallback preset.
3327-
3772+ * @param {AbortSignal} attemptSignal Abort signal to cancel the request.
3328- const negativePrefix = skipCharPrefix
3773+ * @returns {Promise<{result: {format: string, data: string}, prefixedPrompt: string}>}
3329- ? extension_settings.sd.negative_prompt
3774+ * @throws {Error} On failure or when the endpoint returns no image data.
3330- : combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix());
3775+ */
3776+ async function attemptImageGeneration(attemptSignal) {
3777+ const prefix = skipCharPrefix
3778+ ? extension_settings.sd.prompt_prefix
3779+ : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix());
33313780
3332- const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}'));
3781+ const negativePrefix = skipCharPrefix
3333- const negativePrompt = substituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix));
3782+ ? extension_settings.sd.negative_prompt
3783+ : combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix());
33343784
3335- let result = { format: '', data: '' };
3785+ const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}'));
33363786 const currentChatIdnegativePrompt = getCurrentChatIdsubstituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix));
33373787
3338- try {
3788+ let result = { format: '', data: '' };
33393789 switch (extension_settings.sd.source) {
33403790 case sources.extras:
33413791 result = await generateExtrasImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33423792 break;
33433793 case sources.horde:
33443794 result = await generateHordeImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33453795 break;
33463796 case sources.vlad:
33473797 result = await generateAutoImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33483798 break;
33493799 case sources.drawthings:
33503800 result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33513801 break;
33523802 case sources.auto:
33533803 result = await generateAutoImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33543804 break;
33553805 case sources.sdcpp:
33563806 result = await generateSdcppImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33573807 break;
33583808 case sources.novel:
33593809 result = await generateNovelImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33603810 break;
33613811 case sources.openai:
33623812 result = await generateOpenAiImage(prefixedPrompt, signalattemptSignal);
33633813 break;
33643814 case sources.aimlapi:
33653815 result = await generateAimlapiImage(prefixedPrompt, signalattemptSignal);
33663816 break;
33673817 case sources.comfy:
33683818 switch (extension_settings.sd.comfy_type) {
33693819 case comfyTypes.runpod_serverless:
33703820 result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33713821 break;
33723822 case comfyTypes.standard:
33733823 result = await generateComfyImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33743824 break;
33753825 default:
33763826 throw new Error('Unknown comfyUI server type.');
33773827 }
33783828 break;
33793829 case sources.togetherai:
33803830 result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33813831 break;
33823832 case sources.pollinations:
33833833 result = await generatePollinationsImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33843834 break;
33853835 case sources.stability:
33863836 result = await generateStabilityImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33873837 break;
33883838 case sources.huggingface:
33893839 result = await generateHuggingFaceImage(prefixedPrompt, signalattemptSignal);
33903840 break;
33913841 case sources.chutes:
33923842 result = await generateChutesImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33933843 break;
33943844 case sources.electronhub:
33953845 result = await generateElectronHubImage(prefixedPrompt, signalattemptSignal);
33963846 break;
33973847 case sources.nanogpt:
33983848 result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, signalattemptSignal);
33993849 break;
34003850 case sources.bfl:
34013851 result = await generateBflImage(prefixedPrompt, signalattemptSignal);
34023852 break;
34033853 case sources.falai:
34043854 result = await generateFalaiImage(prefixedPrompt, negativePrompt, signalattemptSignal);
34053855 break;
34063856 case sources.xai:
34073857 result = await generateXAIImage(prefixedPrompt, negativePrompt, signalattemptSignal);
34083858 break;
34093859 case sources.google:
34103860 result = await generateGoogleImage(prefixedPrompt, negativePrompt, signalattemptSignal);
34113861 break;
34123862 case sources.zai:
34133863 result = await generateZaiImage(prefixedPrompt, signalattemptSignal);
34143864 break;
34153865 case sources.openrouter:
34163866 result = await generateOpenRouterImage(prefixedPrompt, signalattemptSignal);
34173867 break;
34183868 case sources.workersai:
34193869 result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signalattemptSignal);
34203870 break;
34213871 }
34223872
34233873 if (!result.data) {
34243874 throw new Error('Endpoint did not return image data.');
34253875 }
3876+
3877+ return { result, prefixedPrompt };
3878+ }
3879+
3880+ const currentChatId = getCurrentChatId();
3881+ let genOutput;
3882+ try {
3883+ genOutput = await attemptImageGeneration(signal);
34263884 } catch (err) {
34273885 // Check if this was an intentional abort by user
34283886 if (signal?.aborted) {
@@ -3431,11 +3889,35 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
34313889 return;
34323890 }
34333891
3434- console.error('Image generation request error: ', err);
3892+ if (extension_settings.sd.settings_fallback_enabled && isPresetConfigured(extension_settings.sd.settings_preset_secondary)) {
3435- toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
3893+ console.warn('SD: primary generation failed, falling back to secondary preset', err);
3436- return;
3894+ toastr.warning('Primary image generation failed. Retrying with secondary settings…', 'Image Generation');
3895+ const restore = snapshotSdSettings();
3896+ try {
3897+ applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary);
3898+ genOutput = await attemptImageGeneration(signal);
3899+ } catch (err2) {
3900+ applySdSettingsSnapshot(restore);
3901+ if (signal?.aborted) {
3902+ console.log('SD: Image generation aborted by user');
3903+ toastr.info('Image generation stopped.', 'Image Generation');
3904+ return;
3905+ }
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+ }
3910+ // Restore live (primary) settings after a successful fallback attempt.
3911+ applySdSettingsSnapshot(restore);
3912+ } else {
3913+ console.error('Image generation request error: ', err);
3914+ toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
3915+ return;
3916+ }
34373917 }
34383918
3919+ const { result, prefixedPrompt } = genOutput;
3920+
34393921 if (currentChatId !== getCurrentChatId()) {
34403922 console.warn('Chat changed, aborting SD result saving');
34413923 toastr.warning('Chat changed, generated image discarded.', 'Image Generation');
@@ -5051,7 +5533,10 @@ async function addSDGenButtons() {
50515533 }
50525534 });
50535535
5054- $('#sd_dropdown [id]').on('click', function () {
5536+ renderCustomDropdownEntries();
5537+
5538+ // Use event delegation so dynamically-added custom entries also respond to clicks.
5539+ $('#sd_dropdown').on('click', 'li[id]', function () {
50555540 dropdown.fadeOut(animation_duration);
50565541 const id = $(this).attr('id');
50575542 const idParamMap = {
@@ -5069,10 +5554,39 @@ async function addSDGenButtons() {
50695554 if (param) {
50705555 console.log('doing /sd ' + param);
50715556 generatePicture(initiators.wand, {}, param);
5557+ return;
5558+ }
5559+
5560+ if (id && id.startsWith('sd_custom_')) {
5561+ const entryId = id.slice('sd_custom_'.length);
5562+ console.log('doing /sd custom_' + entryId);
5563+ generatePicture(initiators.wand, {}, 'custom_' + entryId);
50725564 }
50735565 });
50745566}
50755567
5568+/**
5569+ * Renders the user-defined custom entries into the wand dropdown.
5570+ * Removes any previously rendered custom entries first.
5571+ */
5572+function renderCustomDropdownEntries() {
5573+ const list = $('#sd_dropdown ul.list-group');
5574+ if (!list.length) {
5575+ return;
5576+ }
5577+
5578+ list.find('li.sd_custom_entry').remove();
5579+
5580+ const entries = Array.isArray(extension_settings.sd.custom_entries) ? extension_settings.sd.custom_entries : [];
5581+ for (const entry of entries) {
5582+ const li = $('<li></li>')
5583+ .addClass('list-group-item sd_custom_entry')
5584+ .attr('id', 'sd_custom_' + entry.id)
5585+ .text(entry.title);
5586+ list.append(li);
5587+ }
5588+}
5589+
50765590function isValidState() {
50775591 switch (extension_settings.sd.source) {
50785592 case sources.extras:
@@ -5859,6 +6373,21 @@ export async function init() {
58596373 $('#sd_save_style').on('click', onSaveStyleClick);
58606374 $('#sd_rename_style').on('click', onRenameStyleClick);
58616375 $('#sd_delete_style').on('click', onDeleteStyleClick);
6376+ $('#sd_save_preset_primary').on('click', onSavePresetPrimaryClick);
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);
6381+ $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
6382+ $('#sd_custom_entries_list').on('click', '[data-action]', function () {
6383+ const id = $(this).attr('data-entry-id');
6384+ const action = $(this).attr('data-action');
6385+ if (action === 'edit') {
6386+ onEditCustomEntryClick(id);
6387+ } else if (action === 'delete') {
6388+ onDeleteCustomEntryClick(id);
6389+ }
6390+ });
58626391 $('#sd_character_prompt_block').hide();
58636392 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
58646393 $('#sd_openai_style').on('change', onOpenAiStyleSelect);
public/scripts/extensions/stable-diffusion/settings.html+34 -0
@@ -39,6 +39,24 @@
3939 <input id="sd_minimal_prompt_processing" type="checkbox" />
4040 <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>
4141 </label>
42+ <label for="sd_prompt_generation_profile" data-i18n="Prompt generation connection profile">Prompt generation connection profile</label>
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>
45+ <hr>
46+ <h4 data-i18n="Settings Presets & Fallback">Settings Presets &amp; Fallback</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>
48+ <div class="flex-container marginTopBot5 flexWrap">
49+ <div id="sd_save_preset_primary" class="menu_button" data-i18n="Save as Primary">Save as Primary</div>
50+ <div id="sd_load_preset_primary" class="menu_button" data-i18n="Load Primary">Load Primary</div>
51+ <div id="sd_save_preset_secondary" class="menu_button" data-i18n="Save as Secondary">Save as Secondary</div>
52+ <div id="sd_load_preset_secondary" class="menu_button" data-i18n="Load Secondary">Load Secondary</div>
53+ </div>
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.">
55+ <input id="sd_fallback_enabled" type="checkbox" />
56+ <span data-i18n="sd_fallback_enabled_txt">Auto-fallback to Secondary on failure</span>
57+ </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>
59+ <hr>
4260 <label for="sd_source" data-i18n="Source">Source</label>
4361 <select id="sd_source" class="text_pole">
4462 <option value="aimlapi">AI/ML API</option>
@@ -670,4 +688,20 @@
670688 <div id="sd_prompt_templates" class="inline-drawer-content">
671689 </div>
672690 </div>
691+ <div class="inline-drawer">
692+ <div class="inline-drawer-toggle inline-drawer-header">
693+ <b data-i18n="Custom Wand Entries">Custom Wand Entries</b>
694+ <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
695+ </div>
696+ <div class="inline-drawer-content">
697+ <small data-i18n="sd_custom_entries_small">Add your own entries to the image generation wand dropdown. Clicking an entry uses its prompt as the LLM instruction, just like the built-in entries.</small>
698+ <div class="flex-container marginTopBot5">
699+ <div id="sd_custom_entry_add" class="menu_button menu_button_icon">
700+ <i class="fa-solid fa-plus"></i>
701+ <span data-i18n="Add entry">Add entry</span>
702+ </div>
703+ </div>
704+ <div id="sd_custom_entries_list"></div>
705+ </div>
706+ </div>
673707</div>
public/scripts/extensions/stable-diffusion/style.css+14 -0
@@ -87,3 +87,17 @@
8787 top: 0;
8888 transform: translateX(-50%);
8989}
90+
91+.sd_custom_entry_row .sd_custom_entry_title {
92+ font-weight: bold;
93+ overflow: hidden;
94+ text-overflow: ellipsis;
95+ white-space: nowrap;
96+}
97+
98+.sd_custom_entry_row .sd_custom_entry_preview {
99+ opacity: 0.7;
100+ overflow: hidden;
101+ text-overflow: ellipsis;
102+ white-space: nowrap;
103+}