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 | return None | 80 | return None |
| 81 | 81 | ||
| 82 | 82 | ||
| 83 | def active_value(): | 83 | def 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 | |||
| 90 | def values_key(values): | ||
| 91 | return '+'.join(sorted(values)) if values else '' | ||
| 85 | 92 | ||
| 86 | 93 | ||
| 87 | def api(method, path, body=None): | 94 | def api(method, path, body=None): |
| @@ -106,11 +113,15 @@ def find_pod(): | |||
| 106 | return None, None, None | 113 | return None, None, None |
| 107 | 114 | ||
| 108 | 115 | ||
| 109 | def create_pod(value): | 116 | def 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 configured | 126 | 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'] = DATACENTERS | 139 | 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') |
| 132 | 143 | ||
| 133 | 144 | ||
| @@ -156,17 +167,18 @@ def terminate(pod_id): | |||
| 156 | log('terminate failed:', err) | 167 | log('terminate failed:', err) |
| 157 | 168 | ||
| 158 | 169 | ||
| 159 | def ensure_pod(value=None, wait=True): | 170 | def 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 unknown | 178 | # 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, None | 183 | 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 = None | 187 | 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 = value | 191 | have = key |
| 180 | break | 192 | 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') |
| 210 | 222 | ||
| 211 | 223 | ||
| 212 | def match_workflow_model(workflow_bytes): | 224 | def 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 None | 229 | 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 value | 234 | found.append(value) |
| 222 | return None | 235 | return found |
| 223 | 236 | ||
| 224 | 237 | ||
| 225 | def idle_reaper(): | 238 | def 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 pod | 326 | # 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 None | 346 | 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(): | |||
| 369 | 385 | ||
| 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() |
| 379 | 395 | ||
| 380 | 396 | ||
| @@ -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: '', |
| 378 | 378 | ||
| 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: [], |
| 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 | // 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: '', |
| 384 | 391 | ||
| @@ -721,6 +728,14 @@ async function loadSettings() { | |||
| 721 | extension_settings.sd.runpod_models = []; | 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 | 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 | } |
| 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 | function addPromptTemplates() { | 913 | function addPromptTemplates() { |
| 861 | $('#sd_prompt_templates').empty(); | 914 | $('#sd_prompt_templates').empty(); |
| 862 | 915 | ||
| @@ -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 | } |
| 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 | * 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 | } |
| 1641 | 1699 | ||
| 1642 | /** | 1700 | /** |
| 1643 | * The model filename saved for whichever config uses the proxy: the live | 1701 | * 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 | */ |
| 1647 | function getRunpodActiveModel() { | 1705 | function 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 | } |
| 1655 | 1719 | ||
| 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 | } |
| 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 | function onComfyWorkflowChange() { | 2502 | function 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 | } |
| 2413 | 2522 | ||
| @@ -2576,6 +2685,7 @@ async function validateComfyRunPodUrl() { | |||
| 2576 | async function onModelChange() { | 2685 | async 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(); |
| 2580 | 2690 | ||
| 2581 | // Selecting a catalog model while pointed at the RunPod proxy kicks off the | 2691 | // 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 catalog | 3748 | // 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); |
| @@ -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> |
| 502 | 507 | ||
| 503 | <div class="flex-container"> | 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 | comfy.post('/workflows', async (request, response) => { | 504 | comfy.post('/workflows', async (request, response) => { |
| 486 | try { | 505 | try { |
| 487 | const data = getComfyWorkflows(request.user.directories); | 506 | const data = getComfyWorkflows(request.user.directories); |