feat(ai): add OpenRouter and Ollama Cloud providers (#231)

Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
Alexander Kenley
2026-06-01 15:26:10 +10:00
committed by GitHub
parent 4dbc0fe73a
commit 2c4b8b57dd
27 changed files with 699 additions and 169 deletions
+155 -94
View File
@@ -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;