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

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>`;
}

View File

@@ -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 ──

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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
View 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);
}

View File

@@ -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;

View File

@@ -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>'],

View File

@@ -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;

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;

View File

@@ -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);
}

View File

@@ -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}`;