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 @@
1import { DOMPurify, Fuse } from '../../../lib.js';1import { DOMPurify, Fuse, Popper } from '../../../lib.js';
22
3import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';3import { activateSendButtons, animation_duration, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
4import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';4import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
6import { SlashCommand } from '../../slash-commands/SlashCommand.js';6import { SlashCommand } from '../../slash-commands/SlashCommand.js';
@@ -695,6 +695,138 @@ async function generateStreamCallback(args, value) {
695 }695 }
696}696}
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 */
703function 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
698export async function init() {830export async function init() {
699 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);831 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
700832
@@ -713,6 +845,8 @@ export async function init() {
713 const profiles = document.getElementById('connection_profiles');845 const profiles = document.getElementById('connection_profiles');
714 renderConnectionProfiles(profiles);846 renderConnectionProfiles(profiles);
715847
848 addQuickSwitchButton();
849
716 function toggleProfileSpecificButtons() {850 function toggleProfileSpecificButtons() {
717 const profileId = extension_settings.connectionManager.selectedProfile;851 const profileId = extension_settings.connectionManager.selectedProfile;
718 const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];852 const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
public/scripts/extensions/connection-manager/style.css+26 -0
@@ -9,3 +9,29 @@
9#connection_profile_spinner {9#connection_profile_spinner {
10 margin-left: 5px;10 margin-left: 5px;
11}11}
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 @@
1import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
2import {
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
17export { init };
18
19const 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.
24const KEEPALIVE_RESPONSE_LENGTH = 1;
25
26// Extension-prompt key used to inject the keepalive message as a USER message at depth 0.
27const KEEPALIVE_INJECT_ID = 'keepalive_ping';
28
29// Original default message; used to migrate untouched installs to the current default wording.
30const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.';
31
32// Idle-tracking key used when no connection profile is active (manual API panel).
33const 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.
38const 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.
43const MAX_TIMER_DRIFT_MS = 5000;
44
45const 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
53let idleTimer = null;
54let 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.
59let 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).
62let 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.
65const lastActivity = {};
66
67/**
68 * @returns {string} The active connection profile id, or NONE_KEY for a manual connection.
69 */
70function 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 */
80function 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 */
96function 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 */
110function 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 */
127function 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 */
138function 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 */
147function 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 */
171async 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 */
224function 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 */
260function 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 */
292function 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
325async 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 {
10 extension_prompt_types,10 extension_prompt_types,
11 generateQuietPrompt,11 generateQuietPrompt,
12 is_send_press,12 is_send_press,
13 online_status,
13 saveSettingsDebounced,14 saveSettingsDebounced,
14 substituteParamsExtended,15 substituteParamsExtended,
15 generateRaw,16 generateRaw,
@@ -27,7 +28,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
27import { SlashCommand } from '../../slash-commands/SlashCommand.js';28import { SlashCommand } from '../../slash-commands/SlashCommand.js';
28import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';29import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
29import { macros, MacroCategory } from '../../macros/macro-system.js';30import { macros, MacroCategory } from '../../macros/macro-system.js';
30import { countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';31import { ConnectionManagerRequestService, countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';
31import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';32import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
32import { removeReasoningFromString } from '../../reasoning.js';33import { removeReasoningFromString } from '../../reasoning.js';
33import { MacrosParser } from '/scripts/macros.js';34import { MacrosParser } from '/scripts/macros.js';
@@ -136,8 +137,49 @@ const defaultSettings = {
136 maxMessagesPerRequestMax: 250,137 maxMessagesPerRequestMax: 250,
137 maxMessagesPerRequestStep: 1,138 maxMessagesPerRequestStep: 1,
138 prompt_builder: prompt_builders.DEFAULT,139 prompt_builder: prompt_builders.DEFAULT,
140 summaryPromptRole: extension_prompt_roles.SYSTEM,
141 summaryConnectionProfile: '',
139};142};
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 */
149function getSummaryPromptRole() {
150 return Number(extension_settings.memory.summaryPromptRole) === extension_prompt_roles.USER
151 ? extension_prompt_roles.USER
152 : extension_prompt_roles.SYSTEM;
153}
154
155let 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 */
162function 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
141function loadSettings() {183function loadSettings() {
142 if (Object.keys(extension_settings.memory).length === 0) {184 if (Object.keys(extension_settings.memory).length === 0) {
143 Object.assign(extension_settings.memory, defaultSettings);185 Object.assign(extension_settings.memory, defaultSettings);
@@ -158,6 +200,8 @@ function loadSettings() {
158 $('#memory_template').val(extension_settings.memory.template).trigger('input');200 $('#memory_template').val(extension_settings.memory.template).trigger('input');
159 $('#memory_depth').val(extension_settings.memory.depth).trigger('input');201 $('#memory_depth').val(extension_settings.memory.depth).trigger('input');
160 $('#memory_role').val(extension_settings.memory.role).trigger('input');202 $('#memory_role').val(extension_settings.memory.role).trigger('input');
203 $('#memory_summary_prompt_role').val(extension_settings.memory.summaryPromptRole).trigger('input');
204 initSummaryConnectionProfileDropdown();
161 $(`input[name="memory_position"][value="${extension_settings.memory.position}"]`).prop('checked', true).trigger('input');205 $(`input[name="memory_position"][value="${extension_settings.memory.position}"]`).prop('checked', true).trigger('input');
162 $('#memory_prompt_words_force').val(extension_settings.memory.promptForceWords).trigger('input');206 $('#memory_prompt_words_force').val(extension_settings.memory.promptForceWords).trigger('input');
163 $(`input[name="memory_prompt_builder"][value="${extension_settings.memory.prompt_builder}"]`).prop('checked', true).trigger('input');207 $(`input[name="memory_prompt_builder"][value="${extension_settings.memory.prompt_builder}"]`).prop('checked', true).trigger('input');
@@ -314,6 +358,12 @@ function onMemoryRoleInput() {
314 saveSettingsDebounced();358 saveSettingsDebounced();
315}359}
316360
361function onMemorySummaryPromptRoleInput() {
362 const value = $(this).val();
363 extension_settings.memory.summaryPromptRole = Number(value);
364 saveSettingsDebounced();
365}
366
317function onMemoryPositionChange(e) {367function onMemoryPositionChange(e) {
318 const value = e.target.value;368 const value = e.target.value;
319 extension_settings.memory.position = value;369 extension_settings.memory.position = value;
@@ -515,15 +565,22 @@ async function summarizeCallback(args, text) {
515565
516 const source = args.source || extension_settings.memory.source;566 const source = args.source || extension_settings.memory.source;
517 const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords });567 const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords });
568 const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER;
518569
519 try {570 try {
520 switch (source) {571 switch (source) {
521 case summary_sources.extras:572 case summary_sources.extras:
522 return await callExtrasSummarizeAPI(text);573 return await callExtrasSummarizeAPI(text);
523 case summary_sources.main:574 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 }
525 case summary_sources.webllm: {581 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);
527 const params = extension_settings.memory.overrideResponseLength > 0 ? { max_tokens: extension_settings.memory.overrideResponseLength } : {};584 const params = extension_settings.memory.overrideResponseLength > 0 ? { max_tokens: extension_settings.memory.overrideResponseLength } : {};
528 return await generateWebLlmChatPrompt(messages, params);585 return await generateWebLlmChatPrompt(messages, params);
529 }586 }
@@ -646,8 +703,9 @@ async function summarizeChatWebLLM(context, force) {
646 return null;703 return null;
647 }704 }
648705
706 const promptRole = getSummaryPromptRole() === extension_prompt_roles.USER ? 'user' : 'system';
649 const messages = [707 const messages = [
650 { role: 'system', content: prompt },708 { role: promptRole, content: prompt },
651 { role: 'user', content: rawPrompt },709 { role: 'user', content: rawPrompt },
652 ];710 ];
653711
@@ -678,6 +736,73 @@ async function summarizeChatWebLLM(context, force) {
678 }736 }
679}737}
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.
741let 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 */
758async 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
681async function summarizeChatMain(context, force, skipWIAN) {806async function summarizeChatMain(context, force, skipWIAN) {
682 const prompt = await getSummaryPromptForNow(context, force);807 const prompt = await getSummaryPromptForNow(context, force);
683808
@@ -686,59 +811,101 @@ async function summarizeChatMain(context, force, skipWIAN) {
686 }811 }
687812
688 console.log('sending summary prompt');813 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 }
704 }846 }
705 }
706847
707 if ([prompt_builders.RAW_BLOCKING, prompt_builders.RAW_NON_BLOCKING].includes(extension_settings.memory.prompt_builder)) {848 if ([prompt_builders.RAW_BLOCKING, prompt_builders.RAW_NON_BLOCKING].includes(extension_settings.memory.prompt_builder)) {
708 const lock = extension_settings.memory.prompt_builder === prompt_builders.RAW_BLOCKING;849 const lock = extension_settings.memory.prompt_builder === prompt_builders.RAW_BLOCKING;
709 try {850 try {
710 inApiCall = true;851 if (lock) {
711 if (lock) {852 deactivateSendButtons();
712 deactivateSendButtons();853 }
713 }
714854
715 const { rawPrompt, lastUsedIndex } = await getRawSummaryPrompt(context, prompt);855 const { rawPrompt, lastUsedIndex } = await getRawSummaryPrompt(context, prompt);
716856
717 if (lastUsedIndex === null || lastUsedIndex === -1) {857 if (lastUsedIndex === null || lastUsedIndex === -1) {
718 if (force) {858 if (force) {
719 toastr.info('To try again, remove the latest summary.', 'No messages found to summarize');859 toastr.info('To try again, remove the latest summary.', 'No messages found to summarize');
720 }860 }
721861
722 return null;862 return null;
723 }863 }
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 }
738 }882 }
739 }883 }
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;
740 }901 }
741902
903 if (result === null) {
904 return null;
905 }
906
907 const { summary, index } = result;
908
742 if (!summary) {909 if (!summary) {
743 console.warn('Empty summary received');910 console.warn('Empty summary received');
744 return;911 return;
@@ -1047,6 +1214,7 @@ function setupListeners() {
1047 $('#memory_template').off('input').on('input', onMemoryTemplateInput);1214 $('#memory_template').off('input').on('input', onMemoryTemplateInput);
1048 $('#memory_depth').off('input').on('input', onMemoryDepthInput);1215 $('#memory_depth').off('input').on('input', onMemoryDepthInput);
1049 $('#memory_role').off('input').on('input', onMemoryRoleInput);1216 $('#memory_role').off('input').on('input', onMemoryRoleInput);
1217 $('#memory_summary_prompt_role').off('input').on('input', onMemorySummaryPromptRoleInput);
1050 $('input[name="memory_position"]').off('change').on('change', onMemoryPositionChange);1218 $('input[name="memory_position"]').off('change').on('change', onMemoryPositionChange);
1051 $('#memory_prompt_words_force').off('input').on('input', onMemoryPromptWordsForceInput);1219 $('#memory_prompt_words_force').off('input').on('input', onMemoryPromptWordsForceInput);
1052 $('#memory_prompt_builder_default').off('input').on('input', onMemoryPromptBuilderInput);1220 $('#memory_prompt_builder_default').off('input').on('input', onMemoryPromptBuilderInput);
public/scripts/extensions/memory/settings.html+9 -0
@@ -69,6 +69,15 @@
69 </div>69 </div>
70 </label>70 </label>
71 <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>71 <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>
72 <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>81 <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>
73 <input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" />82 <input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" />
74 <label for="memory_override_response_length">83 <label for="memory_override_response_length">
public/scripts/extensions/stable-diffusion/index.js+573 -44
@@ -10,6 +10,7 @@ import {
10 getCurrentChatId,10 getCurrentChatId,
11 getRequestHeaders,11 getRequestHeaders,
12 getUserAvatar,12 getUserAvatar,
13 online_status,
13 saveSettingsDebounced,14 saveSettingsDebounced,
14 substituteParams,15 substituteParams,
15 substituteParamsExtended,16 substituteParamsExtended,
@@ -40,11 +41,12 @@ import {
40 resetScrollHeight,41 resetScrollHeight,
41 saveBase64AsFile,42 saveBase64AsFile,
42 stringFormat,43 stringFormat,
44 waitUntilCondition,
43} from '../../utils.js';45} from '../../utils.js';
44import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';46import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';
45import { SECRET_KEYS, secret_state } from '../../secrets.js';47import { SECRET_KEYS, secret_state } from '../../secrets.js';
46import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js';48import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js';
47import { getMultimodalCaption } from '../shared.js';49import { ConnectionManagerRequestService, getMultimodalCaption } from '../shared.js';
48import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';50import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
49import { SlashCommand } from '../../slash-commands/SlashCommand.js';51import { SlashCommand } from '../../slash-commands/SlashCommand.js';
50import {52import {
@@ -125,6 +127,7 @@ const generationMode = {
125 USER_MULTIMODAL: 9,127 USER_MULTIMODAL: 9,
126 FACE_MULTIMODAL: 10,128 FACE_MULTIMODAL: 10,
127 FREE_EXTENDED: 11,129 FREE_EXTENDED: 11,
130 CUSTOM: 12,
128};131};
129132
130const multimodalMap = {133const multimodalMap = {
@@ -147,6 +150,7 @@ const modeLabels = {
147 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',150 [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)',
148 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',151 [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)',
149 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',152 [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)',
153 [generationMode.CUSTOM]: 'Custom',
150};154};
151155
152const triggerWords = {156const triggerWords = {
@@ -360,8 +364,97 @@ const defaultSettings = {
360 google_api: 'makersuite',364 google_api: 'makersuite',
361 google_enhance: true,365 google_enhance: true,
362 google_duration: 6,366 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: [],
363};378};
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 */
386const 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 */
409function 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 */
424function 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 */
443function 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 */
452async function refreshSettingsUi() {
453 // loadSettings() appends to #sd_style without clearing it, so empty it first.
454 $('#sd_style').empty();
455 await loadSettings();
456}
457
365const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);458const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
366const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase());459const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase());
367460
@@ -459,6 +552,37 @@ function toggleSourceControls() {
459 });552 });
460}553}
461554
555let 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.
558let 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 */
565function 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
462async function loadSettings() {586async function loadSettings() {
463 // Initialize settings587 // Initialize settings
464 if (Object.keys(extension_settings.sd).length === 0) {588 if (Object.keys(extension_settings.sd).length === 0) {
@@ -495,6 +619,23 @@ async function loadSettings() {
495 extension_settings.sd.styles = defaultStyles;619 extension_settings.sd.styles = defaultStyles;
496 }620 }
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
498 // Preserve an original seed if exists639 // Preserve an original seed if exists
499 if (extension_settings.sd.original_seed >= 0) {640 if (extension_settings.sd.original_seed >= 0) {
500 extension_settings.sd.seed = extension_settings.sd.original_seed;641 extension_settings.sd.seed = extension_settings.sd.original_seed;
@@ -560,6 +701,7 @@ async function loadSettings() {
560 $('#sd_google_api').val(extension_settings.sd.google_api);701 $('#sd_google_api').val(extension_settings.sd.google_api);
561 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);702 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
562 $('#sd_google_duration').val(extension_settings.sd.google_duration);703 $('#sd_google_duration').val(extension_settings.sd.google_duration);
704 $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);
563705
564 for (const style of extension_settings.sd.styles) {706 for (const style of extension_settings.sd.styles) {
565 const option = document.createElement('option');707 const option = document.createElement('option');
@@ -572,8 +714,12 @@ async function loadSettings() {
572 const resolutionId = getClosestKnownResolution();714 const resolutionId = getClosestKnownResolution();
573 $('#sd_resolution').val(resolutionId);715 $('#sd_resolution').val(resolutionId);
574716
717 initPromptGenerationProfileDropdown();
718
575 toggleSourceControls();719 toggleSourceControls();
576 addPromptTemplates();720 addPromptTemplates();
721 renderCustomEntriesList();
722 renderCustomDropdownEntries();
577 registerFunctionTool();723 registerFunctionTool();
578724
579 await loadSettingOptions();725 await loadSettingOptions();
@@ -803,6 +949,217 @@ async function onRenameStyleClick() {
803 saveSettingsDebounced();949 saveSettingsDebounced();
804}950}
805951
952function onSavePresetPrimaryClick() {
953 extension_settings.sd.settings_preset_primary = snapshotSdSettings();
954 saveSettingsDebounced();
955 toastr.success(t`Primary settings preset saved.`, t`Image Generation`);
956}
957
958function onSavePresetSecondaryClick() {
959 extension_settings.sd.settings_preset_secondary = snapshotSdSettings();
960 saveSettingsDebounced();
961 toastr.success(t`Secondary settings preset saved.`, t`Image Generation`);
962}
963
964async 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
976async 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
988function 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 */
996function 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 */
1049function 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 */
1059async 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
1089async 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
1116async 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
1143async 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
806/**1163/**
807 * Modifies prompt based on user inputs.1164 * Modifies prompt based on user inputs.
808 * @param {string} prompt Prompt to refine1165 * @param {string} prompt Prompt to refine
@@ -2858,6 +3215,16 @@ async function loadComfyWorkflows() {
2858}3215}
28593216
2860function getGenerationType(prompt) {3217function 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
2861 let mode = generationMode.FREE;3228 let mode = generationMode.FREE;
28623229
2863 for (const [key, values] of Object.entries(triggerWords)) {3230 for (const [key, values] of Object.entries(triggerWords)) {
@@ -2881,6 +3248,13 @@ function getGenerationType(prompt) {
2881}3248}
28823249
2883function getQuietPrompt(mode, trigger) {3250function 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
2884 if (mode === generationMode.FREE) {3258 if (mode === generationMode.FREE) {
2885 return trigger;3259 return trigger;
2886 }3260 }
@@ -3291,9 +3665,18 @@ function getUserAvatarUrl() {
3291 */3665 */
3292async function generatePrompt(quietPrompt) {3666async function generatePrompt(quietPrompt) {
3293 const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');3667 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
3295 const processedReply = processReply(reply);3679 const processedReply = processReply(reply);
3296 toastr.clear(toast);
32973680
3298 if (!processedReply) {3681 if (!processedReply) {
3299 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');3682 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) {
3304}3687}
33053688
3306/**3689/**
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 */
3704async 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/**
3307 * Sends a request to image generation endpoint and processes the result.3752 * Sends a request to image generation endpoint and processes the result.
3308 * @param {number} generationType Type of image generation3753 * @param {number} generationType Type of image generation
3309 * @param {string} prompt Prompt to be used for image generation3754 * @param {string} prompt Prompt to be used for image generation
@@ -3321,108 +3766,121 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
33213766
3322 const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType);3767 const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType);
33233768
3324 const prefix = skipCharPrefix3769 /**
3325 ? extension_settings.sd.prompt_prefix3770 * 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.
33273772 * @param {AbortSignal} attemptSignal Abort signal to cancel the request.
3328 const negativePrefix = skipCharPrefix3773 * @returns {Promise<{result: {format: string, data: string}, prefixedPrompt: string}>}
3329 ? extension_settings.sd.negative_prompt3774 * @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}'));
3336 const currentChatId = getCurrentChatId();3786 const negativePrompt = substituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix));
33373787
3338 try {3788 let result = { format: '', data: '' };
3339 switch (extension_settings.sd.source) {3789 switch (extension_settings.sd.source) {
3340 case sources.extras:3790 case sources.extras:
3341 result = await generateExtrasImage(prefixedPrompt, negativePrompt, signal);3791 result = await generateExtrasImage(prefixedPrompt, negativePrompt, attemptSignal);
3342 break;3792 break;
3343 case sources.horde:3793 case sources.horde:
3344 result = await generateHordeImage(prefixedPrompt, negativePrompt, signal);3794 result = await generateHordeImage(prefixedPrompt, negativePrompt, attemptSignal);
3345 break;3795 break;
3346 case sources.vlad:3796 case sources.vlad:
3347 result = await generateAutoImage(prefixedPrompt, negativePrompt, signal);3797 result = await generateAutoImage(prefixedPrompt, negativePrompt, attemptSignal);
3348 break;3798 break;
3349 case sources.drawthings:3799 case sources.drawthings:
3350 result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, signal);3800 result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, attemptSignal);
3351 break;3801 break;
3352 case sources.auto:3802 case sources.auto:
3353 result = await generateAutoImage(prefixedPrompt, negativePrompt, signal);3803 result = await generateAutoImage(prefixedPrompt, negativePrompt, attemptSignal);
3354 break;3804 break;
3355 case sources.sdcpp:3805 case sources.sdcpp:
3356 result = await generateSdcppImage(prefixedPrompt, negativePrompt, signal);3806 result = await generateSdcppImage(prefixedPrompt, negativePrompt, attemptSignal);
3357 break;3807 break;
3358 case sources.novel:3808 case sources.novel:
3359 result = await generateNovelImage(prefixedPrompt, negativePrompt, signal);3809 result = await generateNovelImage(prefixedPrompt, negativePrompt, attemptSignal);
3360 break;3810 break;
3361 case sources.openai:3811 case sources.openai:
3362 result = await generateOpenAiImage(prefixedPrompt, signal);3812 result = await generateOpenAiImage(prefixedPrompt, attemptSignal);
3363 break;3813 break;
3364 case sources.aimlapi:3814 case sources.aimlapi:
3365 result = await generateAimlapiImage(prefixedPrompt, signal);3815 result = await generateAimlapiImage(prefixedPrompt, attemptSignal);
3366 break;3816 break;
3367 case sources.comfy:3817 case sources.comfy:
3368 switch (extension_settings.sd.comfy_type) {3818 switch (extension_settings.sd.comfy_type) {
3369 case comfyTypes.runpod_serverless:3819 case comfyTypes.runpod_serverless:
3370 result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, signal);3820 result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, attemptSignal);
3371 break;3821 break;
3372 case comfyTypes.standard:3822 case comfyTypes.standard:
3373 result = await generateComfyImage(prefixedPrompt, negativePrompt, signal);3823 result = await generateComfyImage(prefixedPrompt, negativePrompt, attemptSignal);
3374 break;3824 break;
3375 default:3825 default:
3376 throw new Error('Unknown comfyUI server type.');3826 throw new Error('Unknown comfyUI server type.');
3377 }3827 }
3378 break;3828 break;
3379 case sources.togetherai:3829 case sources.togetherai:
3380 result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, signal);3830 result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, attemptSignal);
3381 break;3831 break;
3382 case sources.pollinations:3832 case sources.pollinations:
3383 result = await generatePollinationsImage(prefixedPrompt, negativePrompt, signal);3833 result = await generatePollinationsImage(prefixedPrompt, negativePrompt, attemptSignal);
3384 break;3834 break;
3385 case sources.stability:3835 case sources.stability:
3386 result = await generateStabilityImage(prefixedPrompt, negativePrompt, signal);3836 result = await generateStabilityImage(prefixedPrompt, negativePrompt, attemptSignal);
3387 break;3837 break;
3388 case sources.huggingface:3838 case sources.huggingface:
3389 result = await generateHuggingFaceImage(prefixedPrompt, signal);3839 result = await generateHuggingFaceImage(prefixedPrompt, attemptSignal);
3390 break;3840 break;
3391 case sources.chutes:3841 case sources.chutes:
3392 result = await generateChutesImage(prefixedPrompt, negativePrompt, signal);3842 result = await generateChutesImage(prefixedPrompt, negativePrompt, attemptSignal);
3393 break;3843 break;
3394 case sources.electronhub:3844 case sources.electronhub:
3395 result = await generateElectronHubImage(prefixedPrompt, signal);3845 result = await generateElectronHubImage(prefixedPrompt, attemptSignal);
3396 break;3846 break;
3397 case sources.nanogpt:3847 case sources.nanogpt:
3398 result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, signal);3848 result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, attemptSignal);
3399 break;3849 break;
3400 case sources.bfl:3850 case sources.bfl:
3401 result = await generateBflImage(prefixedPrompt, signal);3851 result = await generateBflImage(prefixedPrompt, attemptSignal);
3402 break;3852 break;
3403 case sources.falai:3853 case sources.falai:
3404 result = await generateFalaiImage(prefixedPrompt, negativePrompt, signal);3854 result = await generateFalaiImage(prefixedPrompt, negativePrompt, attemptSignal);
3405 break;3855 break;
3406 case sources.xai:3856 case sources.xai:
3407 result = await generateXAIImage(prefixedPrompt, negativePrompt, signal);3857 result = await generateXAIImage(prefixedPrompt, negativePrompt, attemptSignal);
3408 break;3858 break;
3409 case sources.google:3859 case sources.google:
3410 result = await generateGoogleImage(prefixedPrompt, negativePrompt, signal);3860 result = await generateGoogleImage(prefixedPrompt, negativePrompt, attemptSignal);
3411 break;3861 break;
3412 case sources.zai:3862 case sources.zai:
3413 result = await generateZaiImage(prefixedPrompt, signal);3863 result = await generateZaiImage(prefixedPrompt, attemptSignal);
3414 break;3864 break;
3415 case sources.openrouter:3865 case sources.openrouter:
3416 result = await generateOpenRouterImage(prefixedPrompt, signal);3866 result = await generateOpenRouterImage(prefixedPrompt, attemptSignal);
3417 break;3867 break;
3418 case sources.workersai:3868 case sources.workersai:
3419 result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signal);3869 result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, attemptSignal);
3420 break;3870 break;
3421 }3871 }
34223872
3423 if (!result.data) {3873 if (!result.data) {
3424 throw new Error('Endpoint did not return image data.');3874 throw new Error('Endpoint did not return image data.');
3425 }3875 }
3876
3877 return { result, prefixedPrompt };
3878 }
3879
3880 const currentChatId = getCurrentChatId();
3881 let genOutput;
3882 try {
3883 genOutput = await attemptImageGeneration(signal);
3426 } catch (err) {3884 } catch (err) {
3427 // Check if this was an intentional abort by user3885 // Check if this was an intentional abort by user
3428 if (signal?.aborted) {3886 if (signal?.aborted) {
@@ -3431,11 +3889,35 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
3431 return;3889 return;
3432 }3890 }
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 }
3437 }3917 }
34383918
3919 const { result, prefixedPrompt } = genOutput;
3920
3439 if (currentChatId !== getCurrentChatId()) {3921 if (currentChatId !== getCurrentChatId()) {
3440 console.warn('Chat changed, aborting SD result saving');3922 console.warn('Chat changed, aborting SD result saving');
3441 toastr.warning('Chat changed, generated image discarded.', 'Image Generation');3923 toastr.warning('Chat changed, generated image discarded.', 'Image Generation');
@@ -5051,7 +5533,10 @@ async function addSDGenButtons() {
5051 }5533 }
5052 });5534 });
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 () {
5055 dropdown.fadeOut(animation_duration);5540 dropdown.fadeOut(animation_duration);
5056 const id = $(this).attr('id');5541 const id = $(this).attr('id');
5057 const idParamMap = {5542 const idParamMap = {
@@ -5069,10 +5554,39 @@ async function addSDGenButtons() {
5069 if (param) {5554 if (param) {
5070 console.log('doing /sd ' + param);5555 console.log('doing /sd ' + param);
5071 generatePicture(initiators.wand, {}, param);5556 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);
5072 }5564 }
5073 });5565 });
5074}5566}
50755567
5568/**
5569 * Renders the user-defined custom entries into the wand dropdown.
5570 * Removes any previously rendered custom entries first.
5571 */
5572function 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
5076function isValidState() {5590function isValidState() {
5077 switch (extension_settings.sd.source) {5591 switch (extension_settings.sd.source) {
5078 case sources.extras:5592 case sources.extras:
@@ -5859,6 +6373,21 @@ export async function init() {
5859 $('#sd_save_style').on('click', onSaveStyleClick);6373 $('#sd_save_style').on('click', onSaveStyleClick);
5860 $('#sd_rename_style').on('click', onRenameStyleClick);6374 $('#sd_rename_style').on('click', onRenameStyleClick);
5861 $('#sd_delete_style').on('click', onDeleteStyleClick);6375 $('#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 });
5862 $('#sd_character_prompt_block').hide();6391 $('#sd_character_prompt_block').hide();
5863 $('#sd_interactive_mode').on('input', onInteractiveModeInput);6392 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
5864 $('#sd_openai_style').on('change', onOpenAiStyleSelect);6393 $('#sd_openai_style').on('change', onOpenAiStyleSelect);
public/scripts/extensions/stable-diffusion/settings.html+34 -0
@@ -39,6 +39,24 @@
39 <input id="sd_minimal_prompt_processing" type="checkbox" />39 <input id="sd_minimal_prompt_processing" type="checkbox" />
40 <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>40 <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>
41 </label>41 </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>
42 <label for="sd_source" data-i18n="Source">Source</label>60 <label for="sd_source" data-i18n="Source">Source</label>
43 <select id="sd_source" class="text_pole">61 <select id="sd_source" class="text_pole">
44 <option value="aimlapi">AI/ML API</option>62 <option value="aimlapi">AI/ML API</option>
@@ -670,4 +688,20 @@
670 <div id="sd_prompt_templates" class="inline-drawer-content">688 <div id="sd_prompt_templates" class="inline-drawer-content">
671 </div>689 </div>
672 </div>690 </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>
673</div>707</div>
public/scripts/extensions/stable-diffusion/style.css+14 -0
@@ -87,3 +87,17 @@
87 top: 0;87 top: 0;
88 transform: translateX(-50%);88 transform: translateX(-50%);
89}89}
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}