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.