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.
| @@ -373,6 +373,9 @@ const defaultSettings = { | ||
| 373 | 373 | ref_images_enabled: false, |
| 374 | 374 | ref_images: [], |
| 375 | 375 | |
| 376 | + // RunPod lazy-pod proxy base URL ('' = feature off) | |
| 377 | + runpod_lazy_url: '', | |
| 378 | + | |
| 376 | 379 | // Dedicated LLM connection profile for image-prompt generation ('' = use active model) |
| 377 | 380 | prompt_generation_profile: '', |
| 378 | 381 | |
| @@ -392,6 +395,7 @@ const PRESET_EXCLUDE_KEYS = [ | ||
| 392 | 395 | // The reference image library is global, not a per-backend setting. |
| 393 | 396 | 'ref_images_enabled', |
| 394 | 397 | 'ref_images', |
| 398 | + 'runpod_lazy_url', | |
| 395 | 399 | // The image-prompt LLM profile is independent of the image backend, so it must |
| 396 | 400 | // never be captured/swapped by image-generation presets or the fallback retry. |
| 397 | 401 | 'prompt_generation_profile', |
| @@ -780,8 +784,10 @@ async function loadSettings() { | ||
| 780 | 784 | $('#sd_google_duration').val(extension_settings.sd.google_duration); |
| 781 | 785 | $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled); |
| 782 | 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 | 788 | renderPresetChain(); |
| 784 | 789 | renderRefImages(); |
| 790 | + setupRunpodLoops(); | |
| 785 | 791 | |
| 786 | 792 | for (const style of extension_settings.sd.styles) { |
| 787 | 793 | const option = document.createElement('option'); |
| @@ -1445,6 +1451,110 @@ async function fetchReferenceImageBase64(refImage) { | ||
| 1445 | 1451 | |
| 1446 | 1452 | // #endregion |
| 1447 | 1453 | |
| 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 | + | |
| 1448 | 1558 | /** |
| 1449 | 1559 | * Rebuilds the custom wand entries list in the settings UI. |
| 1450 | 1560 | */ |
| @@ -6891,6 +7001,9 @@ export async function init() { | ||
| 6891 | 7001 | $('#sd_ref_images_enabled').on('change', onRefImagesEnabledChange); |
| 6892 | 7002 | $('#sd_ref_images_add').on('click', () => $('#sd_ref_images_file').trigger('click')); |
| 6893 | 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 | 7007 | $('#sd_custom_entry_add').on('click', onAddCustomEntryClick); |
| 6895 | 7008 | $('#sd_custom_entries_list').on('click', '[data-action]', function () { |
| 6896 | 7009 | const id = $(this).attr('data-entry-id'); |
| @@ -74,6 +74,23 @@ | ||
| 74 | 74 | <input id="sd_ref_images_file" type="file" accept="image/png, image/jpeg, image/webp" multiple hidden /> |
| 75 | 75 | </div> |
| 76 | 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 | 94 | <label for="sd_source" data-i18n="Source">Source</label> |
| 78 | 95 | <select id="sd_source" class="text_pole"> |
| 79 | 96 | <option value="aimlapi">AI/ML API</option> |
| @@ -115,3 +115,15 @@ | ||
| 115 | 115 | text-align: right; |
| 116 | 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; } | |