Stable Diffusion: RunPod on-demand pod warmup UI Status dot (red/orange/green) + Warm up / Shut down buttons for the runpod-lazy proxy, configured via a proxy URL setting. While a tab is open, a keepalive ping (1/min) extends the pod's idle timer; the proxy's NAS-side 15-minute timer remains authoritative, so the pod always shuts down when the frontend disappears.

52784ab2589acf472f14ff742f562b88159b784d

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

3 files changed, +142 -0Showing whitespace changes
public/scripts/extensions/stable-diffusion/index.js+113 -0
@@ -373,6 +373,9 @@ const defaultSettings = {
373373 ref_images_enabled: false,
374374 ref_images: [],
375375
376+ // RunPod lazy-pod proxy base URL ('' = feature off)
377+ runpod_lazy_url: '',
378+
376379 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
377380 prompt_generation_profile: '',
378381
@@ -392,6 +395,7 @@ const PRESET_EXCLUDE_KEYS = [
392395 // The reference image library is global, not a per-backend setting.
393396 'ref_images_enabled',
394397 'ref_images',
398+ 'runpod_lazy_url',
395399 // The image-prompt LLM profile is independent of the image backend, so it must
396400 // never be captured/swapped by image-generation presets or the fallback retry.
397401 'prompt_generation_profile',
@@ -780,8 +784,10 @@ async function loadSettings() {
780784 $('#sd_google_duration').val(extension_settings.sd.google_duration);
781785 $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);
782786 $('#sd_ref_images_enabled').prop('checked', extension_settings.sd.ref_images_enabled);
787+ $('#sd_runpod_lazy_url').val(extension_settings.sd.runpod_lazy_url ?? '');
783788 renderPresetChain();
784789 renderRefImages();
790+ setupRunpodLoops();
785791
786792 for (const style of extension_settings.sd.styles) {
787793 const option = document.createElement('option');
@@ -1445,6 +1451,110 @@ async function fetchReferenceImageBase64(refImage) {
14451451
14461452// #endregion
14471453
1454+// #region RunPod lazy pod
1455+
1456+/** Poll cadence for the pod status indicator (faster while it is starting). */
1457+const RUNPOD_POLL_IDLE_MS = 30000;
1458+const RUNPOD_POLL_BUSY_MS = 5000;
1459+/** Keepalive cadence: signals "a SillyTavern tab is open" to the proxy. */
1460+const RUNPOD_PING_MS = 60000;
1461+
1462+let runpodStatusTimer = null;
1463+let runpodPingTimer = null;
1464+
1465+function getRunpodLazyUrl() {
1466+ return String(extension_settings.sd.runpod_lazy_url ?? '').trim().replace(/\/$/, '');
1467+}
1468+
1469+/**
1470+ * Updates the status dot + text from a /lazy/status response (or an error).
1471+ * @param {object|null} status Parsed status JSON, or null when unreachable.
1472+ */
1473+function renderRunpodStatus(status) {
1474+ const dot = document.getElementById('sd_runpod_dot');
1475+ const text = document.getElementById('sd_runpod_status_text');
1476+ if (!dot || !text) {
1477+ return;
1478+ }
1479+ const phase = status?.state ?? 'red';
1480+ dot.classList.remove('sd_runpod_red', 'sd_runpod_orange', 'sd_runpod_green');
1481+ dot.classList.add(`sd_runpod_${['red', 'orange', 'green'].includes(phase) ? phase : 'red'}`);
1482+ if (!status) {
1483+ text.textContent = 'proxy unreachable';
1484+ } else if (phase === 'green') {
1485+ const left = status.idle_seconds_left ? ` (idle stop in ${Math.round(status.idle_seconds_left / 60)}m)` : '';
1486+ text.textContent = `ready on ${status.gpu ?? 'GPU'}${left}`;
1487+ } else if (phase === 'orange') {
1488+ text.textContent = `starting (models: ${status.models ?? '?'})…`;
1489+ } else {
1490+ text.textContent = 'off';
1491+ }
1492+ return phase;
1493+}
1494+
1495+async function pollRunpodStatus() {
1496+ const url = getRunpodLazyUrl();
1497+ if (!url) {
1498+ return;
1499+ }
1500+ let phase = 'red';
1501+ try {
1502+ const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(5000) });
1503+ phase = renderRunpodStatus(result.ok ? await result.json() : null);
1504+ } catch {
1505+ renderRunpodStatus(null);
1506+ }
1507+ clearTimeout(runpodStatusTimer);
1508+ runpodStatusTimer = setTimeout(pollRunpodStatus, phase === 'orange' ? RUNPOD_POLL_BUSY_MS : RUNPOD_POLL_IDLE_MS);
1509+}
1510+
1511+async function runpodControl(action) {
1512+ const url = getRunpodLazyUrl();
1513+ if (!url) {
1514+ return;
1515+ }
1516+ try {
1517+ const result = await fetch(`${url}/lazy/${action}`, { method: 'POST', signal: AbortSignal.timeout(10000) });
1518+ renderRunpodStatus(result.ok ? await result.json() : null);
1519+ if (action === 'warmup') {
1520+ toastr.info(t`Pod warmup requested. Models will pre-download; the dot turns green when ready.`, t`Image Generation`);
1521+ }
1522+ } catch (error) {
1523+ toastr.error(String(error), t`Image Generation`);
1524+ }
1525+ clearTimeout(runpodStatusTimer);
1526+ runpodStatusTimer = setTimeout(pollRunpodStatus, RUNPOD_POLL_BUSY_MS);
1527+}
1528+
1529+function runpodKeepalive() {
1530+ const url = getRunpodLazyUrl();
1531+ if (!url) {
1532+ return;
1533+ }
1534+ // Only extends the pod's idle timer; the proxy never starts a pod for a ping.
1535+ fetch(`${url}/lazy/ping`, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(() => { });
1536+}
1537+
1538+/** (Re)starts the polling/keepalive loops based on the configured URL. */
1539+function setupRunpodLoops() {
1540+ clearTimeout(runpodStatusTimer);
1541+ clearInterval(runpodPingTimer);
1542+ if (!getRunpodLazyUrl()) {
1543+ renderRunpodStatus(null);
1544+ return;
1545+ }
1546+ pollRunpodStatus();
1547+ runpodPingTimer = setInterval(runpodKeepalive, RUNPOD_PING_MS);
1548+}
1549+
1550+function onRunpodLazyUrlInput() {
1551+ extension_settings.sd.runpod_lazy_url = String($(this).val() ?? '').trim();
1552+ saveSettingsDebounced();
1553+ setupRunpodLoops();
1554+}
1555+
1556+// #endregion
1557+
14481558/**
14491559 * Rebuilds the custom wand entries list in the settings UI.
14501560 */
@@ -6891,6 +7001,9 @@ export async function init() {
68917001 $('#sd_ref_images_enabled').on('change', onRefImagesEnabledChange);
68927002 $('#sd_ref_images_add').on('click', () => $('#sd_ref_images_file').trigger('click'));
68937003 $('#sd_ref_images_file').on('change', onRefImagesFileChange);
7004+ $('#sd_runpod_lazy_url').on('input', onRunpodLazyUrlInput);
7005+ $('#sd_runpod_warmup').on('click', () => runpodControl('warmup'));
7006+ $('#sd_runpod_shutdown').on('click', () => runpodControl('shutdown'));
68947007 $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
68957008 $('#sd_custom_entries_list').on('click', '[data-action]', function () {
68967009 const id = $(this).attr('data-entry-id');
public/scripts/extensions/stable-diffusion/settings.html+17 -0
@@ -74,6 +74,23 @@
7474 <input id="sd_ref_images_file" type="file" accept="image/png, image/jpeg, image/webp" multiple hidden />
7575 </div>
7676 <hr>
77+ <h4 data-i18n="RunPod On-Demand Pod">RunPod On-Demand Pod</h4>
78+ <label for="sd_runpod_lazy_url" data-i18n="Lazy proxy URL">Lazy proxy URL</label>
79+ <input id="sd_runpod_lazy_url" type="text" class="text_pole" placeholder="http://10.0.0.4:8189" />
80+ <div class="flex-container alignItemsCenter marginTopBot5">
81+ <span id="sd_runpod_dot" class="sd_runpod_dot"></span>
82+ <span id="sd_runpod_status_text" class="flex1">unknown</span>
83+ <div id="sd_runpod_warmup" class="menu_button menu_button_icon" title="Start the pod and pre-download all models" data-i18n="[title]sd_runpod_warmup">
84+ <i class="fa-solid fa-fire"></i>
85+ <span data-i18n="Warm up">Warm up</span>
86+ </div>
87+ <div id="sd_runpod_shutdown" class="menu_button menu_button_icon" title="Terminate the pod now" data-i18n="[title]sd_runpod_shutdown">
88+ <i class="fa-solid fa-power-off"></i>
89+ <span data-i18n="Shut down">Shut down</span>
90+ </div>
91+ </div>
92+ <small data-i18n="sd_runpod_small">While a SillyTavern tab is open, a keepalive ping keeps the pod alive; it shuts down 15 minutes after the last activity either way. Red = off, orange = starting/downloading models, green = ready.</small>
93+ <hr>
7794 <label for="sd_source" data-i18n="Source">Source</label>
7895 <select id="sd_source" class="text_pole">
7996 <option value="aimlapi">AI/ML API</option>
public/scripts/extensions/stable-diffusion/style.css+12 -0
@@ -115,3 +115,15 @@
115115 text-align: right;
116116 opacity: 0.7;
117117}
118+
119+.sd_runpod_dot {
120+ width: 12px;
121+ height: 12px;
122+ border-radius: 50%;
123+ flex-shrink: 0;
124+ background: grey;
125+}
126+
127+.sd_runpod_dot.sd_runpod_red { background: #d9534f; }
128+.sd_runpod_dot.sd_runpod_orange { background: #f0ad4e; }
129+.sd_runpod_dot.sd_runpod_green { background: #5cb85c; }