1/** 2 * AI model dropdown loader â fetches available model endpoints from 3 * the backend and populates the editor's three model-select surfaces: 4 * 5 * #ge-ai-model â global Gen picker 6 * #ge-ai-inpaint â inpaint picker 7 * select.ge-tool-model[data-ge-tool-model="â¦"] 8 * â per-tool pickers (harmonize / upscale / style / 9 * sharpen / etc.) 10 * 11 * Each model is filtered through a small capability classifier so the 12 * Gen dropdown only sees text-to-image models, the inpaint dropdown 13 * only sees image+mask edit models, and the per-tool dropdowns get 14 * everything img2img-capable. 15 * 16 * Every picker ends with a "+ Serve a model in Cookbookâ¦" sentinel â 17 * choosing it opens Cookbook â Serve filtered to image models, then 18 * reverts the picker to its prior value (so it's an action, not a 19 * selectable model). 20 * 21 * @param {{ 22 * container: HTMLElement, 23 * apiBase: string, 24 * openCookbookForImg2img: () => void, 25 * }} deps 26 */ 27import { state } from './state.js'; 28import { sortModelIds } from '../modelSort.js'; 29 30// Heuristic classifier on a model id + endpoint name. A model can be: 31// - gen: text-to-image generation 32// - inpaint: image+mask edit (inpaint / img2img) 33// Some models do only one (e.g. dall-e-3 = gen-only, no edits API). 34function modelCaps(modelId, endpointName, endpointType) { 35 const id = (modelId || '').toLowerCase(); 36 const name = (endpointName || '').toLowerCase(); 37 const type = (endpointType || '').toLowerCase(); 38 // Reject anything obviously text-only. 39 const textOnly = /(?:^|[/\-_:])(gpt-?[345]|gpt-oss|claude|llama|qwen[^-]*chat|chat$|instruct$|coder)/i; 40 if (textOnly.test(id) && !/image/i.test(id)) return { gen: false, inpaint: false }; 41 // OpenAI image family. 42 if (/dall-e-3/.test(id)) return { gen: true, inpaint: false }; 43 if (/dall-e-2/.test(id)) return { gen: true, inpaint: true }; 44 if (/gpt-image/.test(id)) return { gen: true, inpaint: true }; 45 // Diffusion families â most generic SD/SDXL/Flux base models 46 // support both via diffusers. 47 if (/(?:^|[/\-_])(?:sd-?xl|sdxl|sd3|sd-|stable[\s-]*diffusion|flux|playground|pixart|kandinsky)/i.test(id)) { 48 const isInpaintModel = /inpaint|edit|fill/i.test(id) || /inpaint|edit|fill/i.test(name); 49 return { gen: !isInpaintModel || /base/i.test(id), inpaint: true }; 50 } 51 // Self-hosted diffusion server: model id often matches the repo 52 // name; trust the endpoint name hint. 53 if (type === 'image') { 54 if (/inpaint|edit|fill/i.test(name)) return { gen: false, inpaint: true }; 55 return { gen: true, inpaint: true }; 56 } 57 if (/inpaint|edit|fill/i.test(name)) return { gen: false, inpaint: true }; 58 if (/diffus|flux|sd|image/i.test(name)) return { gen: true, inpaint: true }; 59 // Editor image tools should be conservative. Unknown LLM/chat models 60 // do not belong in image generation or inpaint pickers. 61 return { gen: false, inpaint: false }; 62} 63 64export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) { 65 // Delegated handler for the "+ Serve a model in Cookbookâ¦" sentinel 66 // option â catches clicks regardless of whether loadAIModels has 67 // rewired the individual select yet and survives any innerHTML 68 // reset later. 69 container.addEventListener('change', (e) => { 70 const sel = e.target.closest('select'); 71 if (!sel) return; 72 if (sel.value !== '__serve_cookbook__') return; 73 // Revert to the previous selection so the sentinel isn't "stuck". 74 const prev = sel._prevServeValue ?? ''; 75 sel.value = prev; 76 openCookbookForImg2img(); 77 }); 78 // Track prior value so we can restore it after the sentinel fires. 79 container.addEventListener('focus', (e) => { 80 const sel = e.target.closest('select'); 81 if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value; 82 }, true); 83 84 const aiGenSelect = document.getElementById('ge-ai-model'); 85 const aiInpaintSelect = document.getElementById('ge-ai-inpaint'); 86 // The global Gen model dropdown was removed from the editor topbar; 87 // only bail if there's nothing to populate at all (neither the Gen 88 // select nor the inpaint select nor any per-tool select). 89 if (!aiGenSelect && !aiInpaintSelect && 90 !document.querySelector('select.ge-tool-model')) return; 91 92 async function loadAIModels(opts = {}) { 93 try { 94 const selectBaseUrl = opts.selectBaseUrl || ''; 95 const prevGenValue = aiGenSelect?.value || ''; 96 const prevInpaintValue = aiInpaintSelect?.value || ''; 97 const res = await fetch(`${apiBase}/api/model-endpoints`); 98 const endpoints = await res.json(); 99 if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>'; 100 if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>'; 101 const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model')); 102 for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>'; 103 let firstGen = null; 104 let firstInpaint = null; 105 let selectedGen = null; 106 let selectedInpaint = null; 107 for (const ep of endpoints) { 108 if (!ep.is_enabled) continue; 109 const hasListedModels = Array.isArray(ep.models) && ep.models.length; 110 const models = hasListedModels ? sortModelIds(ep.models) : ['']; 111 const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image'; 112 // Image/inpaint endpoints can be called by URL even when their 113 // /models cache is still empty, so don't strand a freshly served 114 // Cookbook model as "(offline)" in the editor picker. 115 const epUsable = !!ep.online || isImageEndpoint; 116 for (const modelId of models) { 117 const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type); 118 if (!caps.gen && !caps.inpaint) continue; 119 // Encode "<base_url>::<model_id>" so the value carries both pieces. 120 const value = `${ep.base_url}::${modelId}`; 121 const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url); 122 const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : ''; 123 const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`; 124 if (caps.gen && aiGenSelect) { 125 const opt = document.createElement('option'); 126 opt.value = value; 127 opt.textContent = label; 128 opt.disabled = !epUsable; 129 aiGenSelect.appendChild(opt); 130 if (epUsable && !firstGen) firstGen = value; 131 if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value; 132 } 133 if (caps.inpaint && aiInpaintSelect) { 134 const opt = document.createElement('option'); 135 opt.value = value; 136 opt.textContent = label; 137 opt.disabled = !epUsable; 138 aiInpaintSelect.appendChild(opt); 139 if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value; 140 // Prefer dedicated inpaint/edit models for default selection. 141 if (epUsable && !firstInpaint && (!modelId || /inpaint|edit|fill|gpt-image/i.test(modelId) || /inpaint|edit|fill/i.test(ep.name || ''))) { 142 firstInpaint = value; 143 } 144 } 145 // Per-tool selectors get every img2img-capable entry. Both 146 // caps.inpaint AND caps.gen models work for harmonize /
147 // style / upscale (anything that can do img2img). 148 if (caps.inpaint || caps.gen) { 149 for (const ts of perToolSelects) { 150 const opt = document.createElement('option'); 151 opt.value = value; 152 opt.textContent = label; 153 opt.disabled = !epUsable; 154 ts.appendChild(opt); 155 } 156 } 157 } 158 } 159 const hasValue = (sel, value) => !!value && [...sel.options].some(o => o.value === value); 160 if (aiGenSelect) { 161 if (selectedGen) aiGenSelect.value = selectedGen; 162 else if (hasValue(aiGenSelect, prevGenValue)) aiGenSelect.value = prevGenValue; 163 else if (firstGen) aiGenSelect.value = firstGen; 164 } 165 if (aiInpaintSelect) { 166 if (selectedInpaint) aiInpaintSelect.value = selectedInpaint; 167 else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue; 168 else if (firstInpaint) aiInpaintSelect.value = firstInpaint; 169 } 170 // Append the "Serve a model in Cookbookâ¦" sentinel at the 171 // bottom of every model dropdown. 172 const appendServeSentinel = (sel) => { 173 const sep = document.createElement('option'); 174 sep.disabled = true; 175 sep.textContent = 'ââââââââââ'; 176 sel.appendChild(sep); 177 const serveOpt = document.createElement('option'); 178 serveOpt.value = '__serve_cookbook__'; 179 serveOpt.textContent = '+ Serve a model in Cookbookâ¦'; 180 sel.appendChild(serveOpt); 181 }; 182 for (const ts of perToolSelects) appendServeSentinel(ts); 183 if (aiGenSelect) appendServeSentinel(aiGenSelect); 184 if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect); 185 // Wire the sentinel on the Gen + Inpaint selects too. 186 const wireServeSentinel = (sel) => { 187 if (!sel) return; 188 let prev = sel.value; 189 sel.addEventListener('change', () => { 190 if (sel.value === '__serve_cookbook__') { 191 sel.value = prev; 192 openCookbookForImg2img(); 193 return; 194 } 195 prev = sel.value; 196 }); 197 }; 198 wireServeSentinel(aiGenSelect); 199 wireServeSentinel(aiInpaintSelect); 200 // Restore each per-tool selection from localStorage. 201 for (const ts of perToolSelects) { 202 const key = 'ge-tool-model-' + ts.dataset.geToolModel; 203 try { 204 const saved = localStorage.getItem(key); 205 if (saved && [...ts.options].some(o => o.value === saved)) { 206 ts.value = saved; 207 } 208 } catch {} 209 let prevValue = ts.value; 210 ts.addEventListener('change', () => { 211 if (ts.value === '__serve_cookbook__') { 212 ts.value = prevValue; 213 openCookbookForImg2img(); 214 return; 215 } 216 prevValue = ts.value; 217 try { localStorage.setItem(key, ts.value); } catch {} 218 }); 219 } 220 } catch (e) { 221 // Fetch failed â still give the user the affordance to set up 222 // a model. Otherwise the dropdown shows only "Auto" with no 223 // hint about what to do next. 224 const fallback = '<option value="">Auto</option><option value="" disabled>ââââââââââ</option><option value="__serve_cookbook__">+ Serve a model in Cookbookâ¦</option>'; 225 if (aiGenSelect) aiGenSelect.innerHTML = fallback; 226 if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback; 227 document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; }); 228 const wireServe = (sel) => { 229 if (!sel) return; 230 let prev = sel.value; 231 sel.addEventListener('change', () => { 232 if (sel.value === '__serve_cookbook__') { 233 sel.value = prev; 234 openCookbookForImg2img(); 235 return; 236 } 237 prev = sel.value; 238 }); 239 }; 240 wireServe(aiGenSelect); 241 wireServe(aiInpaintSelect); 242 document.querySelectorAll('select.ge-tool-model').forEach(wireServe); 243 } 244 } 245 loadAIModels(); 246 const onModelEndpointsUpdated = (e) => { 247 if (!container.isConnected) { 248 window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); 249 return; 250 } 251 loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' }); 252 }; 253 window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); 254 // Re-fetch the model list when the user opens the inpaint dropdown, 255 // so a model served via Cookbook mid-edit shows up without having to 256 // close and reopen the editor. Debounced to one refresh per 3s so 257 // rapid open/close doesn't hammer the endpoint. Preserves the 258 // current selection across the reload. 259 let _lastModelRefresh = 0; 260 const refreshOnOpen = (e) => { 261 const sel = e.target.closest('#ge-ai-inpaint, select.ge-tool-model'); 262 if (!sel) return; 263 const now = Date.now(); 264 if (now - _lastModelRefresh < 3000) return; 265 _lastModelRefresh = now;
266 const keep = sel.value; 267 loadAIModels().then(() => { 268 // Restore the prior selection if it still exists. 269 if ([...sel.options].some(o => o.value === keep)) sel.value = keep; 270 }); 271 }; 272 container.addEventListener('mousedown', refreshOnOpen, true); 273}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.