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
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { DOMPurify, Fuse, Popper } from '../../../lib.js'; |
| 2 | 2 | |
| 3 | 3 | import { activateSendButtons, animation_duration, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js'; |
| 4 | 4 | import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 5 | 5 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js'; |
| 6 | 6 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| @@ -695,6 +695,138 @@ async function generateStreamCallback(args, value) { | ||
| 695 | 695 | } |
| 696 | 696 | } |
| 697 | 697 | |
| 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 | + | |
| 698 | 830 | export async function init() { |
| 699 | 831 | extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS); |
| 700 | 832 | |
| @@ -713,6 +845,8 @@ export async function init() { | ||
| 713 | 845 | const profiles = document.getElementById('connection_profiles'); |
| 714 | 846 | renderConnectionProfiles(profiles); |
| 715 | 847 | |
| 848 | + addQuickSwitchButton(); | |
| 849 | + | |
| 716 | 850 | function toggleProfileSpecificButtons() { |
| 717 | 851 | const profileId = extension_settings.connectionManager.selectedProfile; |
| 718 | 852 | const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile']; |
| @@ -9,3 +9,29 @@ | ||
| 9 | 9 | #connection_profile_spinner { |
| 10 | 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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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> | |
| @@ -10,6 +10,7 @@ import { | ||
| 10 | 10 | extension_prompt_types, |
| 11 | 11 | generateQuietPrompt, |
| 12 | 12 | is_send_press, |
| 13 | + online_status, | |
| 13 | 14 | saveSettingsDebounced, |
| 14 | 15 | substituteParamsExtended, |
| 15 | 16 | generateRaw, |
| @@ -27,7 +28,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; | ||
| 27 | 28 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| 28 | 29 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 29 | 30 | import { macros, MacroCategory } from '../../macros/macro-system.js'; |
| 30 | 31 | import { ConnectionManagerRequestService, countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js'; |
| 31 | 32 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 32 | 33 | import { removeReasoningFromString } from '../../reasoning.js'; |
| 33 | 34 | import { MacrosParser } from '/scripts/macros.js'; |
| @@ -136,8 +137,49 @@ const defaultSettings = { | ||
| 136 | 137 | maxMessagesPerRequestMax: 250, |
| 137 | 138 | maxMessagesPerRequestStep: 1, |
| 138 | 139 | prompt_builder: prompt_builders.DEFAULT, |
| 140 | + summaryPromptRole: extension_prompt_roles.SYSTEM, | |
| 141 | + summaryConnectionProfile: '', | |
| 139 | 142 | }; |
| 140 | 143 | |
| 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 | + | |
| 141 | 183 | function loadSettings() { |
| 142 | 184 | if (Object.keys(extension_settings.memory).length === 0) { |
| 143 | 185 | Object.assign(extension_settings.memory, defaultSettings); |
| @@ -158,6 +200,8 @@ function loadSettings() { | ||
| 158 | 200 | $('#memory_template').val(extension_settings.memory.template).trigger('input'); |
| 159 | 201 | $('#memory_depth').val(extension_settings.memory.depth).trigger('input'); |
| 160 | 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 | 205 | $(`input[name="memory_position"][value="${extension_settings.memory.position}"]`).prop('checked', true).trigger('input'); |
| 162 | 206 | $('#memory_prompt_words_force').val(extension_settings.memory.promptForceWords).trigger('input'); |
| 163 | 207 | $(`input[name="memory_prompt_builder"][value="${extension_settings.memory.prompt_builder}"]`).prop('checked', true).trigger('input'); |
| @@ -314,6 +358,12 @@ function onMemoryRoleInput() { | ||
| 314 | 358 | saveSettingsDebounced(); |
| 315 | 359 | } |
| 316 | 360 | |
| 361 | +function onMemorySummaryPromptRoleInput() { | |
| 362 | + const value = $(this).val(); | |
| 363 | + extension_settings.memory.summaryPromptRole = Number(value); | |
| 364 | + saveSettingsDebounced(); | |
| 365 | +} | |
| 366 | + | |
| 317 | 367 | function onMemoryPositionChange(e) { |
| 318 | 368 | const value = e.target.value; |
| 319 | 369 | extension_settings.memory.position = value; |
| @@ -515,15 +565,22 @@ async function summarizeCallback(args, text) { | ||
| 515 | 565 | |
| 516 | 566 | const source = args.source || extension_settings.memory.source; |
| 517 | 567 | const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords }); |
| 568 | + const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER; | |
| 518 | 569 | |
| 519 | 570 | try { |
| 520 | 571 | switch (source) { |
| 521 | 572 | case summary_sources.extras: |
| 522 | 573 | return await callExtrasSummarizeAPI(text); |
| 523 | 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 | 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 | 584 | const params = extension_settings.memory.overrideResponseLength > 0 ? { max_tokens: extension_settings.memory.overrideResponseLength } : {}; |
| 528 | 585 | return await generateWebLlmChatPrompt(messages, params); |
| 529 | 586 | } |
| @@ -646,8 +703,9 @@ async function summarizeChatWebLLM(context, force) { | ||
| 646 | 703 | return null; |
| 647 | 704 | } |
| 648 | 705 | |
| 706 | + const promptRole = getSummaryPromptRole() === extension_prompt_roles.USER ? 'user' : 'system'; | |
| 649 | 707 | const messages = [ |
| 650 | 708 | { role: 'system'promptRole, content: prompt }, |
| 651 | 709 | { role: 'user', content: rawPrompt }, |
| 652 | 710 | ]; |
| 653 | 711 | |
| @@ -678,6 +736,73 @@ async function summarizeChatWebLLM(context, force) { | ||
| 678 | 736 | } |
| 679 | 737 | } |
| 680 | 738 | |
| 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 | + | |
| 681 | 806 | async function summarizeChatMain(context, force, skipWIAN) { |
| 682 | 807 | const prompt = await getSummaryPromptForNow(context, force); |
| 683 | 808 | |
| @@ -686,12 +811,30 @@ async function summarizeChatMain(context, force, skipWIAN) { | ||
| 686 | 811 | } |
| 687 | 812 | |
| 688 | 813 | console.log('sending summary prompt'); |
| 814 | + | |
| 815 | + // Runs the configured summary builder (DEFAULT or RAW) against whatever connection is | |
| 816 | + // currently active. Returns { summary, index }, or null when there is nothing to summarize. | |
| 817 | + const runConfiguredSummary = async () => { | |
| 689 | 818 | let summary = ''; |
| 690 | 819 | let index = null; |
| 691 | 820 | |
| 692 | 821 | if (prompt_builders.DEFAULT === extension_settings.memory.prompt_builder) { |
| 693 | - try { | |
| 822 | + // generateQuietPrompt always injects the instruction as a SYSTEM message | |
| 694 | - inApiCall = true; | |
| 823 | + // (the QUIET_PROMPT extension prompt has no exposed role parameter). | |
| 824 | + // When a USER role is requested, assemble the chat transcript ourselves | |
| 825 | + // (same source as the raw builder) and deliver the instruction as USER | |
| 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 { | |
| 695 | 838 | /** @type {import('../../../script.js').GenerateQuietPromptParams} */ |
| 696 | 839 | const params = { |
| 697 | 840 | quietPrompt: prompt, |
| @@ -699,15 +842,12 @@ async function summarizeChatMain(context, force, skipWIAN) { | ||
| 699 | 842 | responseLength: extension_settings.memory.overrideResponseLength, |
| 700 | 843 | }; |
| 701 | 844 | summary = await generateQuietPrompt(params); |
| 702 | - } finally { | |
| 703 | - inApiCall = false; | |
| 704 | 845 | } |
| 705 | 846 | } |
| 706 | 847 | |
| 707 | 848 | if ([prompt_builders.RAW_BLOCKING, prompt_builders.RAW_NON_BLOCKING].includes(extension_settings.memory.prompt_builder)) { |
| 708 | 849 | const lock = extension_settings.memory.prompt_builder === prompt_builders.RAW_BLOCKING; |
| 709 | 850 | try { |
| 710 | - inApiCall = true; | |
| 711 | 851 | if (lock) { |
| 712 | 852 | deactivateSendButtons(); |
| 713 | 853 | } |
| @@ -722,23 +862,50 @@ async function summarizeChatMain(context, force, skipWIAN) { | ||
| 722 | 862 | return null; |
| 723 | 863 | } |
| 724 | 864 | |
| 865 | + // When the summary instruction should be a USER message, deliver it as | |
| 866 | + // user content and leave the system prompt empty. Otherwise keep the | |
| 867 | + // existing behavior of sending it as the system prompt. | |
| 868 | + const useUserRole = getSummaryPromptRole() === extension_prompt_roles.USER; | |
| 725 | 869 | /** @type {import('../../../script.js').GenerateRawParams} */ |
| 726 | 870 | const params = { |
| 727 | - prompt: rawPrompt, | |
| 871 | + prompt: useUserRole ? [prompt, rawPrompt].filter(x => x).join('\n\n') : rawPrompt, | |
| 728 | - systemPrompt: prompt, | |
| 872 | + systemPrompt: useUserRole ? '' : prompt, | |
| 729 | 873 | responseLength: extension_settings.memory.overrideResponseLength, |
| 730 | 874 | }; |
| 731 | 875 | const rawSummary = await generateRaw(params); |
| 732 | 876 | summary = removeReasoningFromString(rawSummary); |
| 733 | 877 | index = lastUsedIndex; |
| 734 | 878 | } finally { |
| 735 | - inApiCall = false; | |
| 736 | 879 | if (lock) { |
| 737 | 880 | activateSendButtons(); |
| 738 | 881 | } |
| 739 | 882 | } |
| 740 | 883 | } |
| 741 | 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; | |
| 901 | + } | |
| 902 | + | |
| 903 | + if (result === null) { | |
| 904 | + return null; | |
| 905 | + } | |
| 906 | + | |
| 907 | + const { summary, index } = result; | |
| 908 | + | |
| 742 | 909 | if (!summary) { |
| 743 | 910 | console.warn('Empty summary received'); |
| 744 | 911 | return; |
| @@ -1047,6 +1214,7 @@ function setupListeners() { | ||
| 1047 | 1214 | $('#memory_template').off('input').on('input', onMemoryTemplateInput); |
| 1048 | 1215 | $('#memory_depth').off('input').on('input', onMemoryDepthInput); |
| 1049 | 1216 | $('#memory_role').off('input').on('input', onMemoryRoleInput); |
| 1217 | + $('#memory_summary_prompt_role').off('input').on('input', onMemorySummaryPromptRoleInput); | |
| 1050 | 1218 | $('input[name="memory_position"]').off('change').on('change', onMemoryPositionChange); |
| 1051 | 1219 | $('#memory_prompt_words_force').off('input').on('input', onMemoryPromptWordsForceInput); |
| 1052 | 1220 | $('#memory_prompt_builder_default').off('input').on('input', onMemoryPromptBuilderInput); |
| @@ -69,6 +69,15 @@ | ||
| 69 | 69 | </div> |
| 70 | 70 | </label> |
| 71 | 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. {{words}} 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 | 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 | 82 | <input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" /> |
| 74 | 83 | <label for="memory_override_response_length"> |
| @@ -10,6 +10,7 @@ import { | ||
| 10 | 10 | getCurrentChatId, |
| 11 | 11 | getRequestHeaders, |
| 12 | 12 | getUserAvatar, |
| 13 | + online_status, | |
| 13 | 14 | saveSettingsDebounced, |
| 14 | 15 | substituteParams, |
| 15 | 16 | substituteParamsExtended, |
| @@ -40,11 +41,12 @@ import { | ||
| 40 | 41 | resetScrollHeight, |
| 41 | 42 | saveBase64AsFile, |
| 42 | 43 | stringFormat, |
| 44 | + waitUntilCondition, | |
| 43 | 45 | } from '../../utils.js'; |
| 44 | 46 | import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js'; |
| 45 | 47 | import { SECRET_KEYS, secret_state } from '../../secrets.js'; |
| 46 | 48 | import { getNovelAnlas, getNovelUnlimitedImageGeneration, loadNovelSubscriptionData } from '../../nai-settings.js'; |
| 47 | 49 | import { ConnectionManagerRequestService, getMultimodalCaption } from '../shared.js'; |
| 48 | 50 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| 49 | 51 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| 50 | 52 | import { |
| @@ -125,6 +127,7 @@ const generationMode = { | ||
| 125 | 127 | USER_MULTIMODAL: 9, |
| 126 | 128 | FACE_MULTIMODAL: 10, |
| 127 | 129 | FREE_EXTENDED: 11, |
| 130 | + CUSTOM: 12, | |
| 128 | 131 | }; |
| 129 | 132 | |
| 130 | 133 | const multimodalMap = { |
| @@ -147,6 +150,7 @@ const modeLabels = { | ||
| 147 | 150 | [generationMode.FACE_MULTIMODAL]: 'Portrait (Multimodal Mode)', |
| 148 | 151 | [generationMode.USER_MULTIMODAL]: 'User (Multimodal Mode)', |
| 149 | 152 | [generationMode.FREE_EXTENDED]: 'Free Mode (LLM-Extended)', |
| 153 | + [generationMode.CUSTOM]: 'Custom', | |
| 150 | 154 | }; |
| 151 | 155 | |
| 152 | 156 | const triggerWords = { |
| @@ -360,8 +364,97 @@ const defaultSettings = { | ||
| 360 | 364 | google_api: 'makersuite', |
| 361 | 365 | google_enhance: true, |
| 362 | 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 | }; |
| 364 | 379 | |
| 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 | + | |
| 365 | 458 | const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed); |
| 366 | 459 | const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase()); |
| 367 | 460 | |
| @@ -459,6 +552,37 @@ function toggleSourceControls() { | ||
| 459 | 552 | }); |
| 460 | 553 | } |
| 461 | 554 | |
| 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 | + | |
| 462 | 586 | async function loadSettings() { |
| 463 | 587 | // Initialize settings |
| 464 | 588 | if (Object.keys(extension_settings.sd).length === 0) { |
| @@ -495,6 +619,23 @@ async function loadSettings() { | ||
| 495 | 619 | extension_settings.sd.styles = defaultStyles; |
| 496 | 620 | } |
| 497 | 621 | |
| 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 | 639 | // Preserve an original seed if exists |
| 499 | 640 | if (extension_settings.sd.original_seed >= 0) { |
| 500 | 641 | extension_settings.sd.seed = extension_settings.sd.original_seed; |
| @@ -560,6 +701,7 @@ async function loadSettings() { | ||
| 560 | 701 | $('#sd_google_api').val(extension_settings.sd.google_api); |
| 561 | 702 | $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance); |
| 562 | 703 | $('#sd_google_duration').val(extension_settings.sd.google_duration); |
| 704 | + $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled); | |
| 563 | 705 | |
| 564 | 706 | for (const style of extension_settings.sd.styles) { |
| 565 | 707 | const option = document.createElement('option'); |
| @@ -572,8 +714,12 @@ async function loadSettings() { | ||
| 572 | 714 | const resolutionId = getClosestKnownResolution(); |
| 573 | 715 | $('#sd_resolution').val(resolutionId); |
| 574 | 716 | |
| 717 | + initPromptGenerationProfileDropdown(); | |
| 718 | + | |
| 575 | 719 | toggleSourceControls(); |
| 576 | 720 | addPromptTemplates(); |
| 721 | + renderCustomEntriesList(); | |
| 722 | + renderCustomDropdownEntries(); | |
| 577 | 723 | registerFunctionTool(); |
| 578 | 724 | |
| 579 | 725 | await loadSettingOptions(); |
| @@ -803,6 +949,217 @@ async function onRenameStyleClick() { | ||
| 803 | 949 | saveSettingsDebounced(); |
| 804 | 950 | } |
| 805 | 951 | |
| 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 | + | |
| 806 | 1163 | /** |
| 807 | 1164 | * Modifies prompt based on user inputs. |
| 808 | 1165 | * @param {string} prompt Prompt to refine |
| @@ -2858,6 +3215,16 @@ async function loadComfyWorkflows() { | ||
| 2858 | 3215 | } |
| 2859 | 3216 | |
| 2860 | 3217 | function 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 | 3228 | let mode = generationMode.FREE; |
| 2862 | 3229 | |
| 2863 | 3230 | for (const [key, values] of Object.entries(triggerWords)) { |
| @@ -2881,6 +3248,13 @@ function getGenerationType(prompt) { | ||
| 2881 | 3248 | } |
| 2882 | 3249 | |
| 2883 | 3250 | function 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 | 3258 | if (mode === generationMode.FREE) { |
| 2885 | 3259 | return trigger; |
| 2886 | 3260 | } |
| @@ -3291,9 +3665,18 @@ function getUserAvatarUrl() { | ||
| 3291 | 3665 | */ |
| 3292 | 3666 | async function generatePrompt(quietPrompt) { |
| 3293 | 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; | |
| 3295 | - const processedReply = processReply(reply); | |
| 3669 | + let reply; | |
| 3670 | + | |
| 3671 | + try { | |
| 3672 | + reply = profileId | |
| 3673 | + ? await withConnectionProfile(profileId, () => generateQuietPrompt({ quietPrompt })) | |
| 3674 | + : await generateQuietPrompt({ quietPrompt }); | |
| 3675 | + } finally { | |
| 3296 | 3676 | toastr.clear(toast); |
| 3677 | + } | |
| 3678 | + | |
| 3679 | + const processedReply = processReply(reply); | |
| 3297 | 3680 | |
| 3298 | 3681 | if (!processedReply) { |
| 3299 | 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 | } |
| 3305 | 3688 | |
| 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 | + */ | |
| 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 | +/** | |
| 3307 | 3752 | * Sends a request to image generation endpoint and processes the result. |
| 3308 | 3753 | * @param {number} generationType Type of image generation |
| 3309 | 3754 | * @param {string} prompt Prompt to be used for image generation |
| @@ -3321,6 +3766,14 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3321 | 3766 | |
| 3322 | 3767 | const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType); |
| 3323 | 3768 | |
| 3769 | + /** | |
| 3770 | + * Performs a single image generation attempt against the live extension_settings.sd config. | |
| 3771 | + * Reads settings live so it can be retried after applying a fallback preset. | |
| 3772 | + * @param {AbortSignal} attemptSignal Abort signal to cancel the request. | |
| 3773 | + * @returns {Promise<{result: {format: string, data: string}, prefixedPrompt: string}>} | |
| 3774 | + * @throws {Error} On failure or when the endpoint returns no image data. | |
| 3775 | + */ | |
| 3776 | + async function attemptImageGeneration(attemptSignal) { | |
| 3324 | 3777 | const prefix = skipCharPrefix |
| 3325 | 3778 | ? extension_settings.sd.prompt_prefix |
| 3326 | 3779 | : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix()); |
| @@ -3333,96 +3786,101 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3333 | 3786 | const negativePrompt = substituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix)); |
| 3334 | 3787 | |
| 3335 | 3788 | let result = { format: '', data: '' }; |
| 3336 | - const currentChatId = getCurrentChatId(); | |
| 3337 | - | |
| 3338 | - try { | |
| 3339 | 3789 | switch (extension_settings.sd.source) { |
| 3340 | 3790 | case sources.extras: |
| 3341 | 3791 | result = await generateExtrasImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3342 | 3792 | break; |
| 3343 | 3793 | case sources.horde: |
| 3344 | 3794 | result = await generateHordeImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3345 | 3795 | break; |
| 3346 | 3796 | case sources.vlad: |
| 3347 | 3797 | result = await generateAutoImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3348 | 3798 | break; |
| 3349 | 3799 | case sources.drawthings: |
| 3350 | 3800 | result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3351 | 3801 | break; |
| 3352 | 3802 | case sources.auto: |
| 3353 | 3803 | result = await generateAutoImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3354 | 3804 | break; |
| 3355 | 3805 | case sources.sdcpp: |
| 3356 | 3806 | result = await generateSdcppImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3357 | 3807 | break; |
| 3358 | 3808 | case sources.novel: |
| 3359 | 3809 | result = await generateNovelImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3360 | 3810 | break; |
| 3361 | 3811 | case sources.openai: |
| 3362 | 3812 | result = await generateOpenAiImage(prefixedPrompt, signalattemptSignal); |
| 3363 | 3813 | break; |
| 3364 | 3814 | case sources.aimlapi: |
| 3365 | 3815 | result = await generateAimlapiImage(prefixedPrompt, signalattemptSignal); |
| 3366 | 3816 | break; |
| 3367 | 3817 | case sources.comfy: |
| 3368 | 3818 | switch (extension_settings.sd.comfy_type) { |
| 3369 | 3819 | case comfyTypes.runpod_serverless: |
| 3370 | 3820 | result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3371 | 3821 | break; |
| 3372 | 3822 | case comfyTypes.standard: |
| 3373 | 3823 | result = await generateComfyImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3374 | 3824 | break; |
| 3375 | 3825 | default: |
| 3376 | 3826 | throw new Error('Unknown comfyUI server type.'); |
| 3377 | 3827 | } |
| 3378 | 3828 | break; |
| 3379 | 3829 | case sources.togetherai: |
| 3380 | 3830 | result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3381 | 3831 | break; |
| 3382 | 3832 | case sources.pollinations: |
| 3383 | 3833 | result = await generatePollinationsImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3384 | 3834 | break; |
| 3385 | 3835 | case sources.stability: |
| 3386 | 3836 | result = await generateStabilityImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3387 | 3837 | break; |
| 3388 | 3838 | case sources.huggingface: |
| 3389 | 3839 | result = await generateHuggingFaceImage(prefixedPrompt, signalattemptSignal); |
| 3390 | 3840 | break; |
| 3391 | 3841 | case sources.chutes: |
| 3392 | 3842 | result = await generateChutesImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3393 | 3843 | break; |
| 3394 | 3844 | case sources.electronhub: |
| 3395 | 3845 | result = await generateElectronHubImage(prefixedPrompt, signalattemptSignal); |
| 3396 | 3846 | break; |
| 3397 | 3847 | case sources.nanogpt: |
| 3398 | 3848 | result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3399 | 3849 | break; |
| 3400 | 3850 | case sources.bfl: |
| 3401 | 3851 | result = await generateBflImage(prefixedPrompt, signalattemptSignal); |
| 3402 | 3852 | break; |
| 3403 | 3853 | case sources.falai: |
| 3404 | 3854 | result = await generateFalaiImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3405 | 3855 | break; |
| 3406 | 3856 | case sources.xai: |
| 3407 | 3857 | result = await generateXAIImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3408 | 3858 | break; |
| 3409 | 3859 | case sources.google: |
| 3410 | 3860 | result = await generateGoogleImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3411 | 3861 | break; |
| 3412 | 3862 | case sources.zai: |
| 3413 | 3863 | result = await generateZaiImage(prefixedPrompt, signalattemptSignal); |
| 3414 | 3864 | break; |
| 3415 | 3865 | case sources.openrouter: |
| 3416 | 3866 | result = await generateOpenRouterImage(prefixedPrompt, signalattemptSignal); |
| 3417 | 3867 | break; |
| 3418 | 3868 | case sources.workersai: |
| 3419 | 3869 | result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3420 | 3870 | break; |
| 3421 | 3871 | } |
| 3422 | 3872 | |
| 3423 | 3873 | if (!result.data) { |
| 3424 | 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 | 3884 | } catch (err) { |
| 3427 | 3885 | // Check if this was an intentional abort by user |
| 3428 | 3886 | if (signal?.aborted) { |
| @@ -3431,10 +3889,34 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3431 | 3889 | return; |
| 3432 | 3890 | } |
| 3433 | 3891 | |
| 3892 | + if (extension_settings.sd.settings_fallback_enabled && isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | |
| 3893 | + console.warn('SD: primary generation failed, falling back to secondary preset', err); | |
| 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 { | |
| 3434 | 3913 | console.error('Image generation request error: ', err); |
| 3435 | 3914 | toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); |
| 3436 | 3915 | return; |
| 3437 | 3916 | } |
| 3917 | + } | |
| 3918 | + | |
| 3919 | + const { result, prefixedPrompt } = genOutput; | |
| 3438 | 3920 | |
| 3439 | 3921 | if (currentChatId !== getCurrentChatId()) { |
| 3440 | 3922 | console.warn('Chat changed, aborting SD result saving'); |
| @@ -5051,7 +5533,10 @@ async function addSDGenButtons() { | ||
| 5051 | 5533 | } |
| 5052 | 5534 | }); |
| 5053 | 5535 | |
| 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 | 5540 | dropdown.fadeOut(animation_duration); |
| 5056 | 5541 | const id = $(this).attr('id'); |
| 5057 | 5542 | const idParamMap = { |
| @@ -5069,10 +5554,39 @@ async function addSDGenButtons() { | ||
| 5069 | 5554 | if (param) { |
| 5070 | 5555 | console.log('doing /sd ' + param); |
| 5071 | 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 | } |
| 5075 | 5567 | |
| 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 | + | |
| 5076 | 5590 | function isValidState() { |
| 5077 | 5591 | switch (extension_settings.sd.source) { |
| 5078 | 5592 | case sources.extras: |
| @@ -5859,6 +6373,21 @@ export async function init() { | ||
| 5859 | 6373 | $('#sd_save_style').on('click', onSaveStyleClick); |
| 5860 | 6374 | $('#sd_rename_style').on('click', onRenameStyleClick); |
| 5861 | 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 | 6391 | $('#sd_character_prompt_block').hide(); |
| 5863 | 6392 | $('#sd_interactive_mode').on('input', onInteractiveModeInput); |
| 5864 | 6393 | $('#sd_openai_style').on('change', onOpenAiStyleSelect); |
| @@ -39,6 +39,24 @@ | ||
| 39 | 39 | <input id="sd_minimal_prompt_processing" type="checkbox" /> |
| 40 | 40 | <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span> |
| 41 | 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 & 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 | 60 | <label for="sd_source" data-i18n="Source">Source</label> |
| 43 | 61 | <select id="sd_source" class="text_pole"> |
| 44 | 62 | <option value="aimlapi">AI/ML API</option> |
| @@ -670,4 +688,20 @@ | ||
| 670 | 688 | <div id="sd_prompt_templates" class="inline-drawer-content"> |
| 671 | 689 | </div> |
| 672 | 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 | 707 | </div> |
| @@ -87,3 +87,17 @@ | ||
| 87 | 87 | top: 0; |
| 88 | 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 | +} | |