Fix image prompt swipes and RunPod cold starts

abc1cb103bccdb271ac27a2e4ae81b744cd67c57

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

6 files changed, +428 -88Showing whitespace changes
.gitignore+2 -0
@@ -58,3 +58,5 @@ public/scripts/extensions/third-party
5858yarn.lock
5959*.code-workspace
6060test-results/
61+__pycache__/
62+*.py[cod]
homelab/README.md+1 -1
@@ -7,7 +7,7 @@ changing one, update both (scp to the NAS path + commit here).
77| File | Deployed at | Purpose |
88|---|---|---|
99| `whisper-lazy-proxy.py` | `/mnt/user/appdata/whisper-lazy/proxy.py` (container `whisper-lazy`, :10300) | Wyoming lazy proxy: answers HA availability checks from cache, starts `faster-whisper` on demand, stops it after 15 min idle (frees GPU VRAM). |
1010| `runpod-lazy-proxy.py` | `/mnt/user/appdata/runpod-lazy/proxy.py` (container `runpod-lazy`, :8189) | ComfyUI-compatible lazyon-demand proxy for a volumeless RunPod GPU pod: wake-on-generateonly with`/lazy/warmup` model-groupstarts detectionor provisions, while cold ComfyUI requests return 503 for provider failover. `/lazy/status|warmup|shutdown|ping|catalog` control API for theprovide SillyTavern warmupcontrols UI,and a NAS-side 15-min idle terminate. Model-set changes onrequested aby runningWarm podup download in place via the worker image's model-manager (port 8189, LRU disk eviction); pods are only recreated when none exists or the image predates the manager. |
1111| `runpod-pod.sh` | `/boot/config/runpod-pod.sh` | CLI for the runpod-lazy control API (`status|warmup|shutdown|ping|watch`). |
1212
1313Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model
homelab/runpod-lazy-proxy.py+81 -66
@@ -14,14 +14,15 @@ Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI):
1414 GET /lazy/status -> {"state": red|orange|green, "model": ..., ...}
1515 POST /lazy/warmup -> start pod for the active catalog entry
1616 POST /lazy/shutdown -> terminate pod now
1717 POST /lazy/ping -> keepalive (extendsfor idlea timer;ready pod (never starts a pod)
1818 POST /lazy/catalog -> {"models": [...], "active": "<value>"} store catalog;
19- re-provision the pod if the active model changed
19+ never starts or provisions a pod
2020
2121WakeStart semantics: aonly ComfyUIPOST /prompt arrivinglazy/warmup withmay noprovision readyor podchange matchesmodels. theComfyUI
22-workflow against catalog filenames and provisions for that entry (falls back
22+requests return 503 unless a pod is already ready, allowing SillyTavern's image
23-to the active entry). The idle timer lives here (NAS side): IDLE_SECONDS after
23+target fallback chain to continue without waking this target. The idle timer
24-the last activity signal the pod is terminated, frontend or no frontend.
24+lives here (NAS side): IDLE_SECONDS after the last activity signal the pod is
25+terminated, frontend or no frontend.
2526"""
2627import json
2728import os
@@ -45,7 +46,6 @@ POD_NAME = os.environ.get('POD_NAME', 'comfyui-lazy')
4546START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '1500'))
4647HF_TOKEN = os.environ.get('HF_TOKEN', '')
4748COMFY_ARGS = os.environ.get('COMFY_ARGS', '--listen 0.0.0.0 --port 8188 --use-pytorch-cross-attention')
48-CACHE_FILE = os.environ.get('CACHE_FILE', '/app/system_stats.cache')
4949CATALOG_FILE = os.environ.get('CATALOG_FILE', '/app/catalog.json')
5050
5151state = {
@@ -56,6 +56,7 @@ state = {
5656 'since': time.time(),
5757 'last': time.monotonic(),
5858 'ensuring': 0, # active ensure_pod waiters (status skips probing then)
59+ 'control_epoch': 0, # incremented by shutdown to cancel in-flight warmups
5960 'lock': threading.Lock(),
6061}
6162
@@ -209,9 +210,7 @@ def upstream_ready(pod_id):
209210 req = urllib.request.Request(pod_url(pod_id) + '/system_stats', headers={'User-Agent': UA})
210211 with urllib.request.urlopen(req, timeout=5) as resp:
211212 if resp.status == 200:
212213 data = resp.read()
213- with open(CACHE_FILE, 'wb') as f:
214- f.write(data)
215214 return True
216215 except Exception:
217216 pass
@@ -245,49 +244,67 @@ def _create_pod_with_retries(values):
245244 raise RuntimeError(f'could not create pod: {last_err}')
246245
247246
248247def ensure_pod(values=None, wait=True, control_epoch=None):
249248 """Ensures a pod holding the catalog values (None = active selection).
250249
251250 An existing pod gets missing models downloaded IN PLACE through the in-pod
252251 model manager; recreation only happens for old-image pods without one."""
252+ def check_cancelled():
253+ if control_epoch is not None and control_epoch != state['control_epoch']:
254+ raise RuntimeError('pod warmup cancelled')
255+
253256 values = values or active_values()
254257 key = values_key(values)
255258 files = needed_files(values)
256259 dests = [f['dest'].lstrip('/') for f in files]
257260 created = False
258261 with state['lock']:
262+ check_cancelled()
259263 pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu'])
260264 if not pod_id:
261265 pod_id, have, gpu = find_pod()
262266 if not pod_id:
263267 pod_id, gpu = _create_pod_with_retries(values)
264268 have, created = key, True
265- state['pod_id'], state['model'], state['gpu'] = pod_id, have, gpu
269+ check_cancelled()
266- state['last'] = time.monotonic()
270+ state.update({
271+ 'pod_id': pod_id,
272+ 'model': have,
273+ 'gpu': gpu,
274+ 'last': time.monotonic(),
275+ 'phase': 'green' if state['phase'] == 'green' else 'orange',
276+ })
267277
268- if state['phase'] != 'green':
269- state['phase'] = 'orange'
270278 if not wait:
271279 return pod_id
272280 # Freshly created pods boot with the right manifest; existing pods need an
273281 # /ensure pushed once the manager answers.
274282 ensured = created or not files
275283 no_manager_strikes = 0
284+ with state['lock']:
285+ check_cancelled()
276286 state['ensuring'] += 1
277287 try:
278288 deadline = time.monotonic() + START_TIMEOUT
279289 while time.monotonic() < deadline:
280290 # Booting/downloading counts as activity, else the reaper would
281291 # kill a warming pod mid-download.
292+ with state['lock']:
293+ check_cancelled()
294+ if state['pod_id'] != pod_id:
295+ raise RuntimeError('pod replaced while waiting')
282296 state['last'] = time.monotonic()
283297 if not ensured:
284298 try:
285299 mm_request(pod_id, 'POST', '/ensure', {'files': files, 'priority': True})
300+ except Exception:
301+ pass # manager still booting, or old image without one
302+ else:
286303 ensured = True
304+ with state['lock']:
305+ check_cancelled()
287306 state['model'] = values_key(key_values(state['model']) | set(values))
288307 log(f'in-place ensure requested: {key}')
289- except Exception:
290- pass # manager still booting, or old image without one
291308 ready = upstream_ready(pod_id)
292309 models_ok = True
293310 if not created and files and ensured:
@@ -307,12 +324,18 @@ def ensure_pod(values=None, wait=True):
307324 # Comfy is up but there is no manager (old image): last resort.
308325 log(f'pod has models={state["model"]}, need {key} - recreating (no model manager)')
309326 with state['lock']:
327+ check_cancelled()
310328 terminate(pod_id)
311329 pod_id, gpu = _create_pod_with_retries(values)
330+ check_cancelled()
312331 state.update({'pod_id': pod_id, 'model': key, 'gpu': gpu, 'last': time.monotonic()})
313332 created, ensured = True, True
314333 continue
315334 if ready and models_ok:
335+ with state['lock']:
336+ check_cancelled()
337+ if state['pod_id'] != pod_id:
338+ raise RuntimeError('pod replaced while waiting')
316339 if state['phase'] != 'green':
317340 state['phase'] = 'green'
318341 state['since'] = time.time()
@@ -323,6 +346,8 @@ def ensure_pod(values=None, wait=True):
323346 state['prefetch_pod'] = pod_id
324347 threading.Thread(target=prefetch_rest, args=(pod_id,), daemon=True).start()
325348 return pod_id
349+ with state['lock']:
350+ check_cancelled()
326351 if state['phase'] == 'green':
327352 state['phase'] = 'orange' # in-place download in progress
328353 if state['pod_id'] != pod_id:
@@ -330,23 +355,10 @@ def ensure_pod(values=None, wait=True):
330355 time.sleep(5)
331356 raise RuntimeError('pod not ready within START_TIMEOUT')
332357 finally:
358+ with state['lock']:
333359 state['ensuring'] -= 1
334360
335361
336-def match_workflow_models(workflow_bytes):
337- """Returns all catalog values whose filenames appear in the workflow."""
338- try:
339- text = workflow_bytes.decode('utf-8', 'replace')
340- except Exception:
341- return []
342- found = []
343- for entry in load_catalog().get('models', []):
344- value = entry.get('value')
345- if value and value in text:
346- found.append(value)
347- return found
348-
349-
350362def idle_reaper():
351363 while True:
352364 time.sleep(30)
@@ -362,13 +374,21 @@ def idle_reaper():
362374
363375def status_body():
364376 pod_id = state['pod_id']
377+ previous_phase = state['phase']
365378 if state['ensuring'] > 0:
366379 # An ensure_pod waiter owns the phase; probing here could report green
367380 # mid-download or block the status reply on a dead pod's proxy URL.
368381 pass
369382 elif pod_id:
370383 if state['phase'] != 'green' and upstream_ready(pod_id):
371384 state['phase'] = 'green'
385+ else:
386+ found, have, gpu = find_pod()
387+ if found:
388+ state.update({'pod_id': found, 'model': have, 'gpu': gpu, 'phase': 'orange'})
389+ else:
390+ state.update({'pod_id': None, 'model': None, 'gpu': None, 'phase': 'red'})
391+ if state['phase'] == 'green' and previous_phase != 'green':
372392 state['since'] = time.time()
373393 else:
374394 found, have, gpu = find_pod()
@@ -415,17 +435,27 @@ class Proxy(BaseHTTPRequestHandler):
415435
416436 # ---- control API ----
417437 if path == '/lazy/status':
438+ if self.command != 'GET':
439+ return self._reply(405, b'{"error": "method not allowed"}')
418440 return self._reply(200, status_body())
441+ if path in ('/lazy/ping', '/lazy/warmup', '/lazy/shutdown', '/lazy/catalog') and self.command != 'POST':
442+ return self._reply(405, b'{"error": "method not allowed"}')
419443 if path == '/lazy/ping':
420444 if state['pod_id'] and state['phase'] == 'green':
421445 state['last'] = time.monotonic()
422446 return self._reply(200, b'{"ok": true}')
423447 if path == '/lazy/warmup':
424- threading.Thread(target=self._safe_ensure, args=(None,), daemon=True).start()
448+ with state['lock']:
449+ control_epoch = state['control_epoch']
425450 if state['phase'] != 'green':
426451 state['phase'] = 'orange'
452+ threading.Thread(target=self._safe_ensure, args=(None, control_epoch), daemon=True).start()
427453 return self._reply(200, status_body())
428454 if path == '/lazy/shutdown':
455+ # Serialize with pod creation/recreation. Incrementing the epoch
456+ # also cancels a warmup thread that has not entered its lock yet.
457+ with state['lock']:
458+ state['control_epoch'] += 1
429459 pod_id = state['pod_id'] or find_pod()[0]
430460 if pod_id:
431461 terminate(pod_id)
@@ -440,32 +470,24 @@ class Proxy(BaseHTTPRequestHandler):
440470 catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]}
441471 save_catalog(catalog)
442472 log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}")
443- # Fetch missing models proactively when the active set changed
444- # under a live pod (in place; recreate only for old images).
445- if state['pod_id'] and catalog['active'] and state['model'] and not (set(catalog['active']) <= key_values(state['model'])):
446- log('active model set changed - ensuring models on pod')
447- threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start()
448473 return self._reply(200, status_body())
449474 except Exception as err:
450475 return self._reply(400, json.dumps({'error': str(err)}).encode())
451476
452477 # ---- comfy proxying ----
453- state['last'] = time.monotonic()
454478 pod_id = state['pod_id']
455- ready = pod_id and upstream_ready(pod_id)
479+ if not pod_id:
456- if not ready and self.command == 'GET' and path == '/system_stats':
480+ pod_id, have, gpu = find_pod()
457481 tryif pod_id:
458- with open(CACHE_FILE, 'rb') as f:
482+ state.update({'pod_id': pod_id, 'model': have, 'gpu': gpu, 'phase': 'orange'})
459- return self._reply(200, f.read())
483+ ready = pod_id and state['ensuring'] == 0 and upstream_ready(pod_id)
460- except FileNotFoundError:
484+ if not ready:
461- return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())
485+ return self._reply(503, json.dumps({
462- if not ready or (self.command == 'POST' and path == '/prompt'):
486+ 'error': 'RunPod pod is not ready; start it with SillyTavern\'s Warm up control.',
463- values = match_workflow_models(body) if (self.command == 'POST' and path == '/prompt' and body) else None
487+ }).encode())
464- try:
488+ if state['phase'] != 'green':
465- pod_id = ensure_pod(values or None)
489+ state.update({'phase': 'green', 'since': time.time()})
466- except Exception as err:
490+ state['last'] = time.monotonic()
467- log('ensure_pod failed:', err)
468- return self._reply(502, json.dumps({'error': str(err)}).encode())
469491 try:
470492 req = urllib.request.Request(pod_url(pod_id) + self.path, body, method=self.command)
471493 req.add_header('User-Agent', UA)
@@ -482,9 +504,9 @@ class Proxy(BaseHTTPRequestHandler):
482504 finally:
483505 state['last'] = time.monotonic()
484506
485507 def _safe_ensure(self, value, control_epoch):
486508 try:
487509 ensure_pod(value, control_epoch=control_epoch)
488510 except Exception as err:
489511 log('provision failed:', err)
490512
@@ -494,21 +516,14 @@ class Proxy(BaseHTTPRequestHandler):
494516
495517def main():
496518 threading.Thread(target=idle_reaper, daemon=True).start()
497519 # Adopt a pre-existing pod (e.g. after a proxy restart mid-without provisioning) soor
498520 # its readinesschanging waitit. Read- which alsoonly feedsstatus thepolls idlewill timerupdate -its keepsreadiness runningphase.
499521 pod_id, have, gpu = find_pod()
500522 if pod_id:
501523 state.update({'pod_id': pod_id, 'model': have, 'gpu': gpu, 'phase': 'orange', 'last': time.monotonic()})
502524 log(f'adopting existing pod {pod_id} (model={have})')
503-
504- def _adopt():
505- try:
506- ensure_pod(have.split('+') if have else None)
507- except Exception as err:
508- log('adoption wait failed:', err)
509- threading.Thread(target=_adopt, daemon=True).start()
510525 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
511526 log(f'runpod-lazy v5v6 (in-placemanual modelwarmup switchingonly) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
512527 server.serve_forever()
513528
514529
homelab/tests/test_runpod_lazy_proxy.py+161 -0
@@ -0,0 +1,161 @@
1+import http.client
2+import importlib.util
3+import json
4+import os
5+import pathlib
6+import tempfile
7+import threading
8+import unittest
9+from unittest import mock
10+
11+
12+os.environ.setdefault('RUNPOD_KEY', 'test-key')
13+PROXY_PATH = pathlib.Path(__file__).parents[1] / 'runpod-lazy-proxy.py'
14+SPEC = importlib.util.spec_from_file_location('runpod_lazy_proxy', PROXY_PATH)
15+proxy = importlib.util.module_from_spec(SPEC)
16+SPEC.loader.exec_module(proxy)
17+
18+
19+class RunpodLazyProxyTest(unittest.TestCase):
20+ def setUp(self):
21+ self.temp_dir = tempfile.TemporaryDirectory()
22+ proxy.CATALOG_FILE = str(pathlib.Path(self.temp_dir.name) / 'catalog.json')
23+ proxy.state.update({
24+ 'pod_id': None,
25+ 'model': None,
26+ 'phase': 'red',
27+ 'gpu': None,
28+ 'since': 0,
29+ 'last': 100,
30+ 'ensuring': 0,
31+ 'control_epoch': 0,
32+ })
33+ self.find_pod = mock.patch.object(proxy, 'find_pod', return_value=(None, None, None))
34+ self.find_pod_mock = self.find_pod.start()
35+ self.server = proxy.ThreadingHTTPServer(('127.0.0.1', 0), proxy.Proxy)
36+ self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
37+ self.server_thread.start()
38+
39+ def tearDown(self):
40+ self.server.shutdown()
41+ self.server.server_close()
42+ self.server_thread.join()
43+ self.find_pod.stop()
44+ self.temp_dir.cleanup()
45+
46+ def request(self, method, path, payload=None):
47+ body = json.dumps(payload) if payload is not None else None
48+ headers = {'Content-Type': 'application/json'} if body is not None else {}
49+ connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=2)
50+ connection.request(method, path, body=body, headers=headers)
51+ response = connection.getresponse()
52+ data = response.read()
53+ connection.close()
54+ return response.status, json.loads(data or b'{}')
55+
56+ def test_cold_comfy_requests_fail_without_provisioning(self):
57+ with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
58+ for method, path, payload in [
59+ ('GET', '/system_stats', None),
60+ ('GET', '/object_info', None),
61+ ('POST', '/prompt', {'prompt': {}}),
62+ ]:
63+ with self.subTest(path=path):
64+ status, body = self.request(method, path, payload)
65+ self.assertEqual(status, 503)
66+ self.assertIn('Warm up', body['error'])
67+
68+ ensure_pod.assert_not_called()
69+
70+ def test_status_ping_and_catalog_do_not_provision_cold_pod(self):
71+ with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
72+ status, body = self.request('GET', '/lazy/status')
73+ self.assertEqual(status, 200)
74+ self.assertEqual(body['state'], 'red')
75+
76+ status, _ = self.request('POST', '/lazy/ping')
77+ self.assertEqual(status, 200)
78+ self.assertEqual(proxy.state['last'], 100)
79+
80+ catalog = {
81+ 'models': [{'name': 'Test', 'value': 'test.safetensors', 'files': []}],
82+ 'active': ['test.safetensors'],
83+ }
84+ status, _ = self.request('POST', '/lazy/catalog', catalog)
85+ self.assertEqual(status, 200)
86+ self.assertEqual(proxy.load_catalog(), catalog)
87+ ensure_pod.assert_not_called()
88+
89+ def test_status_clears_a_stale_ready_pod_without_restarting_it(self):
90+ proxy.state.update({'pod_id': 'stopped-pod', 'phase': 'green', 'last': 100})
91+
92+ with mock.patch.object(proxy, 'upstream_ready', return_value=False):
93+ status, body = self.request('GET', '/lazy/status')
94+
95+ self.assertEqual(status, 200)
96+ self.assertEqual(body['state'], 'red')
97+ self.assertIsNone(body['pod_id'])
98+
99+ status, _ = self.request('POST', '/lazy/ping')
100+ self.assertEqual(status, 200)
101+ self.assertEqual(proxy.state['last'], 100)
102+
103+ def test_get_warmup_is_rejected_without_provisioning(self):
104+ with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
105+ status, body = self.request('GET', '/lazy/warmup')
106+
107+ self.assertEqual(status, 405)
108+ self.assertEqual(body['error'], 'method not allowed')
109+ ensure_pod.assert_not_called()
110+
111+ def test_warmup_is_the_explicit_provisioning_path(self):
112+ called = threading.Event()
113+ with mock.patch.object(proxy, 'ensure_pod') as ensure_pod:
114+ ensure_pod.side_effect = lambda _value, control_epoch: called.set()
115+
116+ status, body = self.request('POST', '/lazy/warmup')
117+
118+ self.assertEqual(status, 200)
119+ self.assertEqual(body['state'], 'orange')
120+ self.assertTrue(called.wait(1))
121+ ensure_pod.assert_called_once_with(None, control_epoch=0)
122+
123+ def test_shutdown_cancels_pod_creation_in_progress(self):
124+ create_started = threading.Event()
125+ finish_create = threading.Event()
126+ shutdown_finished = threading.Event()
127+ shutdown_result = {}
128+
129+ def create_pod(_values):
130+ create_started.set()
131+ self.assertTrue(finish_create.wait(2))
132+ return 'created-after-warmup', 'test-gpu'
133+
134+ def shutdown_request():
135+ shutdown_result['response'] = self.request('POST', '/lazy/shutdown')
136+ shutdown_finished.set()
137+
138+ with mock.patch.object(proxy, '_create_pod_with_retries', side_effect=create_pod), \
139+ mock.patch.object(proxy, 'terminate') as terminate:
140+ status, body = self.request('POST', '/lazy/warmup')
141+ self.assertEqual(status, 200)
142+ self.assertEqual(body['state'], 'orange')
143+ self.assertTrue(create_started.wait(1))
144+
145+ shutdown_thread = threading.Thread(target=shutdown_request)
146+ shutdown_thread.start()
147+ self.assertFalse(shutdown_finished.wait(0.1))
148+
149+ finish_create.set()
150+ shutdown_thread.join(2)
151+ self.assertFalse(shutdown_thread.is_alive())
152+
153+ status, body = shutdown_result['response']
154+ self.assertEqual(status, 200)
155+ self.assertEqual(body['state'], 'red')
156+ self.assertIsNone(proxy.state['pod_id'])
157+ terminate.assert_called_once_with('created-after-warmup')
158+
159+
160+if __name__ == '__main__':
161+ unittest.main()
public/scripts/extensions/stable-diffusion/index.js+181 -19
@@ -511,9 +511,13 @@ async function isCurrentSourceReachable() {
511511
512512 switch (extension_settings.sd.source) {
513513 case sources.comfy:
514514 returnif (extension_settings.sd.comfy_type =!== comfyTypes.standard) {
515- ? probe('/api/sd/comfy/ping', { url: extension_settings.sd.comfy_url })
515+ return true;
516- : true;
516+ }
517+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
518+ return isRunpodReady(SOURCE_PROBE_TIMEOUT_MS);
519+ }
520+ return probe('/api/sd/comfy/ping', { url: extension_settings.sd.comfy_url });
517521 case sources.auto:
518522 case sources.vlad:
519523 case sources.drawthings:
@@ -831,7 +835,6 @@ async function loadSettings() {
831835 renderRefImages();
832836 renderRunpodModels();
833837 setupRunpodLoops();
834- pushRunpodCatalog();
835838
836839 for (const style of extension_settings.sd.styles) {
837840 const option = document.createElement('option');
@@ -1538,7 +1541,7 @@ async function fetchReferenceImageBase64(refImage) {
15381541/** Poll cadence for the pod status indicator (faster while it is starting). */
15391542const RUNPOD_POLL_IDLE_MS = 30000;
15401543const RUNPOD_POLL_BUSY_MS = 5000;
15411544/** Keepalive cadence: signalsfor "a SillyTavernpod tabthat isthe open"status toAPI thehas proxyconfirmed is ready. */
15421545const RUNPOD_PING_MS = 60000;
15431546
15441547let runpodStatusTimer = null;
@@ -1550,6 +1553,36 @@ function getRunpodLazyUrl() {
15501553}
15511554
15521555/**
1556+ * Reads the lazy proxy status without starting or warming a pod.
1557+ * @param {number} timeout Request timeout in milliseconds.
1558+ * @returns {Promise<object>} Parsed /lazy/status response.
1559+ */
1560+async function getRunpodStatus(timeout = 8000) {
1561+ const url = getRunpodLazyUrl();
1562+ if (!url) {
1563+ throw new Error('RunPod lazy proxy URL is not configured.');
1564+ }
1565+ const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(timeout) });
1566+ if (!result.ok) {
1567+ throw new Error(`RunPod status returned ${result.status}.`);
1568+ }
1569+ return result.json();
1570+}
1571+
1572+/**
1573+ * Checks whether the lazy proxy already has a ready pod.
1574+ * @param {number} timeout Request timeout in milliseconds.
1575+ * @returns {Promise<boolean>} True only when /lazy/status reports green.
1576+ */
1577+async function isRunpodReady(timeout = 8000) {
1578+ try {
1579+ return (await getRunpodStatus(timeout))?.state === 'green';
1580+ } catch {
1581+ return false;
1582+ }
1583+}
1584+
1585+/**
15531586 * Updates the status dot + text from a /lazy/status response (or an error).
15541587 * @param {object|null} status Parsed status JSON, or null when unreachable.
15551588 */
@@ -1619,12 +1652,8 @@ async function pollRunpodStatus() {
16191652 }
16201653 let phase = runpodLastPhase;
16211654 try {
1622- const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(8000) });
1623- if (!result.ok) {
1624- throw new Error(`status ${result.status}`);
1625- }
16261655 runpodPollFailures = 0;
16271656 phase = renderRunpodStatus(await result.jsongetRunpodStatus());
16281657 } catch {
16291658 // A single slow/failed poll (e.g. while the proxy is provisioning a pod)
16301659 // must not flip the dot to red; only sustained unreachability does.
@@ -1643,8 +1672,14 @@ async function runpodControl(action) {
16431672 return;
16441673 }
16451674 try {
1675+ if (action === 'warmup' && !(await pushRunpodCatalog())) {
1676+ throw new Error('Could not sync the RunPod model catalog. The pod was not started.');
1677+ }
16461678 const result = await fetch(`${url}/lazy/${action}`, { method: 'POST', signal: AbortSignal.timeout(10000) });
1647- renderRunpodStatus(result.ok ? await result.json() : null);
1679+ if (!result.ok) {
1680+ throw new Error(`RunPod ${action} returned ${result.status}.`);
1681+ }
1682+ renderRunpodStatus(await result.json());
16481683 if (action === 'warmup') {
16491684 toastr.info(t`Pod warmup requested. Models will pre-download; the dot turns green when ready.`, t`Image Generation`);
16501685 }
@@ -1657,10 +1692,11 @@ async function runpodControl(action) {
16571692
16581693function runpodKeepalive() {
16591694 const url = getRunpodLazyUrl();
16601695 if (!url || runpodLastPhase !== 'green') {
16611696 return;
16621697 }
1663- // Only extends the pod's idle timer; the proxy never starts a pod for a ping.
1698+ // The proxy also rejects wake-on-ping; this phase guard avoids unnecessary
1699+ // requests whenever the last read-only status poll says the pod is down.
16641700 fetch(`${url}/lazy/ping`, { method: 'POST', signal: AbortSignal.timeout(5000) }).catch(() => { });
16651701}
16661702
@@ -1747,11 +1783,15 @@ function getRunpodActiveModels() {
17471783 return [config.model, config.lora].filter(v => v && known.has(v));
17481784}
17491785
1750-/** Pushes the model catalog + active selection to the proxy (fire-and-forget). */
1786+/**
1787+ * Pushes the model catalog + active selection to the proxy.
1788+ * Catalog updates only store configuration; only /lazy/warmup may provision.
1789+ * @returns {Promise<boolean>} Whether the proxy accepted the catalog.
1790+ */
17511791async function pushRunpodCatalog() {
17521792 const url = getRunpodLazyUrl();
17531793 if (!url) {
17541794 return false;
17551795 }
17561796 const models = getRunpodCatalog().map(m => ({
17571797 name: m.name || m.value,
@@ -1760,14 +1800,19 @@ async function pushRunpodCatalog() {
17601800 files: parseRunpodFiles(m.downloads),
17611801 }));
17621802 try {
17631803 const result = await fetch(`${url}/lazy/catalog`, {
17641804 method: 'POST',
17651805 headers: { 'Content-Type': 'application/json' },
17661806 signal: AbortSignal.timeout(8000),
17671807 body: JSON.stringify({ models, active: getRunpodActiveModels() }),
17681808 });
1809+ if (!result.ok) {
1810+ throw new Error(`catalog returned ${result.status}`);
1811+ }
1812+ return true;
17691813 } catch (error) {
17701814 console.warn('SD: runpod catalog push failed', error);
1815+ return false;
17711816 }
17721817}
17731818
@@ -2724,8 +2769,8 @@ async function onModelChange() {
27242769 rememberWorkflowPref('model', extension_settings.sd.model);
27252770 saveSettingsDebounced();
27262771
2727- // Selecting a catalog model while pointed at the RunPod proxy kicks off the
2772+ // Keep the proxy's stored selection current, but catalog updates never
2728- // download / re-provision for it right away.
2773+ // download, provision, or start a pod. Warm up applies the selection.
27292774 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
27302775 pushRunpodCatalog();
27312776 }
@@ -3142,6 +3187,10 @@ async function loadComfySamplers() {
31423187 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
31433188 return ['N/A'];
31443189 }
3190+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3191+ // Do not query /object_info through a cold lazy proxy on page load.
3192+ return extension_settings.sd.sampler ? [extension_settings.sd.sampler] : [];
3193+ }
31453194 if (!extension_settings.sd.comfy_url) {
31463195 return [];
31473196 }
@@ -3909,6 +3958,10 @@ async function loadComfySchedulers() {
39093958 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
39103959 return ['N/A'];
39113960 }
3961+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3962+ // Do not query /object_info through a cold lazy proxy on page load.
3963+ return extension_settings.sd.scheduler ? [extension_settings.sd.scheduler] : [];
3964+ }
39123965 if (!extension_settings.sd.comfy_url) {
39133966 return [];
39143967 }
@@ -5742,6 +5795,9 @@ async function generateComfyImageCommon(prompt, negativePrompt, signal, basePath
57425795 * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
57435796 */
57445797async function generateComfyImage(prompt, negativePrompt, signal) {
5798+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url) && !(await isRunpodReady(SOURCE_PROBE_TIMEOUT_MS))) {
5799+ throw new Error('RunPod on-demand pod is not ready. Start it with the Warm up control.');
5800+ }
57455801 const placeholders = [
57465802 'model',
57475803 'vae',
@@ -6433,6 +6489,8 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
64336489 media_display: MEDIA_DISPLAY.GALLERY,
64346490 media_index: 0,
64356491 inline_image: false,
6492+ sd_prompt: prompt,
6493+ sd_prompt_message: messageText,
64366494 },
64376495 };
64386496 context.chat.push(message);
@@ -6480,6 +6538,7 @@ async function addSDGenButtons() {
64806538 });
64816539
64826540 $(document).on('click', '.sd_message_gen', (e) => sdMessageButton($(e.currentTarget), { animate: false }));
6541+ $(document).on('click', '.mes_edit', rememberImagePromptBeforeEdit);
64836542
64846543 $(document).on('click touchend', function (e) {
64856544 const target = $(e.target);
@@ -6614,6 +6673,108 @@ function isValidState() {
66146673/** @type {WeakMap<HTMLElement, AbortController>} */
66156674const buttonAbortControllers = new WeakMap();
66166675
6676+/** @type {WeakMap<ChatMessage, { message: string, prompt: string }>} */
6677+const imagePromptEditSnapshots = new WeakMap();
6678+
6679+/**
6680+ * Gets the most recent generated media attachment whose prompt backs image swipes.
6681+ * @param {ChatMessage} message Message that owns the media gallery.
6682+ * @returns {MediaAttachment|null} Generated media attachment, if one exists.
6683+ */
6684+function getImagePromptMedia(message) {
6685+ const media = message?.extra?.media;
6686+ if (!Array.isArray(media)) {
6687+ return null;
6688+ }
6689+
6690+ return media.findLast(item => item?.source === MEDIA_SOURCE.GENERATED || item?.generation_type !== undefined) ?? null;
6691+}
6692+
6693+/**
6694+ * Extracts an edited prompt while preserving the surrounding generated-message template.
6695+ * Falls back to the entire edited message when the old prompt cannot be located, which
6696+ * also supports users who intentionally replace the template along with the prompt.
6697+ * @param {string} previousMessage Message text before editing.
6698+ * @param {string} editedMessage Message text after editing.
6699+ * @param {string} previousPrompt Prompt represented by the previous message.
6700+ * @returns {string} Prompt to use for subsequent image swipes.
6701+ */
6702+function extractEditedImagePrompt(previousMessage, editedMessage, previousPrompt) {
6703+ if (previousMessage === editedMessage) {
6704+ return previousPrompt;
6705+ }
6706+
6707+ if (previousPrompt) {
6708+ let promptIndex = previousMessage.lastIndexOf(previousPrompt);
6709+ while (promptIndex >= 0) {
6710+ const prefix = previousMessage.slice(0, promptIndex);
6711+ const suffix = previousMessage.slice(promptIndex + previousPrompt.length);
6712+ if (editedMessage.startsWith(prefix) && editedMessage.endsWith(suffix) && editedMessage.length >= prefix.length + suffix.length) {
6713+ return editedMessage.slice(prefix.length, editedMessage.length - suffix.length).trim();
6714+ }
6715+ promptIndex = previousMessage.lastIndexOf(previousPrompt, promptIndex - 1);
6716+ }
6717+ }
6718+
6719+ return editedMessage.trim();
6720+}
6721+
6722+/**
6723+ * Remembers the pre-edit message text. New image messages persist this information,
6724+ * while this snapshot provides the same behavior for messages created before the fix.
6725+ * @param {JQuery.ClickEvent} event Edit-button click event.
6726+ */
6727+function rememberImagePromptBeforeEdit(event) {
6728+ const context = getContext();
6729+ const messageId = Number($(event.currentTarget).closest('.mes').attr('mesid'));
6730+ const message = context.chat[messageId];
6731+ const mediaAttachment = getImagePromptMedia(message);
6732+ if (!message || !mediaAttachment) {
6733+ return;
6734+ }
6735+
6736+ const messageText = String(message.mes ?? '');
6737+ const representedMedia = message.extra.media.findLast(item => item?.title && messageText.includes(item.title));
6738+ const prompt = message.extra?.sd_prompt ?? representedMedia?.title ?? mediaAttachment.title ?? message.extra?.title ?? '';
6739+ imagePromptEditSnapshots.set(message, {
6740+ message: messageText,
6741+ prompt: String(prompt),
6742+ });
6743+}
6744+
6745+/**
6746+ * Makes an edited generated-image message the prompt source for later image swipes.
6747+ * @param {number} messageId Edited message index.
6748+ */
6749+function onImagePromptMessageEdited(messageId) {
6750+ const context = getContext();
6751+ const message = context.chat[messageId];
6752+ const mediaAttachment = getImagePromptMedia(message);
6753+ if (!message || !mediaAttachment) {
6754+ return;
6755+ }
6756+
6757+ const snapshot = imagePromptEditSnapshots.get(message) ?? (
6758+ typeof message.extra?.sd_prompt_message === 'string'
6759+ ? { message: message.extra.sd_prompt_message, prompt: String(message.extra.sd_prompt ?? mediaAttachment.title ?? '') }
6760+ : null
6761+ );
6762+ imagePromptEditSnapshots.delete(message);
6763+ if (!snapshot) {
6764+ return;
6765+ }
6766+
6767+ const editedMessage = String(message.mes ?? '');
6768+ if (editedMessage === snapshot.message) {
6769+ return;
6770+ }
6771+
6772+ const editedPrompt = extractEditedImagePrompt(snapshot.message, editedMessage, snapshot.prompt);
6773+ message.extra.sd_prompt = editedPrompt;
6774+ message.extra.sd_prompt_message = editedMessage;
6775+ message.extra.sd_prompt_override = editedPrompt;
6776+}
6777+
66176778/**
66186779 * "Paintbrush" button handler to generate a new image for a message.
66196780 * @param {JQuery<HTMLElement>} $icon The click target.
@@ -6761,7 +6922,7 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
67616922
67626923 try {
67636924 const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; };
67646925 const savedPrompt = message.extra.sd_prompt_override ?? mediaAttachment.title ?? message.extra.title ?? '';
67656926 const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? '';
67666927 const refineArgs = {
67676928 negative: savedNegative,
@@ -7438,6 +7599,7 @@ export async function init() {
74387599 });
74397600
74407601 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
7602+ eventSource.on(event_types.MESSAGE_EDITED, onImagePromptMessageEdited);
74417603 eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped);
74427604
74437605 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
public/scripts/extensions/stable-diffusion/settings.html+2 -2
@@ -100,9 +100,9 @@
100100 <span data-i18n="Copy ComfyUI URL">Copy ComfyUI URL</span>
101101 </div>
102102 </div>
103103 <small data-i18n="sd_runpod_small">WhileThe apod SillyTavernstarts tabonly iswhen open,you apress keepaliveWarm pingup. A SillyTavern tab keeps thean already-ready pod alive; it shuts down 15 minutes after the last activity. eitherImage wayrequests fail over instead of starting a stopped pod. Red = off, orange = starting/downloading models, green = ready.</small>
104104 <h5 data-i18n="Model catalog">Model catalog</h5>
105105 <small data-i18n="sd_runpod_models_small">When the ComfyUI URL points at the proxy, the Model dropdown lists these entries instead of asking the backend. ThePress podWarm downloadsup exactlyto apply the selected entry'smodel filesand atdownload bootits files; changing the selection re-provisionsor withgenerating thean newimage modelnever starts or provisions a stopped pod. Filename = what &quot;%model%&quot; resolves to; downloads = one &quot;subpath url&quot; per line, including companion files (text encoder, VAE, LoRA).</small>
106106 <div id="sd_runpod_models_list" class="flex-container flexFlowColumn marginTopBot5"></div>
107107 <div class="flex-container marginTopBot5">
108108 <div id="sd_runpod_models_add" class="menu_button menu_button_icon">