feat(ai): add OpenRouter and Ollama Cloud providers (#231)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
@@ -2036,6 +2036,7 @@
|
||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
||||
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
|
||||
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
||||
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
|
||||
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
||||
<option value="https://api.mistral.ai/v1" data-logo="mistral">Mistral</option>
|
||||
<option value="https://api.together.xyz/v1" data-logo="together">Together AI</option>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -216,7 +217,7 @@ async function _loadModelsForUser(username, allowedSet, privPanel) {
|
||||
return;
|
||||
}
|
||||
const allEmpty = allowedSet.size === 0;
|
||||
listEl.innerHTML = allModels.map(m => {
|
||||
listEl.innerHTML = sortModelObjects(allModels).map(m => {
|
||||
const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : '';
|
||||
return `<label>
|
||||
<input type="checkbox" class="priv-model-cb" data-mid="${esc(m.mid)}" ${checked}>
|
||||
@@ -377,6 +378,9 @@ async function loadEndpoints() {
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
|
||||
@@ -552,17 +556,18 @@ async function loadEndpoints() {
|
||||
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
||||
const models = await res.json();
|
||||
_stopSpin();
|
||||
if (!models.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
|
||||
const hiddenSet = new Set(models.filter(m => m.is_hidden).map(m => m.id));
|
||||
const showSearch = models.length >= 8;
|
||||
const sortedModels = sortModelObjects(models);
|
||||
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
|
||||
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||||
const showSearch = sortedModels.length >= 8;
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">${models.length - hiddenSet.size}/${models.length} enabled</span>
|
||||
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
||||
<a href="#" data-ep-select-all="${epId}">All</a>
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</span>
|
||||
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${models.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + models.map(m =>
|
||||
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||||
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
||||
<span class="adm-check-dot" aria-hidden="true"></span>
|
||||
@@ -623,6 +628,9 @@ async function _saveEpModelState(epId, panel) {
|
||||
const badge = row.querySelector('.admin-badge');
|
||||
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
@@ -702,12 +710,19 @@ function initEndpointForm() {
|
||||
// Strip trailing paths that shouldn't be in a base URL
|
||||
u = u.replace(/\/v1\/(models|chat\/completions|completions|messages)\/?$/i, '/v1');
|
||||
u = u.replace(/\/(models|chat\/completions|completions|v1\/messages)\/?$/i, '');
|
||||
u = u.replace(/\/api\/(chat|tags|generate)\/?$/i, '/api');
|
||||
// Fix double /v1/v1
|
||||
u = u.replace(/\/v1\/v1$/, '/v1');
|
||||
// Strip query params and fragments
|
||||
u = u.split('?')[0].split('#')[0];
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.hostname.endsWith('ollama.com')) {
|
||||
u = 'https://ollama.com/api';
|
||||
}
|
||||
} catch(e) {}
|
||||
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
|
||||
if (!u.includes('api.') && !u.includes('openrouter') && !u.endsWith('/v1')) {
|
||||
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (!parsed.pathname || parsed.pathname === '/') {
|
||||
@@ -814,9 +829,13 @@ function initEndpointForm() {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
||||
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
||||
}
|
||||
const epType = el('adm-epType');
|
||||
if (epType) fd.append('model_type', epType.value);
|
||||
fd.append('skip_probe', 'false');
|
||||
if (provider.value && /openrouter\.ai|ollama\.com/i.test(provider.value)) fd.append('require_models', 'true');
|
||||
else fd.append('skip_probe', 'false');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { selectSession } from './sessions.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
|
||||
const API = '/api/assistant';
|
||||
|
||||
@@ -250,9 +251,8 @@ function _renderSettingsBody(body, data, tzList) {
|
||||
try {
|
||||
const models = await _fetchJSON(`/api/model-endpoints/${ep.id}/models`);
|
||||
let mHTML = '';
|
||||
for (const m of (models.models || models || [])) {
|
||||
const mid = typeof m === 'string' ? m : (m.id || m.name || '');
|
||||
if (!mid) continue;
|
||||
const modelIds = (models.models || models || []).map(m => typeof m === 'string' ? m : (m.id || m.name || '')).filter(Boolean);
|
||||
for (const mid of sortModelIds(modelIds)) {
|
||||
const sel = mid === crew.model ? ' selected' : '';
|
||||
mHTML += `<option value="${_esc(mid)}"${sel}>${_esc(mid.split('/').pop())}</option>`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { sortModelObjects } from '../modelSort.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -84,9 +85,9 @@ async function fetchModels() {
|
||||
});
|
||||
});
|
||||
}
|
||||
state._fetchModelsCache = models;
|
||||
state._fetchModelsCache = sortModelObjects(models);
|
||||
state._fetchModelsCacheTime = now;
|
||||
return models;
|
||||
return state._fetchModelsCache;
|
||||
}
|
||||
|
||||
// ── Shuffle pool persistence ──
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||
// - gen: text-to-image generation
|
||||
@@ -106,7 +107,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
for (const ep of endpoints) {
|
||||
if (!ep.is_enabled) continue;
|
||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||
const models = hasListedModels ? ep.models : [''];
|
||||
const models = hasListedModels ? sortModelIds(ep.models) : [''];
|
||||
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
||||
// Image/inpaint endpoints can be called by URL even when their
|
||||
// /models cache is still empty, so don't strand a freshly served
|
||||
|
||||
@@ -7,6 +7,7 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
|
||||
let API_BASE = '';
|
||||
let _active = false;
|
||||
@@ -55,7 +56,7 @@ function _initGroupTab() {
|
||||
result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id });
|
||||
});
|
||||
});
|
||||
_modelsCache = result;
|
||||
_modelsCache = sortModelObjects(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -412,7 +413,7 @@ export async function showModelPicker() {
|
||||
result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id, epName: item.endpoint_name || '' });
|
||||
});
|
||||
});
|
||||
_cachedModels = result;
|
||||
_cachedModels = sortModelObjects(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { providerLogo } from './providers.js';
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
@@ -156,7 +157,7 @@ function _initModelPickerDropdown() {
|
||||
});
|
||||
});
|
||||
});
|
||||
return result;
|
||||
return sortModelObjects(result);
|
||||
}
|
||||
|
||||
function _populate(filter) {
|
||||
@@ -184,6 +185,8 @@ function _initModelPickerDropdown() {
|
||||
if (favs.includes(m.mid)) favModels.push(m);
|
||||
else restModels.push(m);
|
||||
});
|
||||
sortModelObjects(favModels).forEach(function(m, i) { favModels[i] = m; });
|
||||
sortModelObjects(restModels).forEach(function(m, i) { restModels[i] = m; });
|
||||
|
||||
function _addSection(label) {
|
||||
const el = document.createElement('div');
|
||||
|
||||
29
static/js/modelSort.js
Normal file
29
static/js/modelSort.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Shared alphabetical sorting for model pickers and dropdowns.
|
||||
|
||||
function _sortText(value) {
|
||||
return String(value || '').split('/').pop().trim() || String(value || '');
|
||||
}
|
||||
|
||||
function _compareText(a, b) {
|
||||
return _sortText(a).localeCompare(_sortText(b), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
}) || String(a || '').localeCompare(String(b || ''), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
export function sortModelIds(models) {
|
||||
return (models || []).slice().sort(_compareText);
|
||||
}
|
||||
|
||||
export function compareModelObjects(a, b) {
|
||||
const aLabel = a && (a.display || a.displayName || a.name || a.mid || a.id || a.model);
|
||||
const bLabel = b && (b.display || b.displayName || b.name || b.mid || b.id || b.model);
|
||||
return _compareText(aLabel, bLabel);
|
||||
}
|
||||
|
||||
export function sortModelObjects(models) {
|
||||
return (models || []).slice().sort(compareModelObjects);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -603,7 +604,7 @@ export async function refreshProviders() {
|
||||
|
||||
if (openai) {
|
||||
const models = (openai.items?.[0]?.models) || [];
|
||||
models.forEach(m => {
|
||||
sortModelIds(models).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
|
||||
@@ -11,6 +11,14 @@ const _PROVIDERS = [
|
||||
[/openai|gpt-|^o[13]-|chatgpt|dall-e/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>'],
|
||||
|
||||
// OpenRouter
|
||||
[/openrouter|open router/i,
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
|
||||
|
||||
// Ollama / Ollama Cloud
|
||||
[/ollama/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.4 10.2a4.8 4.8 0 0 1 9.1-1.9 4.1 4.1 0 0 1 1 .1A4.8 4.8 0 0 1 17 18H7.4a3.9 3.9 0 0 1 0-7.8Zm0 2a1.9 1.9 0 0 0 0 3.8H17a2.8 2.8 0 0 0 .2-5.6 2.7 2.7 0 0 0-1.3.2l-.9.4-.4-.9a2.8 2.8 0 0 0-5.4 1.1v1H7.4Z"/></svg>'],
|
||||
|
||||
// Anthropic — Claude (official Simple Icons)
|
||||
[/anthropic|claude/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as jobs from './jobs.js';
|
||||
import themeModule from '../theme.js';
|
||||
import createResearchSynapse from '../researchSynapse.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
// jobId -> { synapse, status } — survives across _renderJobs() rebuilds so
|
||||
// the SVG keeps its accumulated nodes/edges between progress events.
|
||||
@@ -637,7 +638,7 @@ function _populateModels(endpointId) {
|
||||
if (!endpointId) return;
|
||||
const ep = _endpoints.find(e => e.id === endpointId);
|
||||
if (!ep || !ep.models) return;
|
||||
ep.models.forEach(m => {
|
||||
sortModelIds(ep.models).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
|
||||
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
|
||||
import searchModule from './search.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { clearDockSide } from './modalSnap.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -31,6 +32,7 @@ function initTabs() {
|
||||
// they flip toggles instead of having to close + reopen the modal.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -160,6 +162,93 @@ function initOpacityToggle() {
|
||||
AI TAB
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
const _aiEndpointRefreshers = new Set();
|
||||
let _aiEndpointRefreshInFlight = null;
|
||||
|
||||
async function _fetchModelEndpoints() {
|
||||
const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await epRes.json();
|
||||
return Array.isArray(endpoints) ? endpoints : [];
|
||||
}
|
||||
|
||||
function _endpointLabel(ep) {
|
||||
return ep.name + (ep.online ? '' : ' (offline)');
|
||||
}
|
||||
|
||||
function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) {
|
||||
if (!selectEl) return;
|
||||
const previous = selected !== undefined ? selected : selectEl.value;
|
||||
const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
|
||||
? selectEl.options[0].textContent
|
||||
: null;
|
||||
while (selectEl.options.length) selectEl.remove(0);
|
||||
if (blankText !== null) {
|
||||
const blank = document.createElement('option');
|
||||
blank.value = '';
|
||||
blank.textContent = blankText;
|
||||
selectEl.appendChild(blank);
|
||||
}
|
||||
(endpoints || []).forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ep.id;
|
||||
opt.textContent = _endpointLabel(ep);
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
|
||||
selectEl.value = previous;
|
||||
} else if (blankText !== null) {
|
||||
selectEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _fillModelSelect(selectEl, models, selected, keepBlank) {
|
||||
if (!selectEl) return;
|
||||
const previous = selected !== undefined ? selected : selectEl.value;
|
||||
const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
|
||||
? selectEl.options[0].textContent
|
||||
: null;
|
||||
while (selectEl.options.length) selectEl.remove(0);
|
||||
if (blankText !== null) {
|
||||
const blank = document.createElement('option');
|
||||
blank.value = '';
|
||||
blank.textContent = blankText;
|
||||
selectEl.appendChild(blank);
|
||||
}
|
||||
sortModelIds(models).forEach(function(m) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = String(m).split('/').pop();
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
|
||||
selectEl.value = previous;
|
||||
} else if (blankText !== null) {
|
||||
selectEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _registerAiEndpointRefresh(fn) {
|
||||
_aiEndpointRefreshers.add(fn);
|
||||
}
|
||||
|
||||
export async function refreshAiModelEndpoints() {
|
||||
if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight;
|
||||
_aiEndpointRefreshInFlight = (async function() {
|
||||
try {
|
||||
const endpoints = await _fetchModelEndpoints();
|
||||
_aiEndpointRefreshers.forEach(function(fn) {
|
||||
try { fn(endpoints); } catch (e) { console.warn('[settings] endpoint refresh handler failed', e); }
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[settings] failed to refresh model endpoints', e);
|
||||
} finally {
|
||||
_aiEndpointRefreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
return _aiEndpointRefreshInFlight;
|
||||
}
|
||||
|
||||
/* Shared fallback-chain widget — mirrors the Default Chat Model fallback UI
|
||||
* for other model cards (Utility, Vision, …). Pass in the container/button
|
||||
* IDs, the endpoints list, the settings key to persist under, and the
|
||||
@@ -181,7 +270,7 @@ function _bindFallbackWidget(opts) {
|
||||
while (selectEl.options.length) selectEl.remove(0);
|
||||
var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; });
|
||||
if (ep && ep.models) {
|
||||
ep.models.forEach(function(m) {
|
||||
sortModelIds(ep.models).forEach(function(m) {
|
||||
if (!modelsFilter(m, ep)) return;
|
||||
var o = document.createElement('option');
|
||||
o.value = m;
|
||||
@@ -270,6 +359,7 @@ function _bindFallbackWidget(opts) {
|
||||
|
||||
return {
|
||||
setInitial: function(list) { current = (list || []).slice(); render(); },
|
||||
refresh: render,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,31 +379,21 @@ async function initDefaultChat() {
|
||||
|
||||
// Fill any <select> with the models for a given endpoint id.
|
||||
function fillModels(selectEl, epId, selected) {
|
||||
while (selectEl.options.length) selectEl.remove(0);
|
||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||
if (ep && ep.models) {
|
||||
ep.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (selected) selectEl.value = selected;
|
||||
_fillModelSelect(selectEl, ep ? ep.models : [], selected, false);
|
||||
}
|
||||
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
_endpoints = await epRes.json();
|
||||
enabledEndpoints().forEach(function(ep) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = ep.id;
|
||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
||||
epSel.appendChild(opt);
|
||||
});
|
||||
_endpoints = await _fetchModelEndpoints();
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, false);
|
||||
} catch (e) { console.warn('Failed to load endpoints for default chat', e); }
|
||||
|
||||
function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); }
|
||||
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
||||
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
||||
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
||||
renderFallbacks();
|
||||
}
|
||||
|
||||
// Render the fallback chain. Each row is endpoint + model + remove.
|
||||
function renderFallbacks() {
|
||||
@@ -409,6 +489,11 @@ async function initDefaultChat() {
|
||||
renderFallbacks();
|
||||
saveDefault();
|
||||
});
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_endpoints = endpoints;
|
||||
refreshEndpointOptions(epSel.value, modelSel.value);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Utility Model ── */
|
||||
@@ -417,35 +502,19 @@ async function initUtilityModel() {
|
||||
var modelSel = el('set-utilityModelSelect');
|
||||
var msg = el('set-utilityChatMsg');
|
||||
var _endpoints = [];
|
||||
var fallbackWidget = null;
|
||||
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
|
||||
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
|
||||
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
_endpoints = await epRes.json();
|
||||
_endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = ep.id;
|
||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
||||
epSel.appendChild(opt);
|
||||
});
|
||||
_endpoints = await _fetchModelEndpoints();
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
|
||||
|
||||
function refreshModels(selectedModel) {
|
||||
while (modelSel.options.length > 1) modelSel.remove(1);
|
||||
var epId = epSel.value;
|
||||
if (!epId) { modelSel.value = ''; return; }
|
||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||
if (ep && ep.models) {
|
||||
ep.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (selectedModel) modelSel.value = selectedModel;
|
||||
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -453,7 +522,7 @@ async function initUtilityModel() {
|
||||
var settings = await res.json();
|
||||
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
|
||||
refreshModels(settings.utility_model || '');
|
||||
_bindFallbackWidget({
|
||||
fallbackWidget = _bindFallbackWidget({
|
||||
containerId: 'set-utilityFallbacks',
|
||||
addBtnId: 'set-utilityAddFallback',
|
||||
endpoints: function() { return _endpoints; },
|
||||
@@ -483,6 +552,13 @@ async function initUtilityModel() {
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
|
||||
modelSel.addEventListener('change', saveUtility);
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_endpoints = endpoints;
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||
refreshModels(modelSel.value);
|
||||
if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Teacher Model ── */
|
||||
@@ -501,31 +577,14 @@ async function initTeacherModel() {
|
||||
var _endpoints = [];
|
||||
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
_endpoints = await epRes.json();
|
||||
_endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = ep.id;
|
||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
||||
epSel.appendChild(opt);
|
||||
});
|
||||
_endpoints = await _fetchModelEndpoints();
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||
} catch (e) { console.warn('Failed to load endpoints for teacher model', e); }
|
||||
|
||||
function refreshModels(selectedModel) {
|
||||
while (modelSel.options.length > 1) modelSel.remove(1);
|
||||
var epId = epSel.value;
|
||||
if (!epId) { modelSel.value = ''; return; }
|
||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||
if (ep && ep.models) {
|
||||
ep.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (selectedModel) modelSel.value = selectedModel;
|
||||
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||
}
|
||||
|
||||
// Disable / enable the endpoint+model dropdowns based on the
|
||||
@@ -595,6 +654,12 @@ async function initTeacherModel() {
|
||||
}
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveTeacher(); });
|
||||
modelSel.addEventListener('change', saveTeacher);
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_endpoints = endpoints;
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||
refreshModels(modelSel.value);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Image Generation ── */
|
||||
@@ -624,7 +689,7 @@ async function initImageSettings() {
|
||||
if (_isInpaintModel(mid)) imageModels.push(mid);
|
||||
});
|
||||
});
|
||||
imageModels.forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); });
|
||||
sortModelIds(imageModels).forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); });
|
||||
// Hardcoded fallbacks shown as "(not detected)" so users know what to
|
||||
// download/serve to enable inpaint here.
|
||||
['stable-diffusion-3.5-medium', 'stable-diffusion-inpainting'].forEach(mid => {
|
||||
@@ -666,6 +731,7 @@ async function initVisionSettings() {
|
||||
const enabledToggle = el('set-visionEnabledToggle');
|
||||
const configWrap = vlSel ? vlSel.closest('div[style*="flex-direction"]') : null;
|
||||
var _visionEndpoints = [];
|
||||
var visionFallbackWidget = null;
|
||||
var _vlExclude = ['audio', 'realtime', 'tts', 'dall-e', 'embedding', 'search', 'whisper'];
|
||||
function _isVisionModel(mid) {
|
||||
var lower = String(mid || '').toLowerCase();
|
||||
@@ -674,27 +740,30 @@ async function initVisionSettings() {
|
||||
try {
|
||||
const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
|
||||
const modelsData = await modelsRes.json();
|
||||
const visionModels = [];
|
||||
(modelsData.items || []).forEach(item => {
|
||||
if (item.offline) return;
|
||||
(item.models || []).forEach(mid => {
|
||||
if (_isVisionModel(mid)) {
|
||||
var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt);
|
||||
visionModels.push(mid);
|
||||
}
|
||||
});
|
||||
});
|
||||
sortModelIds(visionModels).forEach(mid => {
|
||||
var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for vision settings', e); }
|
||||
// Also pull the raw endpoint list so the fallback widget can resolve
|
||||
// endpoint-id → models the same way the other cards do.
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
_visionEndpoints = await epRes.json();
|
||||
_visionEndpoints = await _fetchModelEndpoints();
|
||||
} catch (e) { console.warn('Failed to load endpoints for vision fallback', e); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
if (settings.vision_model) vlSel.value = settings.vision_model;
|
||||
if (enabledToggle) enabledToggle.checked = settings.vision_enabled !== false;
|
||||
_bindFallbackWidget({
|
||||
visionFallbackWidget = _bindFallbackWidget({
|
||||
containerId: 'set-visionFallbacks',
|
||||
addBtnId: 'set-visionAddFallback',
|
||||
endpoints: function() { return _visionEndpoints; },
|
||||
@@ -725,6 +794,11 @@ async function initVisionSettings() {
|
||||
}
|
||||
vlSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); });
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_visionEndpoints = endpoints;
|
||||
if (visionFallbackWidget && visionFallbackWidget.refresh) visionFallbackWidget.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Face Recognition ── */
|
||||
@@ -1292,44 +1366,24 @@ async function initResearchSettings() {
|
||||
var modelSel = el('set-researchModel');
|
||||
var tokensInput = el('set-researchMaxTokens');
|
||||
var msg = el('set-researchMsg');
|
||||
var endpoints = [];
|
||||
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = ep.id;
|
||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
||||
epSel.appendChild(opt);
|
||||
});
|
||||
endpoints = await _fetchModelEndpoints();
|
||||
_fillEndpointSelect(epSel, endpoints, epSel.value, true);
|
||||
} catch (e) { console.warn('Failed to load endpoints for research', e); }
|
||||
|
||||
async function refreshModels(selectedModel) {
|
||||
function refreshModels(selectedModel) {
|
||||
var epId = epSel.value;
|
||||
while (modelSel.options.length > 1) modelSel.remove(1);
|
||||
if (!epId) { modelSel.value = ''; return; }
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
var ep = endpoints.find(function(e) { return e.id === epId; });
|
||||
if (ep && ep.models) {
|
||||
ep.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (selectedModel) modelSel.value = selectedModel;
|
||||
} catch (e) { /* ignore */ }
|
||||
var ep = endpoints.find(function(e) { return e.id === epId; });
|
||||
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||
}
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id;
|
||||
await refreshModels(settings.research_model || '');
|
||||
refreshModels(settings.research_model || '');
|
||||
if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
|
||||
} catch (e) { console.warn('Failed to load research settings', e); }
|
||||
|
||||
@@ -1371,11 +1425,17 @@ async function initResearchSettings() {
|
||||
}
|
||||
|
||||
epSel.addEventListener('change', async function() {
|
||||
await refreshModels('');
|
||||
refreshModels('');
|
||||
saveResearch();
|
||||
});
|
||||
modelSel.addEventListener('change', saveResearch);
|
||||
tokensInput.addEventListener('change', saveResearch);
|
||||
|
||||
_registerAiEndpointRefresh(function(nextEndpoints) {
|
||||
endpoints = nextEndpoints;
|
||||
_fillEndpointSelect(epSel, endpoints, epSel.value, true);
|
||||
refreshModels(modelSel.value);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Deep Research Search (Search tab) ── */
|
||||
@@ -4202,6 +4262,7 @@ export function open(tab) {
|
||||
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
|
||||
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
||||
syncAppearanceOpacity(activeTab === 'appearance');
|
||||
if (activeTab === 'ai') refreshAiModelEndpoints();
|
||||
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
window.adminModule._initData();
|
||||
}
|
||||
@@ -4226,7 +4287,7 @@ export function close() {
|
||||
}
|
||||
}
|
||||
|
||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility };
|
||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||
|
||||
|
||||
export default settingsModule;
|
||||
|
||||
@@ -47,13 +47,14 @@ const SETUP_PROVIDER_URLS = {
|
||||
deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
|
||||
openai: { name: 'OpenAI', url: 'https://api.openai.com/v1' },
|
||||
openrouter: { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1' },
|
||||
ollama: { name: 'Ollama Cloud', url: 'https://ollama.com/api' },
|
||||
xai: { name: 'xAI', url: 'https://api.x.ai/v1' },
|
||||
anthropic: { name: 'Anthropic', url: 'https://api.anthropic.com/v1' },
|
||||
groq: { name: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
||||
gemini: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||
google: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||
};
|
||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'xai', 'anthropic', 'groq', 'gemini'];
|
||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini'];
|
||||
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_NAMES[SETUP_PROVIDER_NAMES.length - 1];
|
||||
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
||||
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||
@@ -67,6 +68,8 @@ function _setupProviderFromInput(input) {
|
||||
openai: 'openai',
|
||||
chatgpt: 'openai',
|
||||
openrouter: 'openrouter',
|
||||
ollama: 'ollama',
|
||||
ollamacloud: 'ollama',
|
||||
anthropic: 'anthropic',
|
||||
claude: 'anthropic',
|
||||
groq: 'groq',
|
||||
@@ -84,6 +87,7 @@ function _extractSetupProviderCredential(input) {
|
||||
const providerAliases = [
|
||||
['deepseek ai', 'deepseek'], ['deepseek', 'deepseek'],
|
||||
['open router', 'openrouter'], ['openrouter', 'openrouter'],
|
||||
['ollama cloud', 'ollama'], ['ollama', 'ollama'],
|
||||
['open ai', 'openai'], ['openai', 'openai'], ['chatgpt', 'openai'],
|
||||
['anthropic', 'anthropic'], ['claude', 'anthropic'],
|
||||
['groq', 'groq'],
|
||||
@@ -488,8 +492,13 @@ function detectProvider(input) {
|
||||
for (const suffix of ['/models', '/chat/completions', '/completions', '/v1/messages']) {
|
||||
if (url.endsWith(suffix)) url = url.slice(0, -suffix.length).replace(/\/+$/, '');
|
||||
}
|
||||
url = url.replace(/\/api\/(chat|tags|generate)\/?$/i, '/api');
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname.endsWith('ollama.com')) url = 'https://ollama.com/api';
|
||||
} catch(e) {}
|
||||
// Add /v1 if bare host:port
|
||||
if (/^https?:\/\/[^/]+$/.test(url) && !url.includes('api.')) url += '/v1';
|
||||
if (/^https?:\/\/[^/]+$/.test(url) && !url.includes('api.') && !url.includes('ollama.com')) url += '/v1';
|
||||
return { base_url: url, api_key: '', name: '' };
|
||||
}
|
||||
// Known key patterns
|
||||
@@ -507,6 +516,13 @@ function detectProvider(input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function setupChatUrlForEndpoint(detected) {
|
||||
const base = (detected.base_url || '').replace(/\/+$/, '');
|
||||
if (detected.name === 'Anthropic') return base.replace(/\/v1$/, '') + '/v1/messages';
|
||||
if (base.includes('ollama.com')) return 'https://ollama.com/api/chat';
|
||||
return base + '/chat/completions';
|
||||
}
|
||||
|
||||
async function connectDetectedSetupEndpoint(detected) {
|
||||
const providerLabel = detected.name || 'custom endpoint';
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
@@ -555,7 +571,7 @@ async function connectDetectedSetupEndpoint(detected) {
|
||||
await typewriterReply(`Found ${count} model${count > 1 ? 's' : ''} on ${providerLabel}. Starting a chat...`);
|
||||
if (modelsModule) await modelsModule.refreshModels(true);
|
||||
const firstModel = data.models[0];
|
||||
const chatUrl = detected.base_url + (detected.name === 'Anthropic' ? '/v1/messages' : '/chat/completions');
|
||||
const chatUrl = setupChatUrlForEndpoint(detected);
|
||||
if (sessionModule) {
|
||||
await sessionModule.createDirectChat(chatUrl, firstModel, data.id);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -1259,7 +1260,7 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
||||
if (it.offline || !it.models || it.models.length === 0) continue;
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = it.endpoint_name || it.host || 'endpoint';
|
||||
const all = [...(it.models || []), ...(it.models_extra || [])];
|
||||
const all = sortModelIds([...(it.models || []), ...(it.models_extra || [])]);
|
||||
for (const m of all) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `${it.url}::${m}`;
|
||||
|
||||
Reference in New Issue
Block a user