635 lines
23 KiB
JavaScript
635 lines
23 KiB
JavaScript
// static/js/models.js
|
|
|
|
/**
|
|
* Model and provider management
|
|
*/
|
|
|
|
import Storage from './storage.js';
|
|
import uiModule from './ui.js';
|
|
import sessionModule from './sessions.js';
|
|
import dragSortModule from './dragSort.js';
|
|
import spinnerModule from './spinner.js';
|
|
import { modelColor } from './chatRenderer.js';
|
|
import { providerLogo } from './providers.js';
|
|
import { sortModelIds } from './modelSort.js';
|
|
|
|
let API_BASE = '';
|
|
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
|
let _lastFetchTime = 0;
|
|
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
|
|
const COLLAPSE_KEY = 'odysseus-models-collapsed';
|
|
const FAVORITES_KEY = 'odysseus-model-favorites';
|
|
const USAGE_KEY = 'odysseus-model-usage';
|
|
const SORT_KEY = 'odysseus-model-sort';
|
|
|
|
export function init(apiBase) {
|
|
API_BASE = apiBase;
|
|
}
|
|
|
|
// ── Collapse state persistence ──
|
|
function _loadCollapsed() {
|
|
return Storage.getJSON(COLLAPSE_KEY, {});
|
|
}
|
|
function _saveCollapsed(state) {
|
|
Storage.setJSON(COLLAPSE_KEY, state);
|
|
}
|
|
|
|
// ── Favorites persistence ──
|
|
function _loadFavorites() {
|
|
return Storage.getJSON(FAVORITES_KEY, []);
|
|
}
|
|
function _saveFavorites(list) {
|
|
Storage.setJSON(FAVORITES_KEY, list);
|
|
}
|
|
function _isFavorite(mid) {
|
|
return _loadFavorites().includes(mid);
|
|
}
|
|
function _toggleFavorite(mid) {
|
|
const favs = _loadFavorites();
|
|
const idx = favs.indexOf(mid);
|
|
if (idx >= 0) favs.splice(idx, 1);
|
|
else favs.push(mid);
|
|
_saveFavorites(favs);
|
|
return idx < 0; // returns true if now favorited
|
|
}
|
|
|
|
// ── Usage tracking ──
|
|
function _loadUsage() {
|
|
return Storage.getJSON(USAGE_KEY, {});
|
|
}
|
|
function _trackUsage(mid) {
|
|
const usage = _loadUsage();
|
|
if (!usage[mid]) usage[mid] = { count: 0, last: 0 };
|
|
usage[mid].count++;
|
|
usage[mid].last = Date.now();
|
|
Storage.setJSON(USAGE_KEY, usage);
|
|
}
|
|
function _getSortMode() {
|
|
return Storage.get(SORT_KEY, '');
|
|
}
|
|
function _setSortMode(mode) {
|
|
Storage.set(SORT_KEY, mode);
|
|
}
|
|
|
|
/**
|
|
* Build a single model row element.
|
|
*/
|
|
function _startChat(url, mid, endpointId) {
|
|
// Block model switching while compare mode is active
|
|
if (window.compareModule && window.compareModule.isActive()) return;
|
|
_trackUsage(mid);
|
|
if (sessionModule) {
|
|
sessionModule.createDirectChat(url, mid, endpointId);
|
|
} else if (uiModule) {
|
|
uiModule.showError('Session module not loaded');
|
|
}
|
|
}
|
|
|
|
function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
|
|
const row = document.createElement('div');
|
|
row.className = 'models-row' + (offline ? ' models-row-offline' : '');
|
|
row.setAttribute('data-model-id', mid);
|
|
if (modelType === 'image') row.setAttribute('data-model-type', 'image');
|
|
|
|
const handle = document.createElement('span');
|
|
handle.className = 'item-drag-handle';
|
|
handle.textContent = '\u22EE\u22EE';
|
|
handle.title = 'Drag to reorder';
|
|
row.appendChild(handle);
|
|
|
|
// Favorite indicator — provider logo or colored dot
|
|
const fav = document.createElement('span');
|
|
const _favColor = modelColor(mid);
|
|
const _logo = providerLogo(mid);
|
|
if (_logo) {
|
|
fav.className = 'model-fav-btn provider-logo' + (_isFavorite(mid) ? ' active' : '');
|
|
fav.innerHTML = _logo;
|
|
fav.style.opacity = '0.4';
|
|
} else {
|
|
fav.className = 'model-fav-btn' + (_isFavorite(mid) ? ' active' : '');
|
|
}
|
|
fav.title = 'Toggle favorite';
|
|
fav.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const nowFav = _toggleFavorite(mid);
|
|
fav.classList.toggle('active', nowFav);
|
|
uiModule.showToast(nowFav ? 'Favorited' : 'Unfavorited');
|
|
refreshModels();
|
|
});
|
|
const span = document.createElement('span');
|
|
span.className = 'grow';
|
|
span.textContent = displayName.split('/').pop();
|
|
if (modelType === 'image') {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'model-type-badge';
|
|
badge.textContent = 'IMG';
|
|
badge.title = 'Image generation model';
|
|
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;';
|
|
span.appendChild(badge);
|
|
}
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.textContent = offline ? 'Offline' : (modelType === 'image' ? '+ Image' : '+ Chat');
|
|
btn.className = 'model-chat-btn';
|
|
btn.style.transition = 'all 0.2s ease';
|
|
if (offline) {
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.4';
|
|
btn.style.cursor = 'not-allowed';
|
|
} else {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
_startChat(url, mid, endpointId);
|
|
});
|
|
}
|
|
|
|
// Clicking anywhere on the row (except drag handle and fav) starts a chat
|
|
if (!offline) {
|
|
let _touchMoved = false;
|
|
row.addEventListener('touchstart', () => { _touchMoved = false; }, { passive: true });
|
|
row.addEventListener('touchmove', () => { _touchMoved = true; }, { passive: true });
|
|
row.addEventListener('click', (e) => {
|
|
if (e.target.closest('.item-drag-handle') || e.target.closest('.model-fav-btn')) return;
|
|
if (_touchMoved) { _touchMoved = false; return; }
|
|
_startChat(url, mid, endpointId);
|
|
});
|
|
}
|
|
|
|
row.appendChild(fav);
|
|
row.appendChild(span);
|
|
row.appendChild(btn);
|
|
return row;
|
|
}
|
|
|
|
export async function refreshModels(force = false) {
|
|
const box = document.getElementById('models');
|
|
if (!box) return;
|
|
|
|
// Skip network fetch if cache is fresh and not forced — still re-render UI
|
|
const now = Date.now();
|
|
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
|
|
|
|
box.innerHTML = '';
|
|
if (needsFetch) {
|
|
const _loadingSpinner = spinnerModule.create('', 'right', 'wave');
|
|
box.appendChild(_loadingSpinner.createElement());
|
|
_loadingSpinner.start();
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/models`);
|
|
const data = await res.json();
|
|
_lastFetchTime = Date.now();
|
|
_cachedItems = data.items || [];
|
|
} catch (e) {
|
|
console.error(e);
|
|
box.textContent = '(scan failed)';
|
|
return;
|
|
} finally {
|
|
box.innerHTML = '';
|
|
}
|
|
}
|
|
try {
|
|
|
|
const collapseState = _loadCollapsed();
|
|
let groupIdx = 0; // unique ID counter for drag-sort containers
|
|
|
|
// Collect models grouped by category → endpoint
|
|
const groups = { local: {}, api: {} };
|
|
// Also track extra (non-curated) models per endpoint
|
|
const extraGroups = { local: {}, api: {} };
|
|
if (_cachedItems && _cachedItems.length > 0) {
|
|
_cachedItems.forEach(item => {
|
|
const cat = item.category === 'local' ? 'local' : 'api';
|
|
const epName = item.endpoint_name || 'Unknown';
|
|
const isOffline = !!item.offline;
|
|
if (!groups[cat][epName]) groups[cat][epName] = [];
|
|
if (!extraGroups[cat][epName]) extraGroups[cat][epName] = [];
|
|
const displayNames = item.models_display || item.models || [];
|
|
const epModelType = item.model_type || 'llm';
|
|
(item.models || []).forEach((mid, i) => {
|
|
groups[cat][epName].push({
|
|
mid, url: item.url,
|
|
displayName: displayNames[i] || mid,
|
|
endpointId: item.endpoint_id || null,
|
|
offline: isOffline,
|
|
modelType: epModelType,
|
|
});
|
|
});
|
|
// Extra (non-curated) models from server
|
|
const extraDisplayNames = item.models_extra_display || item.models_extra || [];
|
|
(item.models_extra || []).forEach((mid, i) => {
|
|
extraGroups[cat][epName].push({
|
|
mid, url: item.url,
|
|
displayName: extraDisplayNames[i] || mid,
|
|
endpointId: item.endpoint_id || null,
|
|
offline: isOffline,
|
|
modelType: epModelType,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Render Favorites section on top ──
|
|
const favs = _loadFavorites();
|
|
if (favs.length > 0) {
|
|
const favModels = [];
|
|
// Collect favorited models from all groups (keep them in originals too)
|
|
for (const cat of ['local', 'api']) {
|
|
for (const [epName, epModels] of Object.entries(groups[cat])) {
|
|
for (const m of epModels) {
|
|
if (favs.includes(m.mid)) {
|
|
favModels.push(m);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Sort favorites by active sort mode, or by favorited order as default
|
|
const favSort = _getSortMode();
|
|
if (favSort === 'alpha') {
|
|
favModels.sort((a, b) => a.displayName.split('/').pop().localeCompare(b.displayName.split('/').pop()));
|
|
} else if (favSort === 'last-used') {
|
|
const usage = _loadUsage();
|
|
favModels.sort((a, b) => ((usage[b.mid] || {}).last || 0) - ((usage[a.mid] || {}).last || 0));
|
|
} else if (favSort === 'most-used') {
|
|
const usage = _loadUsage();
|
|
favModels.sort((a, b) => ((usage[b.mid] || {}).count || 0) - ((usage[a.mid] || {}).count || 0));
|
|
} else {
|
|
favModels.sort((a, b) => favs.indexOf(a.mid) - favs.indexOf(b.mid));
|
|
}
|
|
|
|
if (favModels.length > 0) {
|
|
const favHeader = document.createElement('div');
|
|
favHeader.className = 'models-category-header';
|
|
const favToggle = document.createElement('span');
|
|
favToggle.className = 'folder-toggle';
|
|
const favCollapsed = collapseState['cat:favorites'] === true;
|
|
favToggle.textContent = favCollapsed ? '\u25B6' : '\u25BC';
|
|
favHeader.appendChild(favToggle);
|
|
const favLabel = document.createElement('span');
|
|
favLabel.textContent = 'Favorites';
|
|
favHeader.appendChild(favLabel);
|
|
const favCount = document.createElement('span');
|
|
favCount.className = 'folder-count';
|
|
favCount.textContent = '(' + favModels.length + ')';
|
|
favHeader.appendChild(favCount);
|
|
favHeader.addEventListener('click', () => {
|
|
const s = _loadCollapsed();
|
|
s['cat:favorites'] = !favCollapsed;
|
|
_saveCollapsed(s);
|
|
refreshModels();
|
|
});
|
|
box.appendChild(favHeader);
|
|
|
|
if (!favCollapsed) {
|
|
const favContainer = document.createElement('div');
|
|
favContainer.className = 'models-group-content';
|
|
favContainer.id = 'models-group-' + (groupIdx++);
|
|
favModels.forEach(({ mid, url, displayName, endpointId, offline, modelType }) => {
|
|
favContainer.appendChild(_buildModelRow(mid, url, displayName, endpointId, offline, modelType));
|
|
});
|
|
box.appendChild(favContainer);
|
|
}
|
|
}
|
|
}
|
|
|
|
const localCount = Object.values(groups.local).reduce((s, a) => s + a.length, 0);
|
|
const apiCount = Object.values(groups.api).reduce((s, a) => s + a.length, 0);
|
|
const hasMultipleCategories = localCount > 0 && apiCount > 0;
|
|
const needsGrouping = hasMultipleCategories ||
|
|
Object.keys(groups.local).length > 1 || Object.keys(groups.api).length > 1;
|
|
|
|
const categoryOrder = [
|
|
{ key: 'local', label: 'Local' },
|
|
{ key: 'api', label: 'API' },
|
|
];
|
|
|
|
categoryOrder.forEach(({ key, label }) => {
|
|
const endpoints = groups[key];
|
|
const models = Object.values(endpoints).flat();
|
|
if (models.length === 0) return;
|
|
|
|
const multiEndpoints = Object.keys(endpoints).length > 1;
|
|
|
|
// --- Category-level collapsible group ---
|
|
if (hasMultipleCategories) {
|
|
const catCollapsed = collapseState['cat:' + key] === true;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'models-category-header';
|
|
|
|
const toggle = document.createElement('span');
|
|
toggle.className = 'folder-toggle';
|
|
toggle.textContent = catCollapsed ? '\u25B6' : '\u25BC';
|
|
header.appendChild(toggle);
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.textContent = label;
|
|
header.appendChild(labelSpan);
|
|
|
|
const count = document.createElement('span');
|
|
count.className = 'folder-count';
|
|
count.textContent = '(' + models.length + ')';
|
|
header.appendChild(count);
|
|
|
|
header.addEventListener('click', () => {
|
|
const s = _loadCollapsed();
|
|
s['cat:' + key] = !catCollapsed;
|
|
_saveCollapsed(s);
|
|
refreshModels();
|
|
});
|
|
|
|
box.appendChild(header);
|
|
|
|
if (catCollapsed) return;
|
|
}
|
|
|
|
// --- Endpoint sub-groups ---
|
|
const extraEndpoints = extraGroups[key];
|
|
Object.entries(endpoints).forEach(([epName, epModels]) => {
|
|
const epExtra = extraEndpoints[epName] || [];
|
|
const totalCount = epModels.length + epExtra.length;
|
|
const isOfflineEndpoint = epModels.length > 0 && epModels[0].offline;
|
|
|
|
if (multiEndpoints) {
|
|
const epKey = 'ep:' + key + ':' + epName;
|
|
const epCollapsed = collapseState[epKey] === true;
|
|
|
|
const sub = document.createElement('div');
|
|
sub.className = 'models-endpoint-label';
|
|
|
|
const epToggle = document.createElement('span');
|
|
epToggle.className = 'folder-toggle';
|
|
epToggle.textContent = epCollapsed ? '\u25B6' : '\u25BC';
|
|
sub.appendChild(epToggle);
|
|
|
|
const epLabel = document.createElement('span');
|
|
epLabel.textContent = epName;
|
|
sub.appendChild(epLabel);
|
|
|
|
if (isOfflineEndpoint) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'endpoint-offline-badge';
|
|
badge.textContent = '(offline)';
|
|
sub.appendChild(badge);
|
|
}
|
|
|
|
const epCount = document.createElement('span');
|
|
epCount.className = 'folder-count';
|
|
epCount.textContent = '(' + totalCount + ')';
|
|
sub.appendChild(epCount);
|
|
|
|
sub.addEventListener('click', () => {
|
|
const s = _loadCollapsed();
|
|
s[epKey] = !epCollapsed;
|
|
_saveCollapsed(s);
|
|
refreshModels();
|
|
});
|
|
|
|
box.appendChild(sub);
|
|
|
|
if (epCollapsed) return;
|
|
}
|
|
|
|
// Render model rows into a container
|
|
let target;
|
|
if (needsGrouping) {
|
|
target = document.createElement('div');
|
|
target.className = 'models-group-content';
|
|
target.id = 'models-group-' + (groupIdx++);
|
|
if (multiEndpoints) target.classList.add('indented');
|
|
} else {
|
|
target = box;
|
|
}
|
|
|
|
// Apply sort mode
|
|
const sortMode = _getSortMode();
|
|
if (sortMode === 'alpha') {
|
|
epModels.sort((a, b) => a.displayName.split('/').pop().localeCompare(b.displayName.split('/').pop()));
|
|
} else if (sortMode === 'last-used') {
|
|
const usage = _loadUsage();
|
|
epModels.sort((a, b) => ((usage[b.mid] || {}).last || 0) - ((usage[a.mid] || {}).last || 0));
|
|
} else if (sortMode === 'most-used') {
|
|
const usage = _loadUsage();
|
|
epModels.sort((a, b) => ((usage[b.mid] || {}).count || 0) - ((usage[a.mid] || {}).count || 0));
|
|
}
|
|
|
|
// Show up to MAX_VISIBLE models, rest behind "show more"
|
|
const MAX_VISIBLE = 5;
|
|
const visible = epModels.slice(0, MAX_VISIBLE);
|
|
const overflow = epModels.slice(MAX_VISIBLE);
|
|
const allHidden = [...overflow, ...epExtra];
|
|
|
|
visible.forEach(({ mid, url, displayName, endpointId, offline, modelType }) => {
|
|
target.appendChild(_buildModelRow(mid, url, displayName, endpointId, offline, modelType));
|
|
});
|
|
|
|
if (allHidden.length > 0) {
|
|
const showMoreBtn = document.createElement('div');
|
|
showMoreBtn.className = 'models-show-all-btn';
|
|
showMoreBtn.style.cssText = 'text-align:center;padding:6px;opacity:0.5;cursor:pointer;font-size:0.82em;';
|
|
showMoreBtn.textContent = `Show ${allHidden.length} more model${allHidden.length === 1 ? '' : 's'}`;
|
|
showMoreBtn._target = target;
|
|
showMoreBtn.addEventListener('click', () => {
|
|
showMoreBtn.remove();
|
|
allHidden.forEach(({ mid, url, displayName, endpointId, offline, modelType }) => {
|
|
target.appendChild(_buildModelRow(mid, url, displayName, endpointId, offline, modelType));
|
|
});
|
|
});
|
|
target.appendChild(showMoreBtn);
|
|
}
|
|
|
|
if (needsGrouping) box.appendChild(target);
|
|
});
|
|
});
|
|
|
|
// Restore saved drag order for flat list before enabling drag-sort
|
|
if (!needsGrouping) {
|
|
const savedModelOrder = Storage.getJSON('models-order', []);
|
|
if (savedModelOrder.length) {
|
|
const rowMap = new Map();
|
|
box.querySelectorAll('.models-row').forEach(r => {
|
|
const mid = r.dataset.modelId;
|
|
if (mid) rowMap.set(mid, r);
|
|
});
|
|
const ordered = [];
|
|
savedModelOrder.forEach(mid => {
|
|
if (rowMap.has(mid)) {
|
|
ordered.push(rowMap.get(mid));
|
|
rowMap.delete(mid);
|
|
}
|
|
});
|
|
// Append remaining rows not in saved order
|
|
rowMap.forEach(r => ordered.push(r));
|
|
ordered.forEach(r => box.appendChild(r));
|
|
}
|
|
}
|
|
|
|
// Enable drag sorting
|
|
if (dragSortModule) {
|
|
if (!needsGrouping) {
|
|
// Flat list — sort the whole #models container
|
|
dragSortModule.enable('models', '.models-row', {
|
|
handleSelector: '.item-drag-handle',
|
|
storageKey: 'models-order',
|
|
});
|
|
} else {
|
|
// Grouped — enable sort within each group container
|
|
box.querySelectorAll('.models-group-content').forEach(gc => {
|
|
dragSortModule.enable(gc.id, '.models-row', {
|
|
handleSelector: '.item-drag-handle',
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Search box (shown when >= 5 total models, including hidden overflow) ──
|
|
const totalModelCount = (_cachedItems || []).reduce((n, item) => {
|
|
if (item.offline) return n;
|
|
return n + (item.models || []).length + (item.models_extra || []).length;
|
|
}, 0);
|
|
if (totalModelCount >= 10) {
|
|
const searchBox = document.createElement('input');
|
|
searchBox.type = 'text';
|
|
searchBox.placeholder = 'Search models...';
|
|
searchBox.className = 'model-search-input';
|
|
searchBox.addEventListener('click', (e) => e.stopPropagation());
|
|
searchBox.addEventListener('touchstart', (e) => e.stopPropagation());
|
|
// Container for flat search results (rendered from _cachedItems, ignores collapse)
|
|
const searchResults = document.createElement('div');
|
|
searchResults.className = 'models-search-results';
|
|
searchResults.style.display = 'none';
|
|
box.appendChild(searchResults);
|
|
|
|
searchBox.addEventListener('input', () => {
|
|
const q = searchBox.value.toLowerCase().trim();
|
|
if (!q) {
|
|
// Clear search: hide results, restore normal groups
|
|
searchResults.style.display = 'none';
|
|
searchResults.innerHTML = '';
|
|
for (const ch of box.children) {
|
|
if (ch !== searchBox && ch !== searchResults) ch.style.display = '';
|
|
}
|
|
return;
|
|
}
|
|
// Hide all normal groups/headers, show flat search results
|
|
for (const ch of box.children) {
|
|
if (ch !== searchBox && ch !== searchResults) ch.style.display = 'none';
|
|
}
|
|
searchResults.innerHTML = '';
|
|
searchResults.style.display = '';
|
|
// Build flat results from all cached models
|
|
(_cachedItems || []).forEach(item => {
|
|
if (item.offline) return;
|
|
const allModels = (item.models || []).concat(item.models_extra || []);
|
|
const allDisplay = (item.models_display || []).concat(item.models_extra_display || item.models_extra || []);
|
|
allModels.forEach((mid, i) => {
|
|
const display = allDisplay[i] || mid;
|
|
if (!mid.toLowerCase().includes(q) && !display.toLowerCase().includes(q)) return;
|
|
searchResults.appendChild(
|
|
_buildModelRow(mid, item.url, display, item.endpoint_id || null, false, item.model_type || 'llm')
|
|
);
|
|
});
|
|
});
|
|
if (searchResults.children.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.style.cssText = 'text-align:center;padding:12px;opacity:0.4;';
|
|
empty.textContent = 'No models match "' + searchBox.value.trim() + '"';
|
|
searchResults.appendChild(empty);
|
|
}
|
|
});
|
|
box.insertBefore(searchBox, box.firstChild);
|
|
}
|
|
|
|
if (!_cachedItems || _cachedItems.length === 0) {
|
|
const noModels = document.createElement('div');
|
|
noModels.className = 'models-empty-state';
|
|
if (window._isAdmin) {
|
|
noModels.innerHTML = '<span class="muted">No models found</span><br>'
|
|
+ '<a href="#" onclick="document.getElementById(\'user-bar-admin\')?.click();return false;" class="accent-link">Open Admin to add endpoints</a>'
|
|
+ '<br><span class="muted-sm">Type /setup for Local models or API setup.</span>';
|
|
} else {
|
|
noModels.innerHTML = '<span class="muted">No models available</span><br>'
|
|
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
|
|
}
|
|
box.appendChild(noModels);
|
|
// No endpoints yet: keep the welcome screen focused on first setup.
|
|
const welcomeSub = document.getElementById('welcome-sub');
|
|
if (welcomeSub) welcomeSub.innerHTML = 'Type <span style="color:var(--accent,var(--red));font-weight:600">/setup</span> to get started.';
|
|
const welcomeTip = document.getElementById('welcome-tip');
|
|
if (welcomeTip) welcomeTip.textContent = 'Type /setup, then choose Local models or API.';
|
|
} else {
|
|
// Configured installs should feel ready, not stuck in onboarding.
|
|
const welcomeSub = document.getElementById('welcome-sub');
|
|
if (welcomeSub) welcomeSub.textContent = 'Yours for the voyage.';
|
|
const welcomeTip = document.getElementById('welcome-tip');
|
|
if (welcomeTip) {
|
|
const tips = window.innerWidth <= 768
|
|
? [
|
|
'Tip: Long-press a session for rename, delete, and memory options.',
|
|
'Tip: Tap the eye icon for Nobody mode - no history saved.',
|
|
'Tip: Switch to Agent mode when you want tools.',
|
|
'Tip: Attach images or files using the + button next to the input.',
|
|
]
|
|
: [
|
|
'Tip: Press Ctrl+K to search across all your conversations.',
|
|
'Tip: Press Ctrl+B to quickly toggle the sidebar.',
|
|
'Tip: Shift-click the sidebar toggle to swap it to the other side.',
|
|
'Tip: Drag and drop files onto the chat to attach them.',
|
|
'Tip: Right-click a session for rename, delete, and memory options.',
|
|
];
|
|
welcomeTip.textContent = tips[Math.floor(Math.random() * tips.length)];
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
box.textContent = '(render failed: ' + e.message + ')';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh and display OpenAI providers
|
|
*/
|
|
export async function refreshProviders() {
|
|
const sel = document.getElementById('openai-model');
|
|
if (!sel) return; // Exit if element doesn't exist
|
|
|
|
sel.innerHTML = '<option disabled>Loading providers…</option>';
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/providers`);
|
|
const data = await res.json();
|
|
const openai = (data.providers || []).find(p => p.provider === 'openai');
|
|
|
|
sel.innerHTML = '';
|
|
|
|
if (openai) {
|
|
const models = (openai.items?.[0]?.models) || [];
|
|
sortModelIds(models).forEach(m => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m;
|
|
opt.textContent = m;
|
|
sel.appendChild(opt);
|
|
});
|
|
} else {
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = '(OPENAI_API_KEY not set on server)';
|
|
sel.appendChild(opt);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
export function getCachedItems() { return _cachedItems; }
|
|
|
|
const modelsModule = {
|
|
init,
|
|
refreshModels,
|
|
refreshProviders,
|
|
getCachedItems,
|
|
};
|
|
|
|
export default modelsModule;
|
|
window.modelsModule = modelsModule;
|