Stable Diffusion: LoRA selection + per-workflow model/lora memory - LoRA dropdown for ComfyUI (new %lora% workflow placeholder; backend /api/sd/comfy/loras endpoint lists LoraLoader options; RunPod proxy URLs list catalog entries of kind 'lora' instead). - RunPod catalog entries are typed (model|lora); the active selection pushed to the proxy is now the model+lora set, and the pod provisions the union of their files (homelab: proxy v4). - Per-workflow memory: model + lora are remembered per comfy workflow (stored in comfy_workflow_prefs, part of preset snapshots -> each chain provider keeps its own map); switching workflows auto-restores them.

83a5205f51492f7446527d4a1a90bfc4c633b515

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

4 files changed, +193 -41Showing whitespace changes
homelab/runpod-lazy-proxy.py+45 -29
@@ -80,8 +80,15 @@ def catalog_entry(value):
80 return None80 return None
8181
8282
83def active_value():83def active_values():
84 return load_catalog().get('active')84 active = load_catalog().get('active')
85 if isinstance(active, str):
86 active = [active]
87 return [a for a in (active or []) if a]
88
89
90def values_key(values):
91 return '+'.join(sorted(values)) if values else ''
8592
8693
87def api(method, path, body=None):94def api(method, path, body=None):
@@ -106,11 +113,15 @@ def find_pod():
106 return None, None, None113 return None, None, None
107114
108115
109def create_pod(value):116def create_pod(values):
117 files = []
118 for value in values or []:
110 entry = catalog_entry(value)119 entry = catalog_entry(value)
111 env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': value or ''}120 if entry:
112 if entry and entry.get('files'):121 files.extend(entry.get('files') or [])
113 env['MODEL_MANIFEST'] = json.dumps(entry['files'])122 env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': values_key(values)}
123 if files:
124 env['MODEL_MANIFEST'] = json.dumps(files)
114 else:125 else:
115 env['MODELS'] = 'all' # legacy fallback when no catalog is configured126 env['MODELS'] = 'all' # legacy fallback when no catalog is configured
116 body = {127 body = {
@@ -127,7 +138,7 @@ def create_pod(value):
127 if DATACENTERS:138 if DATACENTERS:
128 body['dataCenterIds'] = DATACENTERS139 body['dataCenterIds'] = DATACENTERS
129 pod = api('POST', '/pods', body)140 pod = api('POST', '/pods', body)
130 log(f"pod created: {pod['id']} model={value or 'legacy-all'} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr")141 log(f"pod created: {pod['id']} models={values_key(values) or 'legacy-all'} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr")
131 return pod['id'], pod.get('machine', {}).get('gpuTypeId')142 return pod['id'], pod.get('machine', {}).get('gpuTypeId')
132143
133144
@@ -156,17 +167,18 @@ def terminate(pod_id):
156 log('terminate failed:', err)167 log('terminate failed:', err)
157168
158169
159def ensure_pod(value=None, wait=True):170def ensure_pod(values=None, wait=True):
160 """Ensures a pod provisioned for catalog entry `value` (None = active entry)."""171 """Ensures a pod provisioned for catalog values (None = active selection)."""
161 value = value or active_value()172 values = values or active_values()
173 key = values_key(values)
162 with state['lock']:174 with state['lock']:
163 pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu'])175 pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu'])
164 if not pod_id:176 if not pod_id:
165 pod_id, have, gpu = find_pod()177 pod_id, have, gpu = find_pod()
166 # Only recreate when the pod's model is KNOWN and different. An unknown178 # Only recreate when the pod's model set is KNOWN and different. An
167 # model (e.g. env not reported yet) must not churn pods.179 # unknown set (e.g. env not reported) must not churn pods.
168 if pod_id and value and have and have != value:180 if pod_id and key and have and have != key:
169 log(f'pod has model={have}, need {value} - recreating')181 log(f'pod has models={have}, need {key} - recreating')
170 terminate(pod_id)182 terminate(pod_id)
171 pod_id, have, gpu = None, None, None183 pod_id, have, gpu = None, None, None
172 if not pod_id:184 if not pod_id:
@@ -175,8 +187,8 @@ def ensure_pod(value=None, wait=True):
175 last_err = None187 last_err = None
176 for attempt in range(3):188 for attempt in range(3):
177 try:189 try:
178 pod_id, gpu = create_pod(value)190 pod_id, gpu = create_pod(values)
179 have = value191 have = key
180 break192 break
181 except urllib.error.HTTPError as err:193 except urllib.error.HTTPError as err:
182 last_err = err.read().decode()[:200]194 last_err = err.read().decode()[:200]
@@ -209,17 +221,18 @@ def ensure_pod(value=None, wait=True):
209 raise RuntimeError('pod not ready within START_TIMEOUT')221 raise RuntimeError('pod not ready within START_TIMEOUT')
210222
211223
212def match_workflow_model(workflow_bytes):224def match_workflow_models(workflow_bytes):
213 """Returns the catalog value whose filename appears in the workflow, else None."""225 """Returns all catalog values whose filenames appear in the workflow."""
214 try:226 try:
215 text = workflow_bytes.decode('utf-8', 'replace')227 text = workflow_bytes.decode('utf-8', 'replace')
216 except Exception:228 except Exception:
217 return None229 return []
230 found = []
218 for entry in load_catalog().get('models', []):231 for entry in load_catalog().get('models', []):
219 value = entry.get('value')232 value = entry.get('value')
220 if value and value in text:233 if value and value in text:
221 return value234 found.append(value)
222 return None235 return found
223236
224237
225def idle_reaper():238def idle_reaper():
@@ -251,7 +264,7 @@ def status_body():
251 'state': state['phase'],264 'state': state['phase'],
252 'pod_id': state['pod_id'],265 'pod_id': state['pod_id'],
253 'model': state['model'],266 'model': state['model'],
254 'active': active_value(),267 'active': active_values(),
255 'gpu': state['gpu'],268 'gpu': state['gpu'],
256 'since': state['since'],269 'since': state['since'],
257 'url': pod_url(state['pod_id']) if state['pod_id'] else None,270 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
@@ -304,13 +317,16 @@ class Proxy(BaseHTTPRequestHandler):
304 if path == '/lazy/catalog':317 if path == '/lazy/catalog':
305 try:318 try:
306 payload = json.loads(body or b'{}')319 payload = json.loads(body or b'{}')
307 catalog = {'models': payload.get('models', []), 'active': payload.get('active')}320 active = payload.get('active')
321 if isinstance(active, str):
322 active = [active]
323 catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]}
308 save_catalog(catalog)324 save_catalog(catalog)
309 log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}")325 log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}")
310 # Re-provision proactively when the active model changed under a live pod326 # Re-provision proactively when the active model changed under a live pod
311 # (known-model pods only - never churn a pod of unknown provenance).327 # (known-model pods only - never churn a pod of unknown provenance).
312 if state['pod_id'] and catalog['active'] and state['model'] and state['model'] != catalog['active']:328 if state['pod_id'] and catalog['active'] and state['model'] and state['model'] != values_key(catalog['active']):
313 log('active model changed - re-provisioning pod')329 log('active model set changed - re-provisioning pod')
314 threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start()330 threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start()
315 return self._reply(200, status_body())331 return self._reply(200, status_body())
316 except Exception as err:332 except Exception as err:
@@ -327,9 +343,9 @@ class Proxy(BaseHTTPRequestHandler):
327 except FileNotFoundError:343 except FileNotFoundError:
328 return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())344 return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())
329 if not ready or (self.command == 'POST' and path == '/prompt'):345 if not ready or (self.command == 'POST' and path == '/prompt'):
330 value = match_workflow_model(body) if (self.command == 'POST' and path == '/prompt' and body) else None346 values = match_workflow_models(body) if (self.command == 'POST' and path == '/prompt' and body) else None
331 try:347 try:
332 pod_id = ensure_pod(value)348 pod_id = ensure_pod(values or None)
333 except Exception as err:349 except Exception as err:
334 log('ensure_pod failed:', err)350 log('ensure_pod failed:', err)
335 return self._reply(502, json.dumps({'error': str(err)}).encode())351 return self._reply(502, json.dumps({'error': str(err)}).encode())
@@ -369,12 +385,12 @@ def main():
369385
370 def _adopt():386 def _adopt():
371 try:387 try:
372 ensure_pod(have)388 ensure_pod(have.split('+') if have else None)
373 except Exception as err:389 except Exception as err:
374 log('adoption wait failed:', err)390 log('adoption wait failed:', err)
375 threading.Thread(target=_adopt, daemon=True).start()391 threading.Thread(target=_adopt, daemon=True).start()
376 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)392 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
377 log(f'runpod-lazy v3 (catalog) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')393 log(f'runpod-lazy v4 (catalog+lora) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
378 server.serve_forever()394 server.serve_forever()
379395
380396
public/scripts/extensions/stable-diffusion/index.js+124 -12
@@ -376,9 +376,16 @@ const defaultSettings = {
376 // RunPod lazy-pod proxy base URL ('' = feature off)376 // RunPod lazy-pod proxy base URL ('' = feature off)
377 runpod_lazy_url: '',377 runpod_lazy_url: '',
378378
379 // RunPod model catalog ({ name, value, downloads } entries)379 // RunPod model catalog ({ name, value, kind: 'model'|'lora', downloads } entries)
380 runpod_models: [],380 runpod_models: [],
381381
382 // Selected LoRA for comfy workflows using the %lora% placeholder
383 lora: '',
384
385 // Per-workflow remembered selections ({ [workflow]: { model, lora } });
386 // part of preset snapshots, so each chain provider keeps its own map.
387 comfy_workflow_prefs: {},
388
382 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)389 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
383 prompt_generation_profile: '',390 prompt_generation_profile: '',
384391
@@ -721,6 +728,14 @@ async function loadSettings() {
721 extension_settings.sd.runpod_models = [];728 extension_settings.sd.runpod_models = [];
722 }729 }
723730
731 if (extension_settings.sd.lora === undefined) {
732 extension_settings.sd.lora = '';
733 }
734
735 if (typeof extension_settings.sd.comfy_workflow_prefs !== 'object' || !extension_settings.sd.comfy_workflow_prefs) {
736 extension_settings.sd.comfy_workflow_prefs = {};
737 }
738
724 if (!Array.isArray(extension_settings.sd.custom_entries)) {739 if (!Array.isArray(extension_settings.sd.custom_entries)) {
725 extension_settings.sd.custom_entries = [];740 extension_settings.sd.custom_entries = [];
726 }741 }
@@ -853,10 +868,48 @@ async function loadSettingOptions() {
853 loadModels(),868 loadModels(),
854 loadSchedulers(),869 loadSchedulers(),
855 loadVaes(),870 loadVaes(),
871 loadLoras(),
856 loadComfyWorkflows(),872 loadComfyWorkflows(),
857 ]);873 ]);
858}874}
859875
876async function loadLoras() {
877 $('#sd_lora').empty();
878 let loras = ['N/A'];
879 if (extension_settings.sd.source === sources.comfy && extension_settings.sd.comfy_type === comfyTypes.standard) {
880 loras = await loadComfyLoras();
881 }
882 for (const lora of loras) {
883 const option = document.createElement('option');
884 option.innerText = lora?.text ?? lora;
885 option.value = lora?.value ?? lora;
886 option.selected = option.value === extension_settings.sd.lora;
887 $('#sd_lora').append(option);
888 }
889}
890
891async function loadComfyLoras() {
892 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
893 return getRunpodCatalog().filter(m => m.kind === 'lora').map(m => ({ value: m.value, text: m.name || m.value }));
894 }
895 if (!extension_settings.sd.comfy_url) {
896 return [];
897 }
898 try {
899 const result = await fetch('/api/sd/comfy/loras', {
900 method: 'POST',
901 headers: getRequestHeaders(),
902 body: JSON.stringify({ url: extension_settings.sd.comfy_url }),
903 });
904 if (!result.ok) {
905 throw new Error('ComfyUI returned an error.');
906 }
907 return await result.json();
908 } catch (error) {
909 return [];
910 }
911}
912
860function addPromptTemplates() {913function addPromptTemplates() {
861 $('#sd_prompt_templates').empty();914 $('#sd_prompt_templates').empty();
862915
@@ -1623,6 +1676,11 @@ function getRunpodCatalog() {
1623 return models.filter(m => m && String(m.value ?? '').trim());1676 return models.filter(m => m && String(m.value ?? '').trim());
1624}1677}
16251678
1679/** Catalog entries that go in the Model dropdown (kind !== 'lora'). */
1680function getRunpodModelEntries() {
1681 return getRunpodCatalog().filter(m => m.kind !== 'lora');
1682}
1683
1626/**1684/**
1627 * Parses a catalog entry's download lines ("dest-subpath url" per line).1685 * Parses a catalog entry's download lines ("dest-subpath url" per line).
1628 * @param {string} text Multiline downloads definition.1686 * @param {string} text Multiline downloads definition.
@@ -1640,17 +1698,23 @@ function parseRunpodFiles(text) {
1640}1698}
16411699
1642/**1700/**
1643 * The model filename saved for whichever config uses the proxy: the live1701 * The model+lora filenames saved for whichever config uses the proxy: the live
1644 * settings if they point at it, else the chain entry that does.1702 * settings if they point at it, else the chain entry that does.
1645 * @returns {string|null} Active catalog value.1703 * @returns {string[]} Active catalog values (model and/or lora).
1646 */1704 */
1647function getRunpodActiveModel() {1705function getRunpodActiveModels() {
1706 let config = null;
1648 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {1707 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
1649 return extension_settings.sd.model || null;1708 config = extension_settings.sd;
1650 }1709 } else {
1651 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];1710 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
1652 const entry = chain.find(e => isRunpodProxyUrl(e?.preset?.comfy_url));1711 config = chain.find(e => isRunpodProxyUrl(e?.preset?.comfy_url))?.preset ?? null;
1653 return entry?.preset?.model ?? null;1712 }
1713 if (!config) {
1714 return [];
1715 }
1716 const known = new Set(getRunpodCatalog().map(m => m.value));
1717 return [config.model, config.lora].filter(v => v && known.has(v));
1654}1718}
16551719
1656/** Pushes the model catalog + active selection to the proxy (fire-and-forget). */1720/** Pushes the model catalog + active selection to the proxy (fire-and-forget). */
@@ -1662,6 +1726,7 @@ async function pushRunpodCatalog() {
1662 const models = getRunpodCatalog().map(m => ({1726 const models = getRunpodCatalog().map(m => ({
1663 name: m.name || m.value,1727 name: m.name || m.value,
1664 value: m.value,1728 value: m.value,
1729 kind: m.kind === 'lora' ? 'lora' : 'model',
1665 files: parseRunpodFiles(m.downloads),1730 files: parseRunpodFiles(m.downloads),
1666 }));1731 }));
1667 try {1732 try {
@@ -1669,7 +1734,7 @@ async function pushRunpodCatalog() {
1669 method: 'POST',1734 method: 'POST',
1670 headers: { 'Content-Type': 'application/json' },1735 headers: { 'Content-Type': 'application/json' },
1671 signal: AbortSignal.timeout(8000),1736 signal: AbortSignal.timeout(8000),
1672 body: JSON.stringify({ models, active: getRunpodActiveModel() }),1737 body: JSON.stringify({ models, active: getRunpodActiveModels() }),
1673 });1738 });
1674 } catch (error) {1739 } catch (error) {
1675 console.warn('SD: runpod catalog push failed', error);1740 console.warn('SD: runpod catalog push failed', error);
@@ -1694,9 +1759,14 @@ function renderRunpodModels() {
1694 const nameInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Display name' })1759 const nameInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Display name' })
1695 .val(model.name || '')1760 .val(model.name || '')
1696 .on('change', function () { model.name = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });1761 .on('change', function () { model.name = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1697 const valueInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Model filename (used by %model%)' })1762 const valueInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Filename (used by %model% / %lora%)' })
1698 .val(model.value || '')1763 .val(model.value || '')
1699 .on('change', function () { model.value = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });1764 .on('change', function () { model.value = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1765 const kindSelect = $('<select></select>').addClass('text_pole')
1766 .append($('<option></option>').val('model').text('model'))
1767 .append($('<option></option>').val('lora').text('lora'))
1768 .val(model.kind === 'lora' ? 'lora' : 'model')
1769 .on('change', function () { model.kind = String($(this).val()); saveSettingsDebounced(); pushRunpodCatalog(); });
1700 const deleteButton = $('<div></div>').addClass('menu_button menu_button_icon fa-solid fa-trash-can')1770 const deleteButton = $('<div></div>').addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1701 .attr('title', 'Remove model')1771 .attr('title', 'Remove model')
1702 .on('click', () => { models.splice(index, 1); saveSettingsDebounced(); renderRunpodModels(); pushRunpodCatalog(); });1772 .on('click', () => { models.splice(index, 1); saveSettingsDebounced(); renderRunpodModels(); pushRunpodCatalog(); });
@@ -1706,7 +1776,7 @@ function renderRunpodModels() {
1706 .on('change', function () { model.downloads = String($(this).val() ?? ''); saveSettingsDebounced(); pushRunpodCatalog(); });1776 .on('change', function () { model.downloads = String($(this).val() ?? ''); saveSettingsDebounced(); pushRunpodCatalog(); });
1707 container.append(1777 container.append(
1708 $('<div></div>').addClass('flexFlowColumn marginTopBot5')1778 $('<div></div>').addClass('flexFlowColumn marginTopBot5')
1709 .append($('<div></div>').addClass('flex-container alignItemsCenter').append(nameInput).append(valueInput).append(deleteButton))1779 .append($('<div></div>').addClass('flex-container alignItemsCenter').append(nameInput).append(valueInput).append(kindSelect).append(deleteButton))
1710 .append(downloadsInput));1780 .append(downloadsInput));
1711 });1781 });
1712}1782}
@@ -2406,8 +2476,47 @@ function onHFModelInput() {
2406 saveSettingsDebounced();2476 saveSettingsDebounced();
2407}2477}
24082478
2479/**
2480 * Stores the current model/lora choice for the active comfy workflow, so
2481 * switching back to this workflow restores it (per provider via presets).
2482 * @param {'model'|'lora'} key Which selection to remember.
2483 * @param {string} value Selected value.
2484 */
2485function rememberWorkflowPref(key, value) {
2486 if (extension_settings.sd.source !== sources.comfy || !extension_settings.sd.comfy_workflow) {
2487 return;
2488 }
2489 const prefs = extension_settings.sd.comfy_workflow_prefs;
2490 prefs[extension_settings.sd.comfy_workflow] = { ...(prefs[extension_settings.sd.comfy_workflow] ?? {}), [key]: value };
2491}
2492
2493async function onLoraChange() {
2494 extension_settings.sd.lora = $('#sd_lora').find(':selected').val();
2495 rememberWorkflowPref('lora', extension_settings.sd.lora);
2496 saveSettingsDebounced();
2497 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2498 pushRunpodCatalog();
2499 }
2500}
2501
2409function onComfyWorkflowChange() {2502function onComfyWorkflowChange() {
2410 extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val();2503 extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val();
2504 // Restore the model/lora remembered for this workflow (auto-configuration
2505 // when switching between e.g. the flux and qwen workflow families).
2506 const pref = extension_settings.sd.comfy_workflow_prefs?.[extension_settings.sd.comfy_workflow];
2507 if (pref) {
2508 if (pref.model) {
2509 extension_settings.sd.model = pref.model;
2510 $('#sd_model').val(pref.model);
2511 }
2512 if (pref.lora !== undefined) {
2513 extension_settings.sd.lora = pref.lora;
2514 $('#sd_lora').val(pref.lora);
2515 }
2516 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2517 pushRunpodCatalog();
2518 }
2519 }
2411 saveSettingsDebounced();2520 saveSettingsDebounced();
2412}2521}
24132522
@@ -2576,6 +2685,7 @@ async function validateComfyRunPodUrl() {
2576async function onModelChange() {2685async function onModelChange() {
2577 const selectedModel = $('#sd_model').find(':selected');2686 const selectedModel = $('#sd_model').find(':selected');
2578 extension_settings.sd.model = selectedModel.val();2687 extension_settings.sd.model = selectedModel.val();
2688 rememberWorkflowPref('model', extension_settings.sd.model);
2579 saveSettingsDebounced();2689 saveSettingsDebounced();
25802690
2581 // Selecting a catalog model while pointed at the RunPod proxy kicks off the2691 // Selecting a catalog model while pointed at the RunPod proxy kicks off the
@@ -3637,7 +3747,7 @@ async function loadComfyModels() {
3637 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {3747 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3638 // The RunPod pod downloads models on demand: list the configured catalog3748 // The RunPod pod downloads models on demand: list the configured catalog
3639 // instead of asking the (possibly cold) backend what it has on disk.3749 // instead of asking the (possibly cold) backend what it has on disk.
3640 return getRunpodCatalog().map(m => ({ value: m.value, text: m.name || m.value }));3750 return getRunpodModelEntries().map(m => ({ value: m.value, text: m.name || m.value }));
3641 }3751 }
3642 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {3752 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
3643 $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]);3753 $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]);
@@ -5587,6 +5697,7 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
5587 const placeholders = [5697 const placeholders = [
5588 'model',5698 'model',
5589 'vae',5699 'vae',
5700 'lora',
5590 'sampler',5701 'sampler',
5591 'scheduler',5702 'scheduler',
5592 'steps',5703 'steps',
@@ -7121,6 +7232,7 @@ export async function init() {
7121 $('#sd_scale').on('input', onScaleInput);7232 $('#sd_scale').on('input', onScaleInput);
7122 $('#sd_steps').on('input', onStepsInput);7233 $('#sd_steps').on('input', onStepsInput);
7123 $('#sd_model').on('change', onModelChange);7234 $('#sd_model').on('change', onModelChange);
7235 $('#sd_lora').on('change', onLoraChange);
7124 $('#sd_vae').on('change', onVaeChange);7236 $('#sd_vae').on('change', onVaeChange);
7125 $('#sd_sampler').on('change', onSamplerChange);7237 $('#sd_sampler').on('change', onSamplerChange);
7126 $('#sd_resolution').on('change', onResolutionChange);7238 $('#sd_resolution').on('change', onResolutionChange);
public/scripts/extensions/stable-diffusion/settings.html+5 -0
@@ -498,6 +498,11 @@
498 <label for="sd_vae">VAE</label>498 <label for="sd_vae">VAE</label>
499 <select id="sd_vae" class="text_pole"></select>499 <select id="sd_vae" class="text_pole"></select>
500 </div>500 </div>
501
502 <div class="flex1" data-sd-source="comfy">
503 <label for="sd_lora" data-i18n="LoRA">LoRA</label>
504 <select id="sd_lora" class="text_pole"></select>
505 </div>
501 </div>506 </div>
502507
503 <div class="flex-container">508 <div class="flex-container">
src/endpoints/stable-diffusion.js+19 -0
@@ -482,6 +482,25 @@ comfy.post('/vaes', async (request, response) => {
482 }482 }
483});483});
484484
485comfy.post('/loras', async (request, response) => {
486 try {
487 const url = new URL(urlJoin(request.body.url, '/object_info'));
488
489 const result = await fetch(url);
490 if (!result.ok) {
491 throw new Error('ComfyUI returned an error.');
492 }
493
494 /** @type {any} */
495 const data = await result.json();
496 const loras = data.LoraLoader?.input?.required?.lora_name?.[0] ?? [];
497 return response.send(loras);
498 } catch (error) {
499 console.error(error);
500 return response.sendStatus(500);
501 }
502});
503
485comfy.post('/workflows', async (request, response) => {504comfy.post('/workflows', async (request, response) => {
486 try {505 try {
487 const data = getComfyWorkflows(request.user.directories);506 const data = getComfyWorkflows(request.user.directories);