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 | ref_images_enabled: false, | 373 | ref_images_enabled: false, |
| 374 | ref_images: [], | 374 | ref_images: [], |
| 375 | 375 | ||
| 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: '', |
| 378 | 381 | ||
| @@ -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 must | 399 | // 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(); | ||
| 785 | 791 | ||
| 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) { | |||
| 1445 | 1451 | ||
| 1446 | // #endregion | 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 | * 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'); |
| @@ -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> |
| @@ -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; } | ||