Cache Keepalive: per-profile idle tracking + per-profile enable; ping only the active profile

edc60c58a76d3dafc9d41f2f0496991d0d92acf5

permissionBRICK <permissionBRICK@gmail.com>

2 files changed, +153 -35Showing whitespace changes
public/scripts/extensions/keepalive/index.js+145 -33
@@ -20,14 +20,53 @@ const MODULE = 'keepalive';
2020// cache), but we never pay for a full completion.
2121const KEEPALIVE_RESPONSE_LENGTH = 1;
2222
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+
2331const defaultSettings = {
2432 enabled: false,
2533 timeoutSeconds: 295,
2634 message: 'Keep the cache warm.',
35+ // Per-profile enable map: { [profileId]: boolean }. Unlisted profiles default to enabled.
36+ profiles: {},
2737};
2838
2939let idleTimer = null;
3040let 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+}
3170
3271/**
3372 * Clears the pending idle timer, if any.
@@ -40,16 +79,28 @@ function clearIdleTimer() {
4079}
4180
4281/**
4382 * Restarts(Re)schedules the idlekeepalive timerfor the ACTIVE profile based on thehow currentlong settings.it has been since
4483 * 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.
4586 */
4687function resetIdleTimerscheduleIdleTimer() {
4788 clearIdleTimer();
48- const s = extension_settings[MODULE];
89+ if (!isKeepaliveEnabledForActive()) {
49- if (!s.enabled) {
5090 return;
5191 }
5292 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();
53104}
54105
55106/**
@@ -59,30 +110,27 @@ function resetIdleTimer() {
59110 * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never
60111 * pay for a real response.
61112 *
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
63114 * 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.
67116 *
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*
71120 * 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
73122 * `is_send_press` (which every user send entry point checks and bails on, and deactivateSendButtons() reflects the busyand
74123 * statedeactivateSendButtons() inreflects the UI.busy Bothstate are releasedand release both in `finally`.
75124 */
76125async function fireKeepalive() {
77- const s = extension_settings[MODULE];
126+ if (!isKeepaliveEnabledForActive()) {
78- if (!s.enabled) {
79127 return;
80128 }
81129
82130 // Never start while any generation is in progress, or while a previous
83131 // keepalive is still running. Reschedule and bail.
84132 if (keepaliveActive || isGenerating() || streamingProcessor) {
85133 resetIdleTimerscheduleIdleTimer();
86134 return;
87135 }
88136
@@ -91,7 +139,10 @@ async function fireKeepalive() {
91139 deactivateSendButtons();
92140
93141 try {
94142 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();
95146 } catch (error) {
96147 // Best-effort: any failure here is harmless and intentionally swallowed.
97148 console.debug('[Keepalive] Background generation ended:', error);
@@ -99,7 +150,43 @@ async function fireKeepalive() {
99150 setSendButtonState(false);
100151 activateSendButtons();
101152 keepaliveActive = false;
102153 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);
103190 }
104191}
105192
@@ -111,19 +198,21 @@ function loadSettings() {
111198 extension_settings[MODULE] = {};
112199 }
113200
114- if (Object.keys(extension_settings[MODULE]).length === 0) {
115- Object.assign(extension_settings[MODULE], defaultSettings);
116- }
117-
118201 for (const key of Object.keys(defaultSettings)) {
119202 if (extension_settings[MODULE][key] === undefined) {
120203 extension_settings[MODULE][key] = structuredClone(defaultSettings[key]);
204+ }
121205 }
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 = {};
122210 }
123211
124212 $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled);
125213 $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds);
126214 $('#keepalive_message').val(extension_settings[MODULE].message);
215+ renderProfileToggles();
127216}
128217
129218/**
@@ -133,19 +222,33 @@ function setupListeners() {
133222 $('#keepalive_enabled').on('change', function () {
134223 extension_settings[MODULE].enabled = !!$(this).prop('checked');
135224 saveSettingsDebounced();
136225 resetIdleTimerscheduleIdleTimer();
137226 });
138227
139228 $('#keepalive_timeout').on('input', function () {
140229 extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295;
141230 saveSettingsDebounced();
142231 resetIdleTimerscheduleIdleTimer();
143232 });
144233
145234 $('#keepalive_message').on('input', function () {
146235 extension_settings[MODULE].message = String($(this).val());
147236 saveSettingsDebounced();
148237 });
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+ });
149252}
150253
151254async function init() {
@@ -155,7 +258,7 @@ async function init() {
155258 loadSettings();
156259 setupListeners();
157260
158261 // ResetAny thechat idleor timergeneration onactivity anycounts chatas or"using" generationthe activityactive profile.
159262 const activityEvents = [
160263 event_types.MESSAGE_SENT,
161264 event_types.MESSAGE_RECEIVED,
@@ -164,8 +267,17 @@ async function init() {
164267 event_types.CHAT_CHANGED,
165268 ];
166269 for (const event of activityEvents) {
167270 eventSource.on(event, resetIdleTimermarkActivity);
168271 }
169272
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();
171283}
public/scripts/extensions/keepalive/settings.html+8 -2
@@ -7,7 +7,7 @@
77 <div class="inline-drawer-content">
88 <label class="checkbox_label" for="keepalive_enabled">
99 <input id="keepalive_enabled" type="checkbox" />
1010 <span data-i18n="ext_keepalive_enable">Enable cache keepalive (master switch)</span>
1111 </label>
1212
1313 <label for="keepalive_timeout" data-i18n="ext_keepalive_timeout">Idle timeout (seconds)</label>
@@ -16,7 +16,13 @@
1616 <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label>
1717 <input id="keepalive_message" class="text_pole" type="text" />
1818
1919 <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>
2026 </div>
2127 </div>
2228</div>