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.
| @@ -80,8 +80,15 @@ def catalog_entry(value): | ||
| 80 | 80 | return None |
| 81 | 81 | |
| 82 | 82 | |
| 83 | 83 | def active_valueactive_values(): |
| 84 | 84 | returnactive = load_catalog().get('active') |
| 85 | + if isinstance(active, str): | |
| 86 | + active = [active] | |
| 87 | + return [a for a in (active or []) if a] | |
| 88 | + | |
| 89 | + | |
| 90 | +def values_key(values): | |
| 91 | + return '+'.join(sorted(values)) if values else '' | |
| 85 | 92 | |
| 86 | 93 | |
| 87 | 94 | def api(method, path, body=None): |
| @@ -106,11 +113,15 @@ def find_pod(): | ||
| 106 | 113 | return None, None, None |
| 107 | 114 | |
| 108 | 115 | |
| 109 | 116 | def create_pod(valuevalues): |
| 110 | 117 | entryfiles = catalog_entry(value)[] |
| 111 | - env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': value or ''} | |
| 118 | + for value in values or []: | |
| 112 | - if entry and entry.get('files'): | |
| 119 | + entry = catalog_entry(value) | |
| 113 | - env['MODEL_MANIFEST'] = json.dumps(entry['files']) | |
| 120 | + if entry: | |
| 121 | + files.extend(entry.get('files') or []) | |
| 122 | + env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': values_key(values)} | |
| 123 | + if files: | |
| 124 | + env['MODEL_MANIFEST'] = json.dumps(files) | |
| 114 | 125 | else: |
| 115 | 126 | env['MODELS'] = 'all' # legacy fallback when no catalog is configured |
| 116 | 127 | body = { |
| @@ -127,7 +138,7 @@ def create_pod(value): | ||
| 127 | 138 | if DATACENTERS: |
| 128 | 139 | body['dataCenterIds'] = DATACENTERS |
| 129 | 140 | pod = api('POST', '/pods', body) |
| 130 | 141 | log(f"pod created: {pod['id']} modelmodels={valuevalues_key(values) or 'legacy-all'} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr") |
| 131 | 142 | return pod['id'], pod.get('machine', {}).get('gpuTypeId') |
| 132 | 143 | |
| 133 | 144 | |
| @@ -156,17 +167,18 @@ def terminate(pod_id): | ||
| 156 | 167 | log('terminate failed:', err) |
| 157 | 168 | |
| 158 | 169 | |
| 159 | 170 | def ensure_pod(valuevalues=None, wait=True): |
| 160 | 171 | """Ensures a pod provisioned for catalog entry `value`values (None = active entryselection).""" |
| 161 | 172 | valuevalues = valuevalues or active_valueactive_values() |
| 173 | + key = values_key(values) | |
| 162 | 174 | with state['lock']: |
| 163 | 175 | pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu']) |
| 164 | 176 | if not pod_id: |
| 165 | 177 | pod_id, have, gpu = find_pod() |
| 166 | 178 | # Only recreate when the pod's model set is KNOWN and different. An unknown |
| 167 | 179 | # modelunknown set (e.g. env not reported yet) must not churn pods. |
| 168 | 180 | if pod_id and valuekey and have and have != valuekey: |
| 169 | 181 | log(f'pod has modelmodels={have}, need {valuekey} - recreating') |
| 170 | 182 | terminate(pod_id) |
| 171 | 183 | pod_id, have, gpu = None, None, None |
| 172 | 184 | if not pod_id: |
| @@ -175,8 +187,8 @@ def ensure_pod(value=None, wait=True): | ||
| 175 | 187 | last_err = None |
| 176 | 188 | for attempt in range(3): |
| 177 | 189 | try: |
| 178 | 190 | pod_id, gpu = create_pod(valuevalues) |
| 179 | 191 | have = valuekey |
| 180 | 192 | break |
| 181 | 193 | except urllib.error.HTTPError as err: |
| 182 | 194 | last_err = err.read().decode()[:200] |
| @@ -209,17 +221,18 @@ def ensure_pod(value=None, wait=True): | ||
| 209 | 221 | raise RuntimeError('pod not ready within START_TIMEOUT') |
| 210 | 222 | |
| 211 | 223 | |
| 212 | 224 | def match_workflow_modelmatch_workflow_models(workflow_bytes): |
| 213 | 225 | """Returns theall catalog valuevalues whose filenamefilenames appearsappear in the workflow, else None.""" |
| 214 | 226 | try: |
| 215 | 227 | text = workflow_bytes.decode('utf-8', 'replace') |
| 216 | 228 | except Exception: |
| 217 | 229 | return None[] |
| 230 | + found = [] | |
| 218 | 231 | for entry in load_catalog().get('models', []): |
| 219 | 232 | value = entry.get('value') |
| 220 | 233 | if value and value in text: |
| 221 | - return value | |
| 234 | + found.append(value) | |
| 222 | 235 | return Nonefound |
| 223 | 236 | |
| 224 | 237 | |
| 225 | 238 | def idle_reaper(): |
| @@ -251,7 +264,7 @@ def status_body(): | ||
| 251 | 264 | 'state': state['phase'], |
| 252 | 265 | 'pod_id': state['pod_id'], |
| 253 | 266 | 'model': state['model'], |
| 254 | 267 | 'active': active_valueactive_values(), |
| 255 | 268 | 'gpu': state['gpu'], |
| 256 | 269 | 'since': state['since'], |
| 257 | 270 | 'url': pod_url(state['pod_id']) if state['pod_id'] else None, |
| @@ -304,13 +317,16 @@ class Proxy(BaseHTTPRequestHandler): | ||
| 304 | 317 | if path == '/lazy/catalog': |
| 305 | 318 | try: |
| 306 | 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 | 324 | save_catalog(catalog) |
| 309 | 325 | log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}") |
| 310 | 326 | # Re-provision proactively when the active model changed under a live pod |
| 311 | 327 | # (known-model pods only - never churn a pod of unknown provenance). |
| 312 | 328 | if state['pod_id'] and catalog['active'] and state['model'] and state['model'] != values_key(catalog['active']): |
| 313 | 329 | log('active model set changed - re-provisioning pod') |
| 314 | 330 | threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start() |
| 315 | 331 | return self._reply(200, status_body()) |
| 316 | 332 | except Exception as err: |
| @@ -327,9 +343,9 @@ class Proxy(BaseHTTPRequestHandler): | ||
| 327 | 343 | except FileNotFoundError: |
| 328 | 344 | return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode()) |
| 329 | 345 | if not ready or (self.command == 'POST' and path == '/prompt'): |
| 330 | 346 | valuevalues = match_workflow_modelmatch_workflow_models(body) if (self.command == 'POST' and path == '/prompt' and body) else None |
| 331 | 347 | try: |
| 332 | 348 | pod_id = ensure_pod(valuevalues or None) |
| 333 | 349 | except Exception as err: |
| 334 | 350 | log('ensure_pod failed:', err) |
| 335 | 351 | return self._reply(502, json.dumps({'error': str(err)}).encode()) |
| @@ -369,12 +385,12 @@ def main(): | ||
| 369 | 385 | |
| 370 | 386 | def _adopt(): |
| 371 | 387 | try: |
| 372 | - ensure_pod(have) | |
| 388 | + ensure_pod(have.split('+') if have else None) | |
| 373 | 389 | except Exception as err: |
| 374 | 390 | log('adoption wait failed:', err) |
| 375 | 391 | threading.Thread(target=_adopt, daemon=True).start() |
| 376 | 392 | server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy) |
| 377 | 393 | log(f'runpod-lazy v3v4 (catalog+lora) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)') |
| 378 | 394 | server.serve_forever() |
| 379 | 395 | |
| 380 | 396 | |
| @@ -376,9 +376,16 @@ const defaultSettings = { | ||
| 376 | 376 | // RunPod lazy-pod proxy base URL ('' = feature off) |
| 377 | 377 | runpod_lazy_url: '', |
| 378 | 378 | |
| 379 | 379 | // RunPod model catalog ({ name, value, kind: 'model'|'lora', downloads } entries) |
| 380 | 380 | runpod_models: [], |
| 381 | 381 | |
| 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 | 389 | // Dedicated LLM connection profile for image-prompt generation ('' = use active model) |
| 383 | 390 | prompt_generation_profile: '', |
| 384 | 391 | |
| @@ -721,6 +728,14 @@ async function loadSettings() { | ||
| 721 | 728 | extension_settings.sd.runpod_models = []; |
| 722 | 729 | } |
| 723 | 730 | |
| 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 | 739 | if (!Array.isArray(extension_settings.sd.custom_entries)) { |
| 725 | 740 | extension_settings.sd.custom_entries = []; |
| 726 | 741 | } |
| @@ -853,10 +868,48 @@ async function loadSettingOptions() { | ||
| 853 | 868 | loadModels(), |
| 854 | 869 | loadSchedulers(), |
| 855 | 870 | loadVaes(), |
| 871 | + loadLoras(), | |
| 856 | 872 | loadComfyWorkflows(), |
| 857 | 873 | ]); |
| 858 | 874 | } |
| 859 | 875 | |
| 876 | +async 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 | + | |
| 891 | +async 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 | + | |
| 860 | 913 | function addPromptTemplates() { |
| 861 | 914 | $('#sd_prompt_templates').empty(); |
| 862 | 915 | |
| @@ -1623,6 +1676,11 @@ function getRunpodCatalog() { | ||
| 1623 | 1676 | return models.filter(m => m && String(m.value ?? '').trim()); |
| 1624 | 1677 | } |
| 1625 | 1678 | |
| 1679 | +/** Catalog entries that go in the Model dropdown (kind !== 'lora'). */ | |
| 1680 | +function getRunpodModelEntries() { | |
| 1681 | + return getRunpodCatalog().filter(m => m.kind !== 'lora'); | |
| 1682 | +} | |
| 1683 | + | |
| 1626 | 1684 | /** |
| 1627 | 1685 | * Parses a catalog entry's download lines ("dest-subpath url" per line). |
| 1628 | 1686 | * @param {string} text Multiline downloads definition. |
| @@ -1640,17 +1698,23 @@ function parseRunpodFiles(text) { | ||
| 1640 | 1698 | } |
| 1641 | 1699 | |
| 1642 | 1700 | /** |
| 1643 | 1701 | * The model+lora filenamefilenames saved for whichever config uses the proxy: the live |
| 1644 | 1702 | * settings if they point at it, else the chain entry that does. |
| 1645 | 1703 | * @returns {string|null[]} Active catalog valuevalues (model and/or lora). |
| 1646 | 1704 | */ |
| 1647 | 1705 | function getRunpodActiveModelgetRunpodActiveModels() { |
| 1706 | + let config = null; | |
| 1648 | 1707 | if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) { |
| 1649 | 1708 | returnconfig = extension_settings.sd.model || null; |
| 1709 | + } else { | |
| 1710 | + const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : []; | |
| 1711 | + config = chain.find(e => isRunpodProxyUrl(e?.preset?.comfy_url))?.preset ?? null; | |
| 1650 | 1712 | } |
| 1651 | - const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : []; | |
| 1713 | + if (!config) { | |
| 1652 | - const entry = chain.find(e => isRunpodProxyUrl(e?.preset?.comfy_url)); | |
| 1714 | + return []; | |
| 1653 | - return entry?.preset?.model ?? null; | |
| 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 | } |
| 1655 | 1719 | |
| 1656 | 1720 | /** Pushes the model catalog + active selection to the proxy (fire-and-forget). */ |
| @@ -1662,6 +1726,7 @@ async function pushRunpodCatalog() { | ||
| 1662 | 1726 | const models = getRunpodCatalog().map(m => ({ |
| 1663 | 1727 | name: m.name || m.value, |
| 1664 | 1728 | value: m.value, |
| 1729 | + kind: m.kind === 'lora' ? 'lora' : 'model', | |
| 1665 | 1730 | files: parseRunpodFiles(m.downloads), |
| 1666 | 1731 | })); |
| 1667 | 1732 | try { |
| @@ -1669,7 +1734,7 @@ async function pushRunpodCatalog() { | ||
| 1669 | 1734 | method: 'POST', |
| 1670 | 1735 | headers: { 'Content-Type': 'application/json' }, |
| 1671 | 1736 | signal: AbortSignal.timeout(8000), |
| 1672 | 1737 | body: JSON.stringify({ models, active: getRunpodActiveModelgetRunpodActiveModels() }), |
| 1673 | 1738 | }); |
| 1674 | 1739 | } catch (error) { |
| 1675 | 1740 | console.warn('SD: runpod catalog push failed', error); |
| @@ -1694,9 +1759,14 @@ function renderRunpodModels() { | ||
| 1694 | 1759 | const nameInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Display name' }) |
| 1695 | 1760 | .val(model.name || '') |
| 1696 | 1761 | .on('change', function () { model.name = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); }); |
| 1697 | 1762 | const valueInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Model filenameFilename (used by %model% / %lora%)' }) |
| 1698 | 1763 | .val(model.value || '') |
| 1699 | 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 | 1770 | const deleteButton = $('<div></div>').addClass('menu_button menu_button_icon fa-solid fa-trash-can') |
| 1701 | 1771 | .attr('title', 'Remove model') |
| 1702 | 1772 | .on('click', () => { models.splice(index, 1); saveSettingsDebounced(); renderRunpodModels(); pushRunpodCatalog(); }); |
| @@ -1706,7 +1776,7 @@ function renderRunpodModels() { | ||
| 1706 | 1776 | .on('change', function () { model.downloads = String($(this).val() ?? ''); saveSettingsDebounced(); pushRunpodCatalog(); }); |
| 1707 | 1777 | container.append( |
| 1708 | 1778 | $('<div></div>').addClass('flexFlowColumn marginTopBot5') |
| 1709 | 1779 | .append($('<div></div>').addClass('flex-container alignItemsCenter').append(nameInput).append(valueInput).append(kindSelect).append(deleteButton)) |
| 1710 | 1780 | .append(downloadsInput)); |
| 1711 | 1781 | }); |
| 1712 | 1782 | } |
| @@ -2406,8 +2476,47 @@ function onHFModelInput() { | ||
| 2406 | 2476 | saveSettingsDebounced(); |
| 2407 | 2477 | } |
| 2408 | 2478 | |
| 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 | + */ | |
| 2485 | +function 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 | + | |
| 2493 | +async 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 | + | |
| 2409 | 2502 | function onComfyWorkflowChange() { |
| 2410 | 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 | 2520 | saveSettingsDebounced(); |
| 2412 | 2521 | } |
| 2413 | 2522 | |
| @@ -2576,6 +2685,7 @@ async function validateComfyRunPodUrl() { | ||
| 2576 | 2685 | async function onModelChange() { |
| 2577 | 2686 | const selectedModel = $('#sd_model').find(':selected'); |
| 2578 | 2687 | extension_settings.sd.model = selectedModel.val(); |
| 2688 | + rememberWorkflowPref('model', extension_settings.sd.model); | |
| 2579 | 2689 | saveSettingsDebounced(); |
| 2580 | 2690 | |
| 2581 | 2691 | // Selecting a catalog model while pointed at the RunPod proxy kicks off the |
| @@ -3637,7 +3747,7 @@ async function loadComfyModels() { | ||
| 3637 | 3747 | if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) { |
| 3638 | 3748 | // The RunPod pod downloads models on demand: list the configured catalog |
| 3639 | 3749 | // instead of asking the (possibly cold) backend what it has on disk. |
| 3640 | 3750 | return getRunpodCataloggetRunpodModelEntries().map(m => ({ value: m.value, text: m.name || m.value })); |
| 3641 | 3751 | } |
| 3642 | 3752 | if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) { |
| 3643 | 3753 | $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]); |
| @@ -5587,6 +5697,7 @@ async function generateComfyImage(prompt, negativePrompt, signal) { | ||
| 5587 | 5697 | const placeholders = [ |
| 5588 | 5698 | 'model', |
| 5589 | 5699 | 'vae', |
| 5700 | + 'lora', | |
| 5590 | 5701 | 'sampler', |
| 5591 | 5702 | 'scheduler', |
| 5592 | 5703 | 'steps', |
| @@ -7121,6 +7232,7 @@ export async function init() { | ||
| 7121 | 7232 | $('#sd_scale').on('input', onScaleInput); |
| 7122 | 7233 | $('#sd_steps').on('input', onStepsInput); |
| 7123 | 7234 | $('#sd_model').on('change', onModelChange); |
| 7235 | + $('#sd_lora').on('change', onLoraChange); | |
| 7124 | 7236 | $('#sd_vae').on('change', onVaeChange); |
| 7125 | 7237 | $('#sd_sampler').on('change', onSamplerChange); |
| 7126 | 7238 | $('#sd_resolution').on('change', onResolutionChange); |
| @@ -498,6 +498,11 @@ | ||
| 498 | 498 | <label for="sd_vae">VAE</label> |
| 499 | 499 | <select id="sd_vae" class="text_pole"></select> |
| 500 | 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 | 506 | </div> |
| 502 | 507 | |
| 503 | 508 | <div class="flex-container"> |
| @@ -482,6 +482,25 @@ comfy.post('/vaes', async (request, response) => { | ||
| 482 | 482 | } |
| 483 | 483 | }); |
| 484 | 484 | |
| 485 | +comfy.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 | + | |
| 485 | 504 | comfy.post('/workflows', async (request, response) => { |
| 486 | 505 | try { |
| 487 | 506 | const data = getComfyWorkflows(request.user.directories); |