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 = {
373 ref_images_enabled: false,373 ref_images_enabled: false,
374 ref_images: [],374 ref_images: [],
375375
376 // RunPod lazy-pod proxy base URL ('' = feature off)
377 runpod_lazy_url: '',
378
376 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)379 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
377 prompt_generation_profile: '',380 prompt_generation_profile: '',
378381
@@ -392,6 +395,7 @@ const PRESET_EXCLUDE_KEYS = [
392 // The reference image library is global, not a per-backend setting.395 // The reference image library is global, not a per-backend setting.
393 'ref_images_enabled',396 'ref_images_enabled',
394 'ref_images',397 'ref_images',
398 'runpod_lazy_url',
395 // The image-prompt LLM profile is independent of the image backend, so it must399 // The image-prompt LLM profile is independent of the image backend, so it must
396 // never be captured/swapped by image-generation presets or the fallback retry.400 // never be captured/swapped by image-generation presets or the fallback retry.
397 'prompt_generation_profile',401 'prompt_generation_profile',
@@ -780,8 +784,10 @@ async function loadSettings() {
780 $('#sd_google_duration').val(extension_settings.sd.google_duration);784 $('#sd_google_duration').val(extension_settings.sd.google_duration);
781 $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);785 $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled);
782 $('#sd_ref_images_enabled').prop('checked', extension_settings.sd.ref_images_enabled);786 $('#sd_ref_images_enabled').prop('checked', extension_settings.sd.ref_images_enabled);
787 $('#sd_runpod_lazy_url').val(extension_settings.sd.runpod_lazy_url ?? '');
783 renderPresetChain();788 renderPresetChain();
784 renderRefImages();789 renderRefImages();
790 setupRunpodLoops();
785791
786 for (const style of extension_settings.sd.styles) {792 for (const style of extension_settings.sd.styles) {
787 const option = document.createElement('option');793 const option = document.createElement('option');
@@ -1445,6 +1451,110 @@ async function fetchReferenceImageBase64(refImage) {
14451451
1446// #endregion1452// #endregion
14471453
1454// #region RunPod lazy pod
1455
1456/** Poll cadence for the pod status indicator (faster while it is starting). */
1457const RUNPOD_POLL_IDLE_MS = 30000;
1458const RUNPOD_POLL_BUSY_MS = 5000;
1459/** Keepalive cadence: signals "a SillyTavern tab is open" to the proxy. */
1460const RUNPOD_PING_MS = 60000;
1461
1462let runpodStatusTimer = null;
1463let runpodPingTimer = null;
1464
1465function 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 */
1473function 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
1495async 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
1511async 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
1529function 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. */
1539function 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
1550function onRunpodLazyUrlInput() {
1551 extension_settings.sd.runpod_lazy_url = String($(this).val() ?? '').trim();
1552 saveSettingsDebounced();
1553 setupRunpodLoops();
1554}
1555
1556// #endregion
1557
1448/**1558/**
1449 * Rebuilds the custom wand entries list in the settings UI.1559 * Rebuilds the custom wand entries list in the settings UI.
1450 */1560 */
@@ -6891,6 +7001,9 @@ export async function init() {
6891 $('#sd_ref_images_enabled').on('change', onRefImagesEnabledChange);7001 $('#sd_ref_images_enabled').on('change', onRefImagesEnabledChange);
6892 $('#sd_ref_images_add').on('click', () => $('#sd_ref_images_file').trigger('click'));7002 $('#sd_ref_images_add').on('click', () => $('#sd_ref_images_file').trigger('click'));
6893 $('#sd_ref_images_file').on('change', onRefImagesFileChange);7003 $('#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'));
6894 $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);7007 $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
6895 $('#sd_custom_entries_list').on('click', '[data-action]', function () {7008 $('#sd_custom_entries_list').on('click', '[data-action]', function () {
6896 const id = $(this).attr('data-entry-id');7009 const id = $(this).attr('data-entry-id');
public/scripts/extensions/stable-diffusion/settings.html+17 -0
@@ -74,6 +74,23 @@
74 <input id="sd_ref_images_file" type="file" accept="image/png, image/jpeg, image/webp" multiple hidden />74 <input id="sd_ref_images_file" type="file" accept="image/png, image/jpeg, image/webp" multiple hidden />
75 </div>75 </div>
76 <hr>76 <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>
77 <label for="sd_source" data-i18n="Source">Source</label>94 <label for="sd_source" data-i18n="Source">Source</label>
78 <select id="sd_source" class="text_pole">95 <select id="sd_source" class="text_pole">
79 <option value="aimlapi">AI/ML API</option>96 <option value="aimlapi">AI/ML API</option>
public/scripts/extensions/stable-diffusion/style.css+12 -0
@@ -115,3 +115,15 @@
115 text-align: right;115 text-align: right;
116 opacity: 0.7;116 opacity: 0.7;
117}117}
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; }