Cache Keepalive: per-profile idle tracking + per-profile enable; ping only the active profile
| @@ -20,14 +20,53 @@ const MODULE = 'keepalive'; | |||
| 20 | // cache), but we never pay for a full completion. | 20 | // cache), but we never pay for a full completion. |
| 21 | const KEEPALIVE_RESPONSE_LENGTH = 1; | 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 | const defaultSettings = { | 31 | const defaultSettings = { |
| 24 | enabled: false, | 32 | enabled: false, |
| 25 | timeoutSeconds: 295, | 33 | timeoutSeconds: 295, |
| 26 | message: 'Keep the cache warm.', | 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 | let idleTimer = null; | 39 | let idleTimer = null; |
| 30 | let keepaliveActive = false; | 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 | * Clears the pending idle timer, if any. | 72 | * Clears the pending idle timer, if any. |
| @@ -40,16 +79,28 @@ function clearIdleTimer() { | |||
| 40 | } | 79 | } |
| 41 | 80 | ||
| 42 | /** | 81 | /** |
| 43 | * Restarts the idle timer based on the current settings. | 82 | * (Re)schedules the keepalive for the ACTIVE profile based on how long it has been since |
| 44 | * Does nothing (and leaves the timer cleared) when the feature is disabled. | 83 | * that profile's last message. Does nothing (and leaves the timer cleared) when keepalive is |
| 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 | function resetIdleTimer() { | 87 | function scheduleIdleTimer() { |
| 47 | clearIdleTimer(); | 88 | clearIdleTimer(); |
| 48 | const s = extension_settings[MODULE]; | 89 | if (!isKeepaliveEnabledForActive()) { |
| 49 | if (!s.enabled) { | ||
| 50 | return; | 90 | return; |
| 51 | } | 91 | } |
| 52 | idleTimer = setTimeout(fireKeepalive, Math.max(60, Number(s.timeoutSeconds) || 295) * 1000); | 92 | const timeoutMs = Math.max(60, Number(extension_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 | * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never | 110 | * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never |
| 60 | * pay for a real response. | 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 | * type === 'quiet' from the StreamingProcessor) and are never saved to chat, so | 114 | * connection in the background. Each profile's idle clock is tracked 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 | * in flight, we hold the same generation lock the blocking summary path uses: | 120 | * temporary response-length override for the duration of the request, so we 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 | * point checks and bails on, and deactivateSendButtons() reflects the busy | 122 | * `is_send_press` (which every user send entry point checks and bails on) and |
| 74 | * state in the UI. Both are released in `finally`. | 123 | * deactivateSendButtons() reflects the busy state — and release both in `finally`. |
| 75 | */ | 124 | */ |
| 76 | async function fireKeepalive() { | 125 | async function fireKeepalive() { |
| 77 | const s = extension_settings[MODULE]; | 126 | if (!isKeepaliveEnabledForActive()) { |
| 78 | if (!s.enabled) { | ||
| 79 | return; | 127 | return; |
| 80 | } | 128 | } |
| 81 | 129 | ||
| 82 | // Never start while any generation is in progress, or while a previous | 130 | // Never start while any generation is in progress, or while a previous |
| 83 | // keepalive is still running. Reschedule and bail. | 131 | // keepalive is still running. Reschedule and bail. |
| 84 | if (keepaliveActive || isGenerating() || streamingProcessor) { | 132 | if (keepaliveActive || isGenerating() || streamingProcessor) { |
| 85 | resetIdleTimer(); | 133 | scheduleIdleTimer(); |
| 86 | return; | 134 | return; |
| 87 | } | 135 | } |
| 88 | 136 | ||
| @@ -91,7 +139,10 @@ async function fireKeepalive() { | |||
| 91 | deactivateSendButtons(); | 139 | deactivateSendButtons(); |
| 92 | 140 | ||
| 93 | try { | 141 | try { |
| 94 | await generateQuietPrompt({ quietPrompt: s.message, responseLength: KEEPALIVE_RESPONSE_LENGTH }); | 142 | await generateQuietPrompt({ quietPrompt: extension_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 | } catch (error) { | 146 | } catch (error) { |
| 96 | // Best-effort: any failure here is harmless and intentionally swallowed. | 147 | // Best-effort: any failure here is harmless and intentionally swallowed. |
| 97 | console.debug('[Keepalive] Background generation ended:', error); | 148 | console.debug('[Keepalive] Background generation ended:', error); |
| @@ -99,7 +150,43 @@ async function fireKeepalive() { | |||
| 99 | setSendButtonState(false); | 150 | setSendButtonState(false); |
| 100 | activateSendButtons(); | 151 | activateSendButtons(); |
| 101 | keepaliveActive = false; | 152 | keepaliveActive = false; |
| 102 | resetIdleTimer(); | 153 | scheduleIdleTimer(); |
| 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 | extension_settings[MODULE] = {}; | 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 | for (const key of Object.keys(defaultSettings)) { | 201 | for (const key of Object.keys(defaultSettings)) { |
| 119 | if (extension_settings[MODULE][key] === undefined) { | 202 | if (extension_settings[MODULE][key] === undefined) { |
| 120 | extension_settings[MODULE][key] = defaultSettings[key]; | 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 | $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled); | 212 | $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled); |
| 125 | $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds); | 213 | $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds); |
| 126 | $('#keepalive_message').val(extension_settings[MODULE].message); | 214 | $('#keepalive_message').val(extension_settings[MODULE].message); |
| 215 | renderProfileToggles(); | ||
| 127 | } | 216 | } |
| 128 | 217 | ||
| 129 | /** | 218 | /** |
| @@ -133,19 +222,33 @@ function setupListeners() { | |||
| 133 | $('#keepalive_enabled').on('change', function () { | 222 | $('#keepalive_enabled').on('change', function () { |
| 134 | extension_settings[MODULE].enabled = !!$(this).prop('checked'); | 223 | extension_settings[MODULE].enabled = !!$(this).prop('checked'); |
| 135 | saveSettingsDebounced(); | 224 | saveSettingsDebounced(); |
| 136 | resetIdleTimer(); | 225 | scheduleIdleTimer(); |
| 137 | }); | 226 | }); |
| 138 | 227 | ||
| 139 | $('#keepalive_timeout').on('input', function () { | 228 | $('#keepalive_timeout').on('input', function () { |
| 140 | extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295; | 229 | extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295; |
| 141 | saveSettingsDebounced(); | 230 | saveSettingsDebounced(); |
| 142 | resetIdleTimer(); | 231 | scheduleIdleTimer(); |
| 143 | }); | 232 | }); |
| 144 | 233 | ||
| 145 | $('#keepalive_message').on('input', function () { | 234 | $('#keepalive_message').on('input', function () { |
| 146 | extension_settings[MODULE].message = String($(this).val()); | 235 | extension_settings[MODULE].message = String($(this).val()); |
| 147 | saveSettingsDebounced(); | 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 | async function init() { | 254 | async function init() { |
| @@ -155,7 +258,7 @@ async function init() { | |||
| 155 | loadSettings(); | 258 | loadSettings(); |
| 156 | setupListeners(); | 259 | setupListeners(); |
| 157 | 260 | ||
| 158 | // Reset the idle timer on any chat or generation activity. | 261 | // Any chat or generation activity counts as "using" the active profile. |
| 159 | const activityEvents = [ | 262 | const activityEvents = [ |
| 160 | event_types.MESSAGE_SENT, | 263 | event_types.MESSAGE_SENT, |
| 161 | event_types.MESSAGE_RECEIVED, | 264 | event_types.MESSAGE_RECEIVED, |
| @@ -164,8 +267,17 @@ async function init() { | |||
| 164 | event_types.CHAT_CHANGED, | 267 | event_types.CHAT_CHANGED, |
| 165 | ]; | 268 | ]; |
| 166 | for (const event of activityEvents) { | 269 | for (const event of activityEvents) { |
| 167 | eventSource.on(event, resetIdleTimer); | 270 | eventSource.on(event, markActivity); |
| 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 | <div class="inline-drawer-content"> | 7 | <div class="inline-drawer-content"> |
| 8 | <label class="checkbox_label" for="keepalive_enabled"> | 8 | <label class="checkbox_label" for="keepalive_enabled"> |
| 9 | <input id="keepalive_enabled" type="checkbox" /> | 9 | <input id="keepalive_enabled" type="checkbox" /> |
| 10 | <span data-i18n="ext_keepalive_enable">Enable cache keepalive</span> | 10 | <span data-i18n="ext_keepalive_enable">Enable cache keepalive (master switch)</span> |
| 11 | </label> | 11 | </label> |
| 12 | 12 | ||
| 13 | <label for="keepalive_timeout" data-i18n="ext_keepalive_timeout">Idle timeout (seconds)</label> | 13 | <label for="keepalive_timeout" data-i18n="ext_keepalive_timeout">Idle timeout (seconds)</label> |
| @@ -16,7 +16,13 @@ | |||
| 16 | <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label> | 16 | <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label> |
| 17 | <input id="keepalive_message" class="text_pole" type="text" /> | 17 | <input id="keepalive_message" class="text_pole" type="text" /> |
| 18 | 18 | ||
| 19 | <small data-i18n="ext_keepalive_help">After this many seconds with no chat activity, a hidden background prompt (the current chat plus this message) is sent to the active model to keep the 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.</small> | 19 | <small data-i18n="ext_keepalive_help">After this many seconds without activity on the active connection profile, a hidden background prompt (the current chat plus this message) is sent 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.</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 | </div> | 26 | </div> |
| 21 | </div> | 27 | </div> |
| 22 | </div> | 28 | </div> |