PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/editor/ai-models.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:01:19 UTC 12,762 bytes, 273 lines download raw bytes

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.