|
|
|
|
@@ -371,7 +371,7 @@ async function loadEndpoints() {
|
|
|
|
|
const listLegacy = el('adm-epList');
|
|
|
|
|
// Refresh model picker so new endpoints show up in chat
|
|
|
|
|
if (window.modelsModule && window.modelsModule.refreshModels) {
|
|
|
|
|
window.modelsModule.refreshModels(true);
|
|
|
|
|
window.modelsModule.refreshModels();
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
|
|
|
|
window.sessionModule.updateModelPicker();
|
|
|
|
|
@@ -411,12 +411,15 @@ async function loadEndpoints() {
|
|
|
|
|
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
|
|
|
|
|
: '<span class="admin-badge admin-badge-off">offline</span>';
|
|
|
|
|
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
|
|
|
|
|
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
|
|
|
|
|
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
|
|
|
|
|
return `
|
|
|
|
|
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
|
|
|
|
|
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
|
|
|
|
|
<span class="admin-user-name">${esc(ep.name)}</span>
|
|
|
|
|
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
|
|
|
|
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
|
|
|
|
${statusBadge}
|
|
|
|
|
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
|
|
|
|
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
|
|
|
|
|
@@ -427,7 +430,7 @@ async function loadEndpoints() {
|
|
|
|
|
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="admin-ep-detail">${esc(ep.base_url)}${_isLocalEndpoint(ep.base_url) ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
|
|
|
|
|
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
|
|
|
|
|
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
|
|
|
|
|
</div>`;
|
|
|
|
|
});
|
|
|
|
|
@@ -446,7 +449,7 @@ async function loadEndpoints() {
|
|
|
|
|
container.innerHTML = indices.map(i => rowHtml[i]).join('');
|
|
|
|
|
};
|
|
|
|
|
const localIdx = [], apiIdx = [];
|
|
|
|
|
data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i));
|
|
|
|
|
data.forEach((ep, i) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? localIdx : apiIdx).push(i));
|
|
|
|
|
// Sort each section: enabled endpoints first, disabled at the bottom.
|
|
|
|
|
// Preserve original order within each group via stable sort.
|
|
|
|
|
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
|
|
|
|
|
@@ -552,22 +555,48 @@ async function loadEndpoints() {
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
panel.appendChild(_ld);
|
|
|
|
|
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
|
|
|
|
const models = await res.json();
|
|
|
|
|
_stopSpin();
|
|
|
|
|
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
|
|
|
|
|
const renderModels = (models, warning = '') => {
|
|
|
|
|
const sortedModels = sortModelObjects(models);
|
|
|
|
|
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
|
|
|
|
|
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
|
|
|
|
|
const attachRefresh = () => {
|
|
|
|
|
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
panel.innerHTML = _loadingHtml('Refreshing models...');
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/model-endpoints/${epId}/models?refresh=true&refresh_timeout=60`, { credentials: 'same-origin' });
|
|
|
|
|
const refreshWarning = res.headers.get('X-Model-Refresh-Warning') || '';
|
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
|
const refreshedModels = await res.json();
|
|
|
|
|
renderModels(refreshedModels, refreshWarning);
|
|
|
|
|
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
if (!sortedModels.length) {
|
|
|
|
|
panel.innerHTML = `<div class="mcp-tools-header">
|
|
|
|
|
<span>Models</span>
|
|
|
|
|
<span style="display:flex;gap:8px;align-items:center;">
|
|
|
|
|
<span class="mcp-tools-count">0/0 enabled</span>
|
|
|
|
|
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
|
|
|
|
</span>
|
|
|
|
|
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
|
|
|
|
|
attachRefresh();
|
|
|
|
|
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">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
|
|
|
|
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
|
|
|
|
<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 ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
|
|
|
|
</div>${warningHtml}${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>
|
|
|
|
|
@@ -580,6 +609,7 @@ async function loadEndpoints() {
|
|
|
|
|
row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none';
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
attachRefresh();
|
|
|
|
|
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
|
|
|
|
|
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
@@ -598,6 +628,13 @@ async function loadEndpoints() {
|
|
|
|
|
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
|
|
|
|
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
|
const models = await res.json();
|
|
|
|
|
_stopSpin();
|
|
|
|
|
renderModels(models);
|
|
|
|
|
} catch (e) { _stopSpin(); panel.innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>'; }
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
@@ -637,6 +674,7 @@ async function _saveEpModelState(epId, panel) {
|
|
|
|
|
function initEndpointForm() {
|
|
|
|
|
const provider = el('adm-epProvider');
|
|
|
|
|
const urlInput = el('adm-epUrl');
|
|
|
|
|
const kindSel = el('adm-epKind');
|
|
|
|
|
|
|
|
|
|
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
|
|
|
|
|
// so the rest of this function (which reads provider.value and dispatches
|
|
|
|
|
@@ -688,14 +726,20 @@ function initEndpointForm() {
|
|
|
|
|
provider.addEventListener('change', () => {
|
|
|
|
|
if (provider.value) urlInput.value = provider.value;
|
|
|
|
|
else urlInput.value = '';
|
|
|
|
|
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
|
|
|
|
});
|
|
|
|
|
urlInput.addEventListener('input', () => {
|
|
|
|
|
if (provider.value && urlInput.value.trim() !== provider.value) {
|
|
|
|
|
provider.value = '';
|
|
|
|
|
if (kindSel) kindSel.value = 'proxy';
|
|
|
|
|
_renderPickerMenu();
|
|
|
|
|
_syncPickerCurrent();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy');
|
|
|
|
|
function _apiEndpointKind() {
|
|
|
|
|
return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy');
|
|
|
|
|
}
|
|
|
|
|
function _normalizeBaseUrl(raw) {
|
|
|
|
|
let u = raw.trim();
|
|
|
|
|
// Fix common protocol typos
|
|
|
|
|
@@ -784,6 +828,8 @@ function initEndpointForm() {
|
|
|
|
|
try {
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append('base_url', url);
|
|
|
|
|
fd.append('endpoint_kind', _apiEndpointKind());
|
|
|
|
|
fd.append('model_refresh_timeout', '30');
|
|
|
|
|
if (apiKey) fd.append('api_key', apiKey);
|
|
|
|
|
const res = await fetch('/api/model-endpoints/test', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
@@ -828,6 +874,10 @@ function initEndpointForm() {
|
|
|
|
|
try {
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append('base_url', url);
|
|
|
|
|
const endpointKind = _apiEndpointKind();
|
|
|
|
|
fd.append('endpoint_kind', endpointKind);
|
|
|
|
|
fd.append('model_refresh_mode', endpointKind === 'proxy' ? 'manual' : 'auto');
|
|
|
|
|
fd.append('model_refresh_timeout', '30');
|
|
|
|
|
if (apiKey) fd.append('api_key', apiKey);
|
|
|
|
|
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
|
|
|
|
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
|
|
|
|
@@ -842,6 +892,7 @@ function initEndpointForm() {
|
|
|
|
|
const count = d.models ? d.models.length : 0;
|
|
|
|
|
urlInput.value = ''; urlInput.style.display = '';
|
|
|
|
|
el('adm-epApiKey').value = ''; provider.value = '';
|
|
|
|
|
if (kindSel) kindSel.value = 'proxy';
|
|
|
|
|
if (epType) epType.value = 'llm';
|
|
|
|
|
if (d.id) _recentlyAddedEpId = String(d.id);
|
|
|
|
|
await loadEndpoints();
|
|
|
|
|
@@ -904,6 +955,8 @@ function initEndpointForm() {
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append('base_url', url);
|
|
|
|
|
if (apiKey) fd.append('api_key', apiKey);
|
|
|
|
|
fd.append('endpoint_kind', 'local');
|
|
|
|
|
fd.append('model_refresh_mode', 'auto');
|
|
|
|
|
const lt = el('adm-epLocalType');
|
|
|
|
|
if (lt) fd.append('model_type', lt.value);
|
|
|
|
|
fd.append('skip_probe', 'false');
|
|
|
|
|
@@ -986,6 +1039,8 @@ function initEndpointForm() {
|
|
|
|
|
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append('base_url', base);
|
|
|
|
|
fd.append('endpoint_kind', 'local');
|
|
|
|
|
fd.append('model_refresh_mode', 'auto');
|
|
|
|
|
fd.append('skip_probe', 'false');
|
|
|
|
|
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|