Add Cache Keepalive extension Reviewed-by: auto-review

acdce0422654c044416be6797fdefd3b99a630ec

permissionBRICK <permissionBRICK@gmail.com>

3 files changed, +207 -0Showing whitespace changes
public/scripts/extensions/keepalive/index.js+171 -0
@@ -0,0 +1,171 @@
1+import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
2+import {
3+ activateSendButtons,
4+ deactivateSendButtons,
5+ eventSource,
6+ event_types,
7+ generateQuietPrompt,
8+ isGenerating,
9+ saveSettingsDebounced,
10+ setSendButtonState,
11+ streamingProcessor,
12+} from '../../../script.js';
13+
14+export { init };
15+
16+const MODULE = 'keepalive';
17+
18+// Cap the keepalive completion to a single token. The full prompt prefix is
19+// still processed by the backend (which is what refreshes the server-side
20+// cache), but we never pay for a full completion.
21+const KEEPALIVE_RESPONSE_LENGTH = 1;
22+
23+const defaultSettings = {
24+ enabled: false,
25+ timeoutSeconds: 295,
26+ message: 'Keep the cache warm.',
27+};
28+
29+let idleTimer = null;
30+let keepaliveActive = false;
31+
32+/**
33+ * Clears the pending idle timer, if any.
34+ */
35+function clearIdleTimer() {
36+ if (idleTimer) {
37+ clearTimeout(idleTimer);
38+ idleTimer = null;
39+ }
40+}
41+
42+/**
43+ * Restarts the idle timer based on the current settings.
44+ * Does nothing (and leaves the timer cleared) when the feature is disabled.
45+ */
46+function resetIdleTimer() {
47+ clearIdleTimer();
48+ const s = extension_settings[MODULE];
49+ if (!s.enabled) {
50+ return;
51+ }
52+ idleTimer = setTimeout(fireKeepalive, Math.max(60, Number(s.timeoutSeconds) || 295) * 1000);
53+}
54+
55+/**
56+ * Fires a background ("quiet") generation against the active model to keep the
57+ * server-side prompt cache warm. The full prompt prefix (current chat context +
58+ * the keepalive message) is sent — which is what refreshes the cache — while the
59+ * completion is capped to a single token (KEEPALIVE_RESPONSE_LENGTH) so we never
60+ * pay for a real response.
61+ *
62+ * Quiet generations are forced non-streaming by the core (Generate excludes
63+ * type === 'quiet' from the StreamingProcessor) and are never saved to chat, so
64+ * the output is simply discarded. We deliberately do NOT hook the global
65+ * STREAM_TOKEN_RECEIVED / stopGeneration() path: quiet requests never emit it,
66+ * and using it would risk aborting a concurrent user generation.
67+ *
68+ * generateQuietPrompt's responseLength applies a *global* temporary
69+ * response-length override for the duration of the request. To make sure a user
70+ * generation can never start (and inherit that override) while the keepalive is
71+ * in flight, we hold the same generation lock the blocking summary path uses:
72+ * setSendButtonState(true) flips `is_send_press`, which every user send entry
73+ * point checks and bails on, and deactivateSendButtons() reflects the busy
74+ * state in the UI. Both are released in `finally`.
75+ */
76+async function fireKeepalive() {
77+ const s = extension_settings[MODULE];
78+ if (!s.enabled) {
79+ return;
80+ }
81+
82+ // Never start while any generation is in progress, or while a previous
83+ // keepalive is still running. Reschedule and bail.
84+ if (keepaliveActive || isGenerating() || streamingProcessor) {
85+ resetIdleTimer();
86+ return;
87+ }
88+
89+ keepaliveActive = true;
90+ setSendButtonState(true);
91+ deactivateSendButtons();
92+
93+ try {
94+ await generateQuietPrompt({ quietPrompt: s.message, responseLength: KEEPALIVE_RESPONSE_LENGTH });
95+ } catch (error) {
96+ // Best-effort: any failure here is harmless and intentionally swallowed.
97+ console.debug('[Keepalive] Background generation ended:', error);
98+ } finally {
99+ setSendButtonState(false);
100+ activateSendButtons();
101+ keepaliveActive = false;
102+ resetIdleTimer();
103+ }
104+}
105+
106+/**
107+ * Loads settings into the global store and hydrates the UI controls.
108+ */
109+function loadSettings() {
110+ if (extension_settings[MODULE] === undefined) {
111+ extension_settings[MODULE] = {};
112+ }
113+
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)) {
119+ if (extension_settings[MODULE][key] === undefined) {
120+ extension_settings[MODULE][key] = defaultSettings[key];
121+ }
122+ }
123+
124+ $('#keepalive_enabled').prop('checked', extension_settings[MODULE].enabled);
125+ $('#keepalive_timeout').val(extension_settings[MODULE].timeoutSeconds);
126+ $('#keepalive_message').val(extension_settings[MODULE].message);
127+}
128+
129+/**
130+ * Wires up the settings UI control handlers.
131+ */
132+function setupListeners() {
133+ $('#keepalive_enabled').on('change', function () {
134+ extension_settings[MODULE].enabled = !!$(this).prop('checked');
135+ saveSettingsDebounced();
136+ resetIdleTimer();
137+ });
138+
139+ $('#keepalive_timeout').on('input', function () {
140+ extension_settings[MODULE].timeoutSeconds = Number($(this).val()) || 295;
141+ saveSettingsDebounced();
142+ resetIdleTimer();
143+ });
144+
145+ $('#keepalive_message').on('input', function () {
146+ extension_settings[MODULE].message = String($(this).val());
147+ saveSettingsDebounced();
148+ });
149+}
150+
151+async function init() {
152+ const settingsHtml = await renderExtensionTemplateAsync(MODULE, 'settings');
153+ $('#extensions_settings2').append(settingsHtml);
154+
155+ loadSettings();
156+ setupListeners();
157+
158+ // Reset the idle timer on any chat or generation activity.
159+ const activityEvents = [
160+ event_types.MESSAGE_SENT,
161+ event_types.MESSAGE_RECEIVED,
162+ event_types.GENERATION_STARTED,
163+ event_types.GENERATION_ENDED,
164+ event_types.CHAT_CHANGED,
165+ ];
166+ for (const event of activityEvents) {
167+ eventSource.on(event, resetIdleTimer);
168+ }
169+
170+ resetIdleTimer();
171+}
public/scripts/extensions/keepalive/manifest.json+14 -0
@@ -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+}
public/scripts/extensions/keepalive/settings.html+22 -0
@@ -0,0 +1,22 @@
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</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 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>
20+ </div>
21+ </div>
22+</div>