PageSourceSearch

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

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:10 UTC 24,344 bytes, 642 lines download raw bytes

1// static/js/models.js
2
3/**
4 * Model and provider management
5 */
6
7import Storage from './storage.js';
8import uiModule from './ui.js';
9import sessionModule from './sessions.js';
10import dragSortModule from './dragSort.js';
11import spinnerModule from './spinner.js';
12import { modelColor } from './chatRenderer.js';
13import { providerLogo } from './providers.js';
14import { sortModelIds } from './modelSort.js';
15
16let API_BASE = '';
17let _cachedItems = []; // cached /api/models items for model-switch dropdown
18let _lastFetchTime = 0;
19let _fetchInflight = null;
20const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
21const COLLAPSE_KEY = 'odysseus-models-collapsed';
22const FAVORITES_KEY = 'odysseus-model-favorites';
23const USAGE_KEY = 'odysseus-model-usage';
24const SORT_KEY = 'odysseus-model-sort';
25
26export function init(apiBase) {
27  API_BASE = apiBase;
28}
29
30// ── Collapse state persistence ──
31function _loadCollapsed() {
32  return Storage.getJSON(COLLAPSE_KEY, {});
33}
34function _saveCollapsed(state) {
35  Storage.setJSON(COLLAPSE_KEY, state);
36}
37
38// ── Favorites persistence ──
39function _loadFavorites() {
40  return Storage.getJSON(FAVORITES_KEY, []);
41}
42function _saveFavorites(list) {
43  Storage.setJSON(FAVORITES_KEY, list);
44}
45function _isFavorite(mid) {
46  return _loadFavorites().includes(mid);
47}
48function _toggleFavorite(mid) {
49  const favs = _loadFavorites();
50  const idx = favs.indexOf(mid);
51  if (idx >= 0) favs.splice(idx, 1);
52  else favs.push(mid);
53  _saveFavorites(favs);
54  return idx < 0; // returns true if now favorited
55}
56
57// ── Usage tracking ──
58function _loadUsage() {
59  return Storage.getJSON(USAGE_KEY, {});
60}
61function _trackUsage(mid) {
62  const usage = _loadUsage();
63  if (!usage[mid]) usage[mid] = { count: 0, last: 0 };
64  usage[mid].count++;
65  usage[mid].last = Date.now();
66  Storage.setJSON(USAGE_KEY, usage);
67}
68function _getSortMode() {
69  return Storage.get(SORT_KEY, '');
70}
71function _setSortMode(mode) {
72  Storage.set(SORT_KEY, mode);
73}
74
75/**
76 * Build a single model row element.
77 */
78function _startChat(url, mid, endpointId) {
79  // Block model switching while compare mode is active
80  if (window.compareModule && window.compareModule.isActive()) return;
81  _trackUsage(mid);
82  if (sessionModule) {
83    sessionModule.createDirectChat(url, mid, endpointId);
84  } else if (uiModule) {
85    uiModule.showError('Session module not loaded');
86  }
87}
88
89function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
90  const row = document.createElement('div');
91  row.className = 'models-row' + (offline ? ' models-row-offline' : '');
92  row.setAttribute('data-model-id', mid);
93  if (modelType === 'image') row.setAttribute('data-model-type', 'image');
94
95  const handle = document.createElement('span');
96  handle.className = 'item-drag-handle';
97  handle.textContent = '\u22EE\u22EE';
98  handle.title = 'Drag to reorder';
99  row.appendChild(handle);
100
101  // Favorite indicator — provider logo or colored dot
102  const fav = document.createElement('span');
103  const _favColor = modelColor(mid);
104  const _logo = providerLogo(mid);
105  if (_logo) {
106    fav.className = 'model-fav-btn provider-logo' + (_isFavorite(mid) ? ' active' : '');
107    fav.innerHTML = _logo;
108    fav.style.opacity = '0.4';
109  } else {
110    fav.className = 'model-fav-btn' + (_isFavorite(mid) ? ' active' : '');
111  }
112  fav.title = 'Toggle favorite';
113  fav.addEventListener('click', (e) => {
114    e.stopPropagation();
115    const nowFav = _toggleFavorite(mid);
116    fav.classList.toggle('active', nowFav);
117    uiModule.showToast(nowFav ? 'Favorited' : 'Unfavorited');
118    refreshModels();
119  });
120  const span = document.createElement('span');
121  span.className = 'grow';
122  span.textContent = displayName.split('/').pop();
123  if (modelType === 'image') {
124    const badge = document.createElement('span');
125    badge.className = 'model-type-badge';
126    badge.textContent = 'IMG';
127    badge.title = 'Image generation model';
128    badge.style.cssText = 'font-size:0.65em;padding:1px 4px;border-radius:3px;background:var(--accent,#7c3aed);color:#fff;margin-left:6px;vertical-align:middle;';
129    span.appendChild(badge);
130  }
131
132  const btn = document.createElement('button');
133  btn.type = 'button';
134  btn.textContent = offline ? 'Offline' : (modelType === 'image' ? '+ Image' : '+ Chat');
135  btn.className = 'model-chat-btn';
136  btn.style.transition = 'all 0.2s ease';
137  if (offline) {
138    btn.disabled = true;
139    btn.style.opacity = '0.4';
140    btn.style.cursor = 'not-allowed';
141  } else {
142    btn.addEventListener('click', (e) => {
143      e.stopPropagation();
144      _startChat(url, mid, endpointId);
145    });
146  }
147
148  // Clicking anywhere on the row (except drag handle and fav) starts a chat
149  if (!offline) {
150    let _touchMoved = false;
151    row.addEventListener('touchstart', () => { _touchMoved = false; }, { passive: true });
152    row.addEventListener('touchmove', () => { _touchMoved = true; }, { passive: true });
153    row.addEventListener('click', (e) => {
154      if (e.target.closest('.item-drag-handle') || e.target.closest('.model-fav-btn')) return;
155      if (_touchMoved) { _touchMoved = false; return; }
156      _startChat(url, mid, endpointId);
157    });
158  }
159
160  row.appendChild(fav);
161  row.appendChild(span);
162  row.appendChild(btn);
163  return row;
164}
165
166export async function refreshModels(force = false) {
167  const box = document.getElementById('models');
168  if (!box) return;
169
170  // Skip network fetch if cache is fresh and not forced — still re-render UI
171  const now = Date.now();
172  const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
173
174  box.innerHTML = '';
175  if (needsFetch) {
176    const _loadingSpinner = spinnerModule.create('', 'right', 'wave');
177    box.appendChild(_loadingSpinner.createElement());
178    _loadingSpinner.start();
179    try {
180      if (!_fetchInflight) {
181        _fetchInflight = fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
182          .then(async (res) => {
183            if (!res.ok) throw new Error(`HTTP ${res.status}`);
184            return res.json();
185          })
186          .finally(() => { _fetchInflight = null; });
187      }
188      const data = await _fetchInflight;
189      _lastFetchTime = Date.now();
190      _cachedItems = data.items || [];
191    } catch (e) {
192      console.error(e);
193      box.textContent = '(scan failed)';
194      return;
195    } finally {
196      box.innerHTML = '';
197    }
198  }
199  try {
200
201    const collapseState = _loadCollapsed();
202    let groupIdx = 0; // unique ID counter for drag-sort containers
203
204    // Collect models grouped by category → endpoint
205    const groups = { local: {}, api: {} };
206    // Also track extra (non-curated) models per endpoint
207    const extraGroups = { local: {}, api: {} };
208    if (_cachedItems && _cachedItems.length > 0) {
209      _cachedItems.forEach(item => {
210        const cat = item.category === 'local' ? 'local' : 'api';
211        const epName = item.endpoint_name || 'Unknown';
212        const isOffline = !!item.offline;
213        if (!groups[cat][epName]) groups[cat][epName] = [];
214        if (!extraGroups[cat][epName]) extraGroups[cat][epName] = [];
215        const displayNames = item.models_display || item.models || [];
216        const epModelType = item.model_type || 'llm';
217        (item.models || []).forEach((mid, i) => {
218          groups[cat][epName].push({
219            mid, url: item.url,
220            displayName: displayNames[i] || mid,
221            endpointId: item.endpoint_id || null,
222            offline: isOffline,
223            modelType: epModelType,
224          });
225        });
226        // Extra (non-curated) models from server
227        const extraDisplayNames = item.models_extra_display || item.models_extra || [];
228        (item.models_extra || []).forEach((mid, i) => {
229          extraGroups[cat][epName].push({
230            mid, url: item.url,
231            displayName: extraDisplayNames[i] || mid,
232            endpointId: item.endpoint_id || null,
233            offline: isOffline,
234            modelType: epModelType,
235          });
236        });
237      });
238    }
239
240    // ── Render Favorites section on top ──
241    const favs = _loadFavorites();
242    if (favs.length > 0) {
243      const favModels = [];
244      // Collect favorited models from all groups (keep them in originals too)
245      for (const cat of ['local', 'api']) {
246        for (const [epName, epModels] of Object.entries(groups[cat])) {
247          for (const m of epModels) {
248            if (favs.includes(m.mid)) {
249              favModels.push(m);
250            }
251          }
252        }
253      }
254      // Sort favorites by active sort mode, or by favorited order as default
255      const favSort = _getSortMode();
256      if (favSort === 'alpha') {
257        favModels.sort((a, b) => a.displayName.split('/').pop().localeCompare(b.displayName.split('/').pop()));
258      } else if (favSort === 'last-used') {
259        const usage = _loadUsage();
260        favModels.sort((a, b) => ((usage[b.mid] || {}
260).last || 0) - ((usage[a.mid] || {}).last || 0));
261      } else if (favSort === 'most-used') {
262        const usage = _loadUsage();
263        favModels.sort((a, b) => ((usage[b.mid] || {}).count || 0) - ((usage[a.mid] || {}).count || 0));
264      } else {
265        favModels.sort((a, b) => favs.indexOf(a.mid) - favs.indexOf(b.mid));
266      }
267
268      if (favModels.length > 0) {
269        const favHeader = document.createElement('div');
270        favHeader.className = 'models-category-header';
271        const favToggle = document.createElement('span');
272        favToggle.className = 'folder-toggle';
273        const favCollapsed = collapseState['cat:favorites'] === true;
274        favToggle.textContent = favCollapsed ? '\u25B6' : '\u25BC';
275        favHeader.appendChild(favToggle);
276        const favLabel = document.createElement('span');
277        favLabel.textContent = 'Favorites';
278        favHeader.appendChild(favLabel);
279        const favCount = document.createElement('span');
280        favCount.className = 'folder-count';
281        favCount.textContent = '(' + favModels.length + ')';
282        favHeader.appendChild(favCount);
283        favHeader.addEventListener('click', () => {
284          const s = _loadCollapsed();
285          s['cat:favorites'] = !favCollapsed;
286          _saveCollapsed(s);
287          refreshModels();
288        });
289        box.appendChild(favHeader);
290
291        if (!favCollapsed) {
292          const favContainer = document.createElement('div');
293          favContainer.className = 'models-group-content';
294          favContainer.id = 'models-group-' + (groupIdx++);
295          favModels.forEach(({ mid, url, displayName, endpointId, offline, modelType }) => {
296            favContainer.appendChild(_buildModelRow(mid, url, displayName, endpointId, offline, modelType));
297          });
298          box.appendChild(favContainer);
299        }
300      }
301    }
302
303    const localCount = Object.values(groups.local).reduce((s, a) => s + a.length, 0);
304    const apiCount = Object.values(groups.api).reduce((s, a) => s + a.length, 0);
305    const hasMultipleCategories = localCount > 0 && apiCount > 0;
306    const needsGrouping = hasMultipleCategories ||
307      Object.keys(groups.local).length > 1 || Object.keys(groups.api).length > 1;
308
309    const categoryOrder = [
310      { key: 'local', label: 'Local' },
311      { key: 'api', label: 'API' },
312    ];
313
314    categoryOrder.forEach(({ key, label }) => {
315      const endpoints = groups[key];
316      const models = Object.values(endpoints).flat();
317      if (models.length === 0) return;
318
319      const multiEndpoints = Object.keys(endpoints).length > 1;
320
321      // --- Category-level collapsible group ---
322      if (hasMultipleCategories) {
323        const catCollapsed = collapseState['cat:' + key] === true;
324
325        const header = document.createElement('div');
326        header.className = 'models-category-header';
327
328        const toggle = document.createElement('span');
329        toggle.className = 'folder-toggle';
330        toggle.textContent = catCollapsed ? '\u25B6' : '\u25BC';
331        header.appendChild(toggle);
332
333        const labelSpan = document.createElement('span');
334        labelSpan.textContent = label;
335        header.appendChild(labelSpan);
336
337        const count = document.createElement('span');
338        count.className = 'folder-count';
339        count.textContent = '(' + models.length + ')';
340        header.appendChild(count);
341
342        header.addEventListener('click', () => {
343          const s = _loadCollapsed();
344          s['cat:' + key] = !catCollapsed;
345          _saveCollapsed(s);
346          refreshModels();
347        });
348
349        box.appendChild(header);
350
351        if (catCollapsed) return;
352      }
353
354      // --- Endpoint sub-groups ---
355      const extraEndpoints = extraGroups[key];
356      Object.entries(endpoints).forEach(([epName, epModels]) => {
357        const epExtra = extraEndpoints[epName] || [];
358        const totalCount = epModels.length + epExtra.length;
359        const isOfflineEndpoint = epModels.length > 0 && epModels[0].offline;
360
361        if (multiEndpoints) {
362          const epKey = 'ep:' + key + ':' + epName;
363          const epCollapsed = collapseState[epKey] === true;
364
365          const sub = document.createElement('div');
366          sub.className = 'models-endpoint-label';
367
368          const epToggle = document.createElement('span');
369          epToggle.className = 'folder-toggle';
370          epToggle.textContent = epCollapsed ? '\u25B6' : '\u25BC';
371          sub.appendChild(epToggle);
372
373          const epLabel = document.createElement('span');
374          epLabel.textContent = epName;
375          sub.appendChild(epLabel);
376
377          if (isOfflineEndpoint) {
378            const badge = document.createElement('span');
379            badge.className = 'endpoint-offline-badge';
380            badge.textContent = '(offline)';
381            sub.appendChild(badge);
382          }
383
384          const epCount = document.createElement('span');
385          epCount.className = 'folder-count';
386          epCount.textContent = '(' + totalCount + ')';
387          sub.appendChild(epCount);
388
389          sub.addEventListener('click', () => {
390            const s = _loadCollapsed();
391            s[epKey] = !epCollapsed;
392            _saveCollapsed(s);
393            refreshModels();
394          });
395
396          box.appendChild(sub);
397
398          if (epCollapsed) return;
399        }
400
401        // Render model rows into a container
402        let target;
403        if (needsGrouping) {
404          target = document.createElement('div');
405          target.className = 'models-group-content';
406          target.id = 'models-group-' + (groupIdx++);
407          if (multiEndpoints) target.classList.add('indented');
408        } else {
409          target = box;
410        }
411
412        // Apply sort mode
413        const sortMode = _getSortMode();
414        if (sortMode === 'alpha') {
415          epModels.sort((a, b) => a.displayName.split('/').pop().localeCompare(b.displayName.split('/').pop()));
416        } else if (sortMode === 'last-used') {
417          const usage = _loadUsage();
418          epModels.sort((a, b) => ((usage[b.mid] || {}).last || 0) - ((usage[a.mid] || {}).last || 0));
419        } else if (sortMode === 'most-used') {
420          const usage = _loadUsage();
421          epModels.sort((a, b) => ((usage[b.mid] || {}).count || 0) - ((usage[a.mid] || {}).count || 0));
422        }
423
424        // Show up to MAX_VISIBLE models, rest behind "show more"
425        const MAX_VISIBLE = 5;
426        const visible = epModels.slice(0, MAX_VISIBLE);
427        const overflow = epModels.slice(MAX_VISIBLE);
428        const allHidden = [...overflow, ...epExtra];
429
430        visible.forEach(({ mid, url, displayName, endpointId, offline, modelType }) => {
431          target.appendChild(_buildModelRow(mid, url, displayName, endpointId, offline, modelType));
432        });
433
434        if (allHidden.length > 0) {
435          const showMoreBtn = document.createElement('div');
436          showMoreBtn.className = 'models-show-all-btn';
437          showMoreBtn.style.cssText = 'text-align:center;padding:6px;opacity:0.5;cursor:pointer;font-size:0.82em;';
438          showMoreBtn.textContent = `Show ${allHidden.length} more model${allHidden.length === 1 ? '' : 's'}`;
439          showMoreBtn._target = target;
440          showMoreBtn.addEventListener('click', () => {
441            showMoreBtn.remove();
442            allHidden.forEach(({ mid, url, displayName, endpointId, offline, modelType }) => {
443              target.appendChild(_buildModelRow(mid, url, displayName, endpointId, offline, modelType));
444            });
445          });
446          target.appendChild(showMoreBtn);
447        }
448
449        if (needsGrouping) box.appendChild(target);
450      });
451    });
452
453    // Restore saved drag order for flat list before enabling drag-sort
454    if (!needsGrouping) {
455      const savedModelOrder = Storage.getJSON('models-order', []);
456      if (savedModelOrder.length) {
457        const rowMap = new Map();
458        box.querySelectorAll('.models-row').forEach(r => {
459          const mid = r.dataset.modelId;
460          if (mid) rowMap.set(mid, r);
461        });
462        const ordered = [];
463        savedModelOrder.forEach(mid => {
464          if (rowMap.has(mid)) {
465            ordered.push(rowMap.get(mid));
466            rowMap.delete(mid);
467          }
468        });
469        // Append remaining rows not in saved order
470        rowMap.forEach(r => ordered.push(r));
471        ordered.forEach(r => box.appendChild(r));
472      }
473    }
474
475    // Enable drag sorting
476    if (dragSortModule) {
477      if (!needsGrouping) {
478        // Flat list — sort the whole #models container
479        dragSortModule.enable('models', '.models-row', {
480          handleSelector: '.item-drag-handle',
481          storageKey: 'models-order',
482        });
483      } else {
484        // Grouped — enable sort within each group container
485        box.querySelectorAll('.models-group-content').forEach(gc => {
486          dragSortModule.enable(gc.id, '.models-row', {
487            handleSelector: '.item-drag-handle',
488          });
489        });
490      }
491    }
492
493    // ── Search box (shown when >= 5 total models, including hidden overflow) ──
494    const totalModelCount = (_cachedItems || []).reduce((n, item) => {
495      if (item.offline) return n;
496      return n + (item.models || []).length + (item.models_extra || []).length;
497    }, 0);
498    if (totalModelCount >= 10) {
499      const searchBox = document.createElement('input');
500      searchBox.type = 'text';
501      searchBox.placeholder = 'Search models...';
502      searchBox.className = 'model-search-input';
503      searchBox.addEventListener('click', (e) => e.stopPropagation());
504      searchBox.addEventListener('touchstart', (e) => e.stopPropagation());
505      // Container for flat search results (rendered from _cachedItems, ignores collapse)
506      const searchResults = document.createElement('div');
507      searchResults.className = 'models-search-results';
508      searchResults.style.display = 'none';
509      box.appendChild(searchResults);
510
511      searchBox.addEventListener('input', () => {
512        const q = searchBox.value.toLowerCase().trim();
513        if (!q) {
514          // Clear search: hide results, restore normal groups
515          searchResults.style.display = 'none';
516          searchResults.innerHTML = '';
517          for (const ch of box.children) {
518            if (ch !== searchBox && ch !== searchResults) ch.style.display = '';
519          }
520          return;
521        }
522        // Hide all normal groups/headers, show flat search results
523        for (const ch of box.children) {
524          if (ch !== searchBox && ch !== searchResults) ch.style.display = 'none';
525        }
526        searchResults.innerHTML = '';
527        searchResults.style.display = '';
528        // Build flat results from all cached models
529        (_cachedItems || []).forEach(item => {
530          if (item.offline) return;
531          const allModels = (item.models || []).concat(item.models_extra || []);
532          const allDisplay = (item.models_display || []).concat(item.models_extra_display || item.models_extra || []);
533          allModels.forEach((mid, i) => {
534            const display = allDisplay[i] || mid;
535            if (!mid.toLowerCase().includes(q) && !display.toLowerCase().includes(q)) return;
536            searchResults.appendChild(
537              _buildModelRow(mid, item.url, display, item.endpoint_id || null, false, item.model_type || 'llm')
538            );
539          });
540        });
541        if (searchResults.children.length === 0) {
542          const empty = document.createElement('div');
543          empty.style.cssText = 'text-align:center;padding:12px;opacity:0.4;';
544          empty.textContent = 'No models match "' + searchBox.value.trim() + '"';
545          searchResults.appendChild(empty);
546        }
547      });
548      box.insertBefore(searchBox, box.firstChild);
549    }
550
551    if (!_cachedItems || _cachedItems.length === 0) {
552      const noModels = document.createElement('div');
553      noModels.className = 'models-empty-state';
554      if (window._isAdmin) {
555        noModels.innerHTML = '<span class="muted">No models found</span><br>'
556          + '<a href="#" onclick="document.getElementById(\'user-bar-admin\')?.click();return false;" class="accent-link">Open Admin to add endpoints</a>'
557          + '<br><span class="muted-sm">
557Type /setup for Local models or API setup.</span>';
558      } else {
559        noModels.innerHTML = '<span class="muted">No models available</span><br>'
560          + '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
561      }
562      box.appendChild(noModels);
563      // No endpoints yet: keep the welcome screen focused on first setup.
564      const welcomeSub = document.getElementById('welcome-sub');
565      if (welcomeSub) welcomeSub.innerHTML = 'Type <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">/setup</span> to get started.';
566      const welcomeTip = document.getElementById('welcome-tip');
567      if (welcomeTip) welcomeTip.textContent = 'Type /setup, then choose Local models or API.';
568    } else {
569      // Configured installs should feel ready, not stuck in onboarding.
570      const welcomeSub = document.getElementById('welcome-sub');
571      if (welcomeSub) welcomeSub.textContent = 'Yours for the voyage.';
572      const welcomeTip = document.getElementById('welcome-tip');
573      if (welcomeTip) {
574        const tips = window.innerWidth <= 768
575          ? [
576              'Tip: Long-press a session for rename, delete, and memory options.',
577              'Tip: Tap the eye icon for Nobody mode - no history saved.',
578              'Tip: Switch to Agent mode when you want tools.',
579              'Tip: Attach images or files using the + button next to the input.',
580            ]
581          : [
582              'Tip: Press Ctrl+K to search across all your conversations.',
583              'Tip: Press Ctrl+B to quickly toggle the sidebar.',
584              'Tip: Shift-click the sidebar toggle to swap it to the other side.',
585              'Tip: Drag and drop files onto the chat to attach them.',
586              'Tip: Right-click a session for rename, delete, and memory options.',
587            ];
588        welcomeTip.textContent = tips[Math.floor(Math.random() * tips.length)];
589      }
590    }
591  } catch (e) {
592    console.error(e);
593    box.textContent = '(render failed: ' + e.message + ')';
594  }
595}
596
597/**
598 * Refresh and display OpenAI providers
599 */
600export async function refreshProviders() {
601  const sel = document.getElementById('openai-model');
602  if (!sel) return; // Exit if element doesn't exist
603
604  sel.innerHTML = '<option disabled>Loading providers…</option>';
605
606  try {
607    const res = await fetch(`${API_BASE}/api/providers`);
608    const data = await res.json();
609    const openai = (data.providers || []).find(p => p.provider === 'openai');
610
611    sel.innerHTML = '';
612
613    if (openai) {
614      const models = (openai.items?.[0]?.models) || [];
615      sortModelIds(models).forEach(m => {
616        const opt = document.createElement('option');
617        opt.value = m;
618        opt.textContent = m;
619        sel.appendChild(opt);
620      });
621    } else {
622      const opt = document.createElement('option');
623      opt.value = '';
624      opt.textContent = '(OPENAI_API_KEY not set on server)';
625      sel.appendChild(opt);
626    }
627  } catch (e) {
628    console.error(e);
629  }
630}
631
632export function getCachedItems() { return _cachedItems; }
633
634const modelsModule = {
635  init,
636  refreshModels,
637  refreshProvi
637ders,
638  getCachedItems,
639};
640
641export default modelsModule;
642window.modelsModule = modelsModule;

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.