Cache Keepalive: per-profile idle tracking + per-profile enable; ping only the active profile
| @@ -20,14 +20,53 @@ const MODULE = 'keepalive'; | ||
| 20 | 20 | // cache), but we never pay for a full completion. |
| 21 | 21 | const KEEPALIVE_RESPONSE_LENGTH = 1; |
| 22 | 22 | |
| 23 | +// Idle-tracking key used when no connection profile is active (manual API panel). | |
| 24 | +const NONE_KEY = '__none__'; | |
| 25 | + | |
| 26 | +// When the active profile is already past its timeout (e.g. you just switched back | |
| 27 | +// to a profile that went cold), wait this short grace before pinging so an imminent | |
| 28 | +// user message can pre-empt the keepalive. | |
| 29 | +const EXPIRED_GRACE_MS = 3000; | |
| 30 | + | |
| 23 | 31 | const defaultSettings = { |
| 24 | 32 | enabled: false, |
| 25 | 33 | timeoutSeconds: 295, |
| 26 | 34 | message: 'Keep the cache warm.', |
| 35 | + // Per-profile enable map: { [profileId]: boolean }. Unlisted profiles default to enabled. | |
| 36 | + profiles: {}, | |
| 27 | 37 | }; |
| 28 | 38 | |
| 29 | 39 | let idleTimer = null; |
| 30 | 40 | let keepaliveActive = false; |
| 41 | +// Per-profile "time since last message", keyed by connection profile id (or NONE_KEY). | |
| 42 | +// In-memory only: on reload every profile is treated as freshly active. | |
| 43 | +const lastActivity = {}; | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * @returns {string} The active connection profile id, or NONE_KEY for a manual connection. | |
| 47 | + */ | |
| 48 | +function getActiveProfileKey() { | |
| 49 | + return extension_settings.connectionManager?.selectedProfile || NONE_KEY; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * Whether keepalive should run for the CURRENTLY ACTIVE connection. Requires the global | |
| 54 | + * master switch; for a real profile it also requires that profile's per-profile toggle | |
| 55 | + * (default on). A manual connection (no profile selected) is governed by the master switch alone. | |
| 56 | + * @returns {boolean} | |
| 57 | + */ | |
| 58 | +function isKeepaliveEnabledForActive() { | |
| 59 | + const s = extension_settings[MODULE]; | |
| 60 | + if (!s.enabled) { | |
| 61 | + return false; | |
| 62 | + } | |
| 63 | + const profileId = extension_settings.connectionManager?.selectedProfile; | |
| 64 | + if (!profileId) { | |
| 65 | + return true; | |
| 66 | + } | |
| 67 | + const perProfile = s.profiles?.[profileId]; | |
| 68 | + return perProfile === undefined ? true : !!perProfile; | |
| 69 | +} | |
| 31 | 70 | |
| 32 | 71 | /** |
| 33 | 72 | * Clears the pending idle timer, if any. |
| @@ -40,16 +79,28 @@ function clearIdleTimer() { | ||
| 40 | 79 | } |
| 41 | 80 | |
| 42 | 81 | /** |
| 43 | 82 | * Restarts(Re)schedules the idlekeepalive timerfor the ACTIVE profile based on thehow currentlong settings.it has been since |
| 44 | 83 | * that profile's last message. Does nothing (and leaves the timer cleared) when the featurekeepalive is disabled. |
| 84 | + * disabled for the active connection. Called on activity AND whenever the active profile | |
| 85 | + * changes, so each profile keeps its own independent idle clock. | |
| 45 | 86 | */ |
| 46 | 87 | function resetIdleTimerscheduleIdleTimer() { |
| 47 | 88 | clearIdleTimer(); |
| 48 | - const s = extension_settings[MODULE]; | |
| 89 | + if (!isKeepaliveEnabledForActive()) { | |
| 49 | - if (!s.enabled) { | |
| 50 | 90 | return; |
| 51 | 91 | } |
| 52 | 92 | idleTimerconst =timeoutMs setTimeout(fireKeepalive,= Math.max(60, Number(sextension_settings[MODULE].timeoutSeconds) || 295) * 1000); |
| 93 | + const last = lastActivity[getActiveProfileKey()] ?? Date.now(); | |
| 94 | + const remaining = timeoutMs - (Date.now() - last); | |
| 95 | + idleTimer = setTimeout(fireKeepalive, remaining > 0 ? remaining : EXPIRED_GRACE_MS); | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * Marks the active profile as just-used (resets its idle clock) and reschedules its keepalive. | |
| 100 | + */ | |
| 101 | +function markActivity() { | |
| 102 | + lastActivity[getActiveProfileKey()] = Date.now(); | |
| 103 | + scheduleIdleTimer(); | |
| 53 | 104 | } |
| 54 | 105 | |
| 55 | 106 | /** |
| @@ -59,30 +110,27 @@ function resetIdleTimer() { | ||
| 59 | 110 | * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never |
| 60 | 111 | * pay for a real response. |
| 61 | 112 | * |
| 62 | - * Quiet generations are forced non-streaming by the core (Generate excludes | |
| 113 | + * Only the CURRENTLY ACTIVE profile is ever pinged — keepalive never switches the | |
| 63 | 114 | * type === 'quiet'connection fromin the StreamingProcessor)background. andEach areprofile's neveridle savedclock tois chattracked separately, so |
| 64 | - * the output is simply discarded. We deliberately do NOT hook the global | |
| 115 | + * switching profiles re-targets the timer at whichever profile is now active. | |
| 65 | - * STREAM_TOKEN_RECEIVED / stopGeneration() path: quiet requests never emit it, | |
| 66 | - * and using it would risk aborting a concurrent user generation. | |
| 67 | 116 | * |
| 68 | - * generateQuietPrompt's responseLength applies a *global* temporary | |
| 117 | + * Quiet generations are forced non-streaming by the core (Generate excludes | |
| 69 | - * response-length override for the duration of the request. To make sure a user | |
| 118 | + * type === 'quiet' from the StreamingProcessor) and are never saved to chat, so the | |
| 70 | - * generation can never start (and inherit that override) while the keepalive is | |
| 119 | + * output is simply discarded. generateQuietPrompt's responseLength applies a *global* | |
| 71 | 120 | * intemporary flight,response-length weoverride holdfor the same generationduration lockof the blockingrequest, summaryso pathwe uses:hold the |
| 72 | - * setSendButtonState(true) flips `is_send_press`, which every user send entry | |
| 121 | + * same generation lock the blocking summary path uses — setSendButtonState(true) flips | |
| 73 | 122 | * `is_send_press` (which every user send entry point checks and bails on, and deactivateSendButtons() reflects the busyand |
| 74 | 123 | * statedeactivateSendButtons() inreflects the UI.busy Bothstate are— releasedand release both in `finally`. |
| 75 | 124 | */ |
| 76 | 125 | async function fireKeepalive() { |
| 77 | - const s = extension_settings[MODULE]; | |
| 126 | + if (!isKeepaliveEnabledForActive()) { | |
| 78 | - if (!s.enabled) { | |
| 79 | 127 | return; |
| 80 | 128 | } |
| 81 | 129 | |
| 82 | 130 | // Never start while any generation is in progress, or while a previous |
| 83 | 131 | // keepalive is still running. Reschedule and bail. |
| 84 | 132 | if (keepaliveActive || isGenerating() || streamingProcessor) { |
| 85 | 133 | resetIdleTimerscheduleIdleTimer(); |
| 86 | 134 | return; |
| 87 | 135 | } |
| 88 | 136 | |
| @@ -91,7 +139,10 @@ async function fireKeepalive() { | ||
| 91 | 139 | deactivateSendButtons(); |
| 92 | 140 | |
| 93 | 141 | try { |
| 94 | 142 | await generateQuietPrompt({ quietPrompt: sextension_settings[MODULE].message, responseLength: KEEPALIVE_RESPONSE_LENGTH }); |
| 143 | + // The ping kept this profile warm; treat it as fresh activity so the next | |
| 144 | + // keepalive for it is a full timeout away. | |
| 145 | + lastActivity[getActiveProfileKey()] = Date.now(); | |
| 95 | 146 | } catch (error) { |
| 96 | 147 | // Best-effort: any failure here is harmless and intentionally swallowed. |
| 97 | 148 | console.debug('[Keepalive] Background generation ended:', error); |
| @@ -99,7 +150,43 @@ async function fireKeepalive() { | ||
| 99 | 150 | setSendButtonState(false); |
| 100 | 151 | activateSendButtons(); |
| 101 | 152 | keepaliveActive = false; |
| 102 | 153 | resetIdleTimerscheduleIdleTimer(); |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** | |
| 158 | + * Renders the per-profile enable checkboxes from the current Connection Manager profiles. | |
| 159 | + */ | |
| 160 | +function renderProfileToggles() { | |
| 161 | + const container = $('#keepalive_profiles_list'); | |
| 162 | + if (!container.length) { | |
| 163 | + return; | |
| 164 | + } | |
| 165 | + container.empty(); | |
| 166 | + | |
| 167 | + const profiles = extension_settings.connectionManager?.profiles; | |
| 168 | + if (!Array.isArray(profiles) || profiles.length === 0) { | |
| 169 | + container.append( | |
| 170 | + $('<small>') | |
| 171 | + .attr('data-i18n', 'ext_keepalive_no_profiles') | |
| 172 | + .text('No connection profiles found. Keepalive uses the active model, governed by the master toggle above.'), | |
| 173 | + ); | |
| 174 | + return; | |
| 175 | + } | |
| 176 | + | |
| 177 | + const perProfile = extension_settings[MODULE].profiles || {}; | |
| 178 | + const sorted = profiles.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))); | |
| 179 | + for (const profile of sorted) { | |
| 180 | + const enabled = perProfile[profile.id] === undefined ? true : !!perProfile[profile.id]; | |
| 181 | + const label = $('<label>').addClass('checkbox_label'); | |
| 182 | + const input = $('<input>') | |
| 183 | + .attr('type', 'checkbox') | |
| 184 | + .addClass('keepalive_profile_toggle') | |
| 185 | + .attr('data-profile-id', profile.id) | |
| 186 | + .prop('checked', enabled); | |
| 187 | + const span = $('<span>').text(profile.name); | |
| 188 | + label.append(input, span); | |
| 189 | + container.append(label); | |
| 103 | 190 | } |
| 104 | 191 | } |
| 105 | 192 | |
| @@ -111,19 +198,21 @@ function loadSettings() { | ||
| 111 | 198 | extension_settings[MODULE] = {}; |
| 112 | 199 | } |
| 113 | 200 | |
| 114 | - if (Object.keys(extension_settings[MODULE]).length === 0) { | |
| 115 | - Object.assign(extension_settings[MODULE], defaultSettings); | |
| 116 | - } | |
| 117 | - | |
| 118 | 201 | for (const key of Object.keys(defaultSettings)) { |
| 119 | 202 | if (extension_settings[MODULE][key] === undefined) { |
| 120 | 203 | extension_settings[MODULE][key] = structuredClone(defaultSettings[key]); |
| 121 | 204 | } |
| 122 | 205 | } |
| 123 | 206 | |
| 207 | + // Guard against a corrupted/legacy value for the per-profile map. | |
| 208 | + if (typeof extension_settings[MODULE].profiles !== 'object' || extension_settings[MODULE].profiles === null) { | |
| 209 | + extension_settings[MODULE].profiles = {}; | |
| 210 | + } | |
| 211 | + | |
| 124 | 212 | $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled); |
| 125 | 213 | $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds); |
| 126 | 214 | $('#keepalive_message').val(extension_settings[MODULE].message); |
| 215 | + renderProfileToggles(); | |
| 127 | 216 | } |
| 128 | 217 | |
| 129 | 218 | /** |
| @@ -133,19 +222,33 @@ function setupListeners() { | ||
| 133 | 222 | $('#keepalive_enabled').on('change', function () { |
| 134 | 223 | extension_settings[MODULE].enabled = !!$(this).prop('checked'); |
| 135 | 224 | saveSettingsDebounced(); |
| 136 | 225 | resetIdleTimerscheduleIdleTimer(); |
| 137 | 226 | }); |
| 138 | 227 | |
| 139 | 228 | $('#keepalive_timeout').on('input', function () { |
| 140 | 229 | extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295; |
| 141 | 230 | saveSettingsDebounced(); |
| 142 | 231 | resetIdleTimerscheduleIdleTimer(); |
| 143 | 232 | }); |
| 144 | 233 | |
| 145 | 234 | $('#keepalive_message').on('input', function () { |
| 146 | 235 | extension_settings[MODULE].message = String($(this).val()); |
| 147 | 236 | saveSettingsDebounced(); |
| 148 | 237 | }); |
| 238 | + | |
| 239 | + // Per-profile toggles are rendered dynamically; use a delegated handler. | |
| 240 | + $('#keepalive_profiles_list').on('change', '.keepalive_profile_toggle', function () { | |
| 241 | + const profileId = $(this).attr('data-profile-id'); | |
| 242 | + if (!profileId) { | |
| 243 | + return; | |
| 244 | + } | |
| 245 | + extension_settings[MODULE].profiles[profileId] = !!$(this).prop('checked'); | |
| 246 | + saveSettingsDebounced(); | |
| 247 | + // If the toggled profile is the one currently active, reschedule immediately. | |
| 248 | + if (profileId === extension_settings.connectionManager?.selectedProfile) { | |
| 249 | + scheduleIdleTimer(); | |
| 250 | + } | |
| 251 | + }); | |
| 149 | 252 | } |
| 150 | 253 | |
| 151 | 254 | async function init() { |
| @@ -155,7 +258,7 @@ async function init() { | ||
| 155 | 258 | loadSettings(); |
| 156 | 259 | setupListeners(); |
| 157 | 260 | |
| 158 | 261 | // ResetAny thechat idleor timergeneration onactivity anycounts chatas or"using" generationthe activityactive profile. |
| 159 | 262 | const activityEvents = [ |
| 160 | 263 | event_types.MESSAGE_SENT, |
| 161 | 264 | event_types.MESSAGE_RECEIVED, |
| @@ -164,8 +267,17 @@ async function init() { | ||
| 164 | 267 | event_types.CHAT_CHANGED, |
| 165 | 268 | ]; |
| 166 | 269 | for (const event of activityEvents) { |
| 167 | 270 | eventSource.on(event, resetIdleTimermarkActivity); |
| 168 | 271 | } |
| 169 | 272 | |
| 170 | - resetIdleTimer(); | |
| 273 | + // When the active profile changes, re-target the idle timer at the new profile using | |
| 274 | + // ITS own last-activity time (do NOT mark activity — switching is not using it). | |
| 275 | + eventSource.on(event_types.CONNECTION_PROFILE_LOADED, scheduleIdleTimer); | |
| 276 | + | |
| 277 | + // Keep the per-profile toggle list in sync with Connection Manager. | |
| 278 | + eventSource.on(event_types.CONNECTION_PROFILE_CREATED, renderProfileToggles); | |
| 279 | + eventSource.on(event_types.CONNECTION_PROFILE_UPDATED, renderProfileToggles); | |
| 280 | + eventSource.on(event_types.CONNECTION_PROFILE_DELETED, renderProfileToggles); | |
| 281 | + | |
| 282 | + scheduleIdleTimer(); | |
| 171 | 283 | } |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | <div class="inline-drawer-content"> |
| 8 | 8 | <label class="checkbox_label" for="keepalive_enabled"> |
| 9 | 9 | <input id="keepalive_enabled" type="checkbox" /> |
| 10 | 10 | <span data-i18n="ext_keepalive_enable">Enable cache keepalive (master switch)</span> |
| 11 | 11 | </label> |
| 12 | 12 | |
| 13 | 13 | <label for="keepalive_timeout" data-i18n="ext_keepalive_timeout">Idle timeout (seconds)</label> |
| @@ -16,7 +16,13 @@ | ||
| 16 | 16 | <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label> |
| 17 | 17 | <input id="keepalive_message" class="text_pole" type="text" /> |
| 18 | 18 | |
| 19 | 19 | <small data-i18n="ext_keepalive_help">After this many seconds withwithout noactivity chaton activitythe active connection profile, a hidden background prompt (the current chat plus this message) is sent to the activethat modelprofile to keep theits 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.</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> | |
| 20 | 26 | </div> |
| 21 | 27 | </div> |
| 22 | 28 | </div> |