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

edc60c58a76d3dafc9d41f2f0496991d0d92acf5

permissionBRICK <permissionBRICK@gmail.com>

2 files changed, +153 -35Ignore whitespace
public/scripts/extensions/keepalive/index.js+145 -33
@@ -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.
21const KEEPALIVE_RESPONSE_LENGTH = 1;21const KEEPALIVE_RESPONSE_LENGTH = 1;
2222
23// Idle-tracking key used when no connection profile is active (manual API panel).
24const 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.
29const EXPIRED_GRACE_MS = 3000;
30
23const defaultSettings = {31const 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};
2838
29let idleTimer = null;39let idleTimer = null;
30let keepaliveActive = false;40let 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.
43const lastActivity = {};
44
45/**
46 * @returns {string} The active connection profile id, or NONE_KEY for a manual connection.
47 */
48function 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 */
58function 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
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}
4180
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 */
46function resetIdleTimer() {87function 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 */
101function markActivity() {
102 lastActivity[getActiveProfileKey()] = Date.now();
103 scheduleIdleTimer();
53}104}
54105
55/**106/**
@@ -59,30 +110,27 @@ function resetIdleTimer() {
59 * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never110 * 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 excludes113 * Only the CURRENTLY ACTIVE profile is ever pinged — keepalive never switches the
63 * type === 'quiet' from the StreamingProcessor) and are never saved to chat, so114 * 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 global115 * 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* temporary117 * 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 user118 * 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 is119 * 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 entry121 * same generation lock the blocking summary path uses — setSendButtonState(true) flips
73 * point checks and bails on, and deactivateSendButtons() reflects the busy122 * `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 */
76async function fireKeepalive() {125async function fireKeepalive() {
77 const s = extension_settings[MODULE];126 if (!isKeepaliveEnabledForActive()) {
78 if (!s.enabled) {
79 return;127 return;
80 }128 }
81129
82 // Never start while any generation is in progress, or while a previous130 // 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 }
88136
@@ -91,7 +139,10 @@ async function fireKeepalive() {
91 deactivateSendButtons();139 deactivateSendButtons();
92140
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 */
160function 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}
105192
@@ -111,19 +198,21 @@ function loadSettings() {
111 extension_settings[MODULE] = {};198 extension_settings[MODULE] = {};
112 }199 }
113200
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 }
123206
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}
128217
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 });
138227
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 });
144233
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}
150253
151async function init() {254async function init() {
@@ -155,7 +258,7 @@ async function init() {
155 loadSettings();258 loadSettings();
156 setupListeners();259 setupListeners();
157260
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 }
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();
171}283}
public/scripts/extensions/keepalive/settings.html+8 -2
@@ -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>
1212
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" />
1818
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>