Improve Ollama setup and model endpoint handling
This commit is contained in:
@@ -280,6 +280,51 @@ function _isLocalEndpoint(url) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function _refreshAfterEndpointChange(deletedEndpointId) {
|
||||
try {
|
||||
const sm = window.sessionModule;
|
||||
const pending = sm && sm.getPendingChat ? sm.getPendingChat() : null;
|
||||
if (deletedEndpointId && pending && String(pending.endpointId || '') === String(deletedEndpointId)) {
|
||||
if (sm.setPendingChat) sm.setPendingChat(null);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', {
|
||||
detail: { deletedEndpointId: deletedEndpointId || null }
|
||||
}));
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function _selectAddedModelInChat(endpoint) {
|
||||
const modelId = endpoint && Array.isArray(endpoint.models) ? endpoint.models[0] : '';
|
||||
if (!modelId) return;
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('odysseus:auto-select-model', {
|
||||
detail: {
|
||||
endpointId: endpoint.id || '',
|
||||
endpointName: endpoint.name || '',
|
||||
modelId,
|
||||
url: endpoint.base_url || '',
|
||||
}
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadEndpoints() {
|
||||
const listLocal = el('adm-epList-local');
|
||||
const listApi = el('adm-epList-api');
|
||||
@@ -306,7 +351,7 @@ async function loadEndpoints() {
|
||||
try { data = await res.json(); } catch { data = []; }
|
||||
}
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
const empty = '<div class="admin-empty">No endpoints configured</div>';
|
||||
const empty = '<div class="admin-empty">None</div>';
|
||||
if (listLocal) listLocal.innerHTML = empty;
|
||||
if (listApi) listApi.innerHTML = '<div class="admin-empty">None</div>';
|
||||
if (listLegacy) listLegacy.innerHTML = empty;
|
||||
@@ -319,9 +364,11 @@ async function loadEndpoints() {
|
||||
// empty, but we still need to render the expand panel so the user can
|
||||
// un-hide them. Gate on the total instead.
|
||||
const hasModels = ep.online && totalCount > 0;
|
||||
const statusBadge = ep.online
|
||||
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
|
||||
: '<span class="admin-badge admin-badge-off">offline</span>';
|
||||
const statusBadge = ep.status === 'empty'
|
||||
? '<span class="admin-badge">no models</span>'
|
||||
: ep.online
|
||||
? `<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' : '';
|
||||
return `
|
||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||||
@@ -417,7 +464,10 @@ async function loadEndpoints() {
|
||||
// Optimistic: remove from UI immediately
|
||||
const row = btn.closest('[data-adm-ep-id]');
|
||||
if (row) row.remove();
|
||||
fetch('/api/model-endpoints/' + epId, { method: 'DELETE' }).then(() => loadEndpoints()).catch(() => loadEndpoints());
|
||||
fetch('/api/model-endpoints/' + epId, { method: 'DELETE' })
|
||||
.then(() => _refreshAfterEndpointChange(epId))
|
||||
.then(() => loadEndpoints())
|
||||
.catch(() => loadEndpoints());
|
||||
});
|
||||
});
|
||||
// Clear the just-added marker now that the row has been rendered
|
||||
@@ -571,6 +621,7 @@ function initEndpointForm() {
|
||||
if (picker && pickerBtn && pickerMenu && pickerCurrent) {
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
if (provider.value && !urlInput.value) urlInput.value = provider.value;
|
||||
pickerBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
pickerMenu.classList.toggle('hidden');
|
||||
@@ -593,6 +644,13 @@ function initEndpointForm() {
|
||||
if (provider.value) urlInput.value = provider.value;
|
||||
else urlInput.value = '';
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
provider.value = '';
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
}
|
||||
});
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let u = raw.trim();
|
||||
// Fix common protocol typos
|
||||
@@ -623,15 +681,96 @@ function initEndpointForm() {
|
||||
return u;
|
||||
}
|
||||
|
||||
async function _defaultOllamaUrl() {
|
||||
try {
|
||||
const res = await fetch('/api/runtime', { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.ollama_base_url) return data.ollama_base_url;
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'http://127.0.0.1:11434/v1';
|
||||
}
|
||||
|
||||
function _renderEndpointTestResult(msg, res, d) {
|
||||
if (res.ok && d.status === 'empty') {
|
||||
msg.textContent = 'Online — no models found';
|
||||
msg.className = 'admin-success';
|
||||
return;
|
||||
}
|
||||
if (res.ok && d.online) {
|
||||
const models = d.models || [];
|
||||
const preview = models.slice(0, 3).map(m => esc(String(m).split('/').pop())).join(', ');
|
||||
msg.innerHTML = `Online — found ${models.length} model${models.length !== 1 ? 's' : ''}${preview ? `: ${preview}${models.length > 3 ? ', …' : ''}` : ''}`;
|
||||
msg.className = 'admin-success';
|
||||
return;
|
||||
}
|
||||
msg.textContent = (d && d.detail) || (d && d.ping_error ? `Offline — ${d.ping_error}` : 'Offline');
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
|
||||
function _endpointMsg(kind) {
|
||||
return el(kind === 'local' ? 'adm-epLocalMsg' : 'adm-epApiMsg') || el('adm-epMsg');
|
||||
}
|
||||
|
||||
let apiTestController = null;
|
||||
const apiTestBtn = el('adm-epApiTestBtn');
|
||||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||||
if (apiTestBtn) {
|
||||
apiTestBtn.addEventListener('click', async () => {
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
const apiKey = el('adm-epApiKey').value.trim();
|
||||
if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; }
|
||||
if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; }
|
||||
const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||||
apiTestController = new AbortController();
|
||||
apiTestBtn.disabled = true;
|
||||
apiTestBtn.textContent = 'Testing...';
|
||||
if (apiCancelTestBtn) apiCancelTestBtn.classList.remove('hidden');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const res = await fetch('/api/model-endpoints/test', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
signal: apiTestController.signal,
|
||||
});
|
||||
const d = await res.json();
|
||||
_renderEndpointTestResult(msg, res, d);
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') {
|
||||
msg.textContent = 'Test canceled';
|
||||
msg.className = '';
|
||||
} else {
|
||||
msg.textContent = 'Test failed: ' + (e && e.message ? e.message : 'request failed');
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
}
|
||||
apiTestController = null;
|
||||
apiTestBtn.disabled = false;
|
||||
apiTestBtn.textContent = 'Test';
|
||||
if (apiCancelTestBtn) apiCancelTestBtn.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
if (apiCancelTestBtn) {
|
||||
apiCancelTestBtn.addEventListener('click', () => {
|
||||
if (apiTestController) apiTestController.abort();
|
||||
});
|
||||
}
|
||||
|
||||
el('adm-epAddBtn').addEventListener('click', async () => {
|
||||
const msg = el('adm-epMsg');
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (provider.value || urlInput.value).trim();
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
const apiKey = el('adm-epApiKey').value.trim();
|
||||
if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; }
|
||||
if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; }
|
||||
// Normalize URL (fix typos, add /v1, strip wrong paths)
|
||||
const url = provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||||
const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||||
const btn = el('adm-epAddBtn');
|
||||
btn.disabled = true; btn.textContent = 'Adding...';
|
||||
try {
|
||||
@@ -640,7 +779,7 @@ function initEndpointForm() {
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const epType = el('adm-epType');
|
||||
if (epType) fd.append('model_type', epType.value);
|
||||
fd.append('skip_probe', 'true');
|
||||
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) {
|
||||
@@ -649,45 +788,17 @@ function initEndpointForm() {
|
||||
el('adm-epApiKey').value = ''; provider.value = '';
|
||||
if (epType) epType.value = 'llm';
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
loadEndpoints();
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d);
|
||||
if (!d.online) {
|
||||
msg.textContent = 'Added (endpoint offline — will retry on next load)';
|
||||
msg.className = 'admin-error';
|
||||
} else {
|
||||
msg.innerHTML = `Added — found ${count} model${count !== 1 ? 's' : ''}. `
|
||||
+ `<a href="#" id="adm-probe-now" style="text-decoration:underline;cursor:pointer;">Probe models?</a>`;
|
||||
} else if (d.status === 'empty') {
|
||||
msg.textContent = 'Added — endpoint reachable, no models found';
|
||||
msg.className = 'admin-success';
|
||||
} else {
|
||||
msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`;
|
||||
msg.className = 'admin-success';
|
||||
const probeLink = el('adm-probe-now');
|
||||
if (probeLink) {
|
||||
probeLink.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
msg.textContent = 'Probing models...';
|
||||
try {
|
||||
const es = new EventSource(`/api/model-endpoints/${d.id}/probe`);
|
||||
let lines = [];
|
||||
es.onmessage = (ev) => {
|
||||
const r = JSON.parse(ev.data);
|
||||
if (r.type === 'probe_result') {
|
||||
const dot = r.status === 'ok' ? '<span style="color:var(--color-success);">●</span>'
|
||||
: r.status === 'timeout' ? '<span style="color:var(--color-warning);">●</span>'
|
||||
: '<span style="color:var(--color-error);">●</span>';
|
||||
const lat = r.latency_ms ? ` ${r.latency_ms}ms` : '';
|
||||
const err = r.error ? ` — ${esc(r.error)}` : '';
|
||||
lines.push(`${dot} ${esc(r.model.split('/').pop())}${lat}${err}`);
|
||||
msg.innerHTML = `Probing... ${lines.length} checked<div style="font-size:0.78rem;margin-top:4px;">${lines.join('<br>')}</div>`;
|
||||
} else if (r.type === 'probe_done') {
|
||||
es.close();
|
||||
let txt = `Done — ${r.ok}/${r.ok + r.hidden} models responding`;
|
||||
if (r.hidden) txt += ` — ${r.hidden} non-responding hidden`;
|
||||
txt += `<div style="font-size:0.78rem;margin-top:4px;">${lines.join('<br>')}</div>`;
|
||||
msg.innerHTML = txt;
|
||||
loadEndpoints();
|
||||
}
|
||||
};
|
||||
es.onerror = () => { es.close(); msg.textContent += ' (probe connection lost)'; };
|
||||
} catch (e) { msg.textContent = 'Probe failed'; msg.className = 'admin-error'; }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
|
||||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||||
@@ -696,9 +807,33 @@ function initEndpointForm() {
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
const localAddBtn = el('adm-epLocalAddBtn');
|
||||
const localTestBtn = el('adm-epLocalTestBtn');
|
||||
if (localTestBtn) {
|
||||
localTestBtn.addEventListener('click', async () => {
|
||||
const msg = _endpointMsg('local');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
|
||||
const url = _normalizeBaseUrl(raw);
|
||||
localTestBtn.disabled = true;
|
||||
localTestBtn.textContent = 'Testing...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await res.json();
|
||||
_renderEndpointTestResult(msg, res, d);
|
||||
} catch (e) {
|
||||
msg.textContent = 'Test failed: ' + (e && e.message ? e.message : 'request failed');
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
localTestBtn.disabled = false;
|
||||
localTestBtn.textContent = 'Test';
|
||||
});
|
||||
}
|
||||
if (localAddBtn) {
|
||||
localAddBtn.addEventListener('click', async () => {
|
||||
const msg = el('adm-epMsg');
|
||||
const msg = _endpointMsg('local');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
|
||||
@@ -709,16 +844,19 @@ function initEndpointForm() {
|
||||
fd.append('base_url', url);
|
||||
const lt = el('adm-epLocalType');
|
||||
if (lt) fd.append('model_type', lt.value);
|
||||
fd.append('skip_probe', 'true');
|
||||
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) {
|
||||
el('adm-epLocalUrl').value = '';
|
||||
if (lt) lt.value = 'llm';
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
loadEndpoints();
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d);
|
||||
const count = (d.models || []).length;
|
||||
msg.textContent = d.online
|
||||
msg.textContent = d.status === 'empty'
|
||||
? 'Added — Ollama is running, no models pulled yet'
|
||||
: d.online
|
||||
? `Added — found ${count} model${count !== 1 ? 's' : ''}`
|
||||
: 'Added (offline — will retry on next load)';
|
||||
msg.className = d.online ? 'admin-success' : 'admin-error';
|
||||
@@ -728,11 +866,27 @@ function initEndpointForm() {
|
||||
});
|
||||
}
|
||||
|
||||
const ollamaBtn = el('adm-epOllamaBtn');
|
||||
if (ollamaBtn) {
|
||||
ollamaBtn.addEventListener('click', async () => {
|
||||
const input = el('adm-epLocalUrl');
|
||||
if (input) {
|
||||
input.value = await _defaultOllamaUrl();
|
||||
input.focus();
|
||||
}
|
||||
const msg = _endpointMsg('local');
|
||||
if (msg) {
|
||||
msg.innerHTML = '<span style="font-size:11px;opacity:0.55;">Ollama ready to test.</span>';
|
||||
msg.className = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Discover local models button
|
||||
const discoverBtn = el('adm-epDiscoverBtn');
|
||||
if (discoverBtn) {
|
||||
discoverBtn.addEventListener('click', async () => {
|
||||
const msg = el('adm-epMsg');
|
||||
const msg = _endpointMsg('local');
|
||||
discoverBtn.disabled = true;
|
||||
// Keep the button's icon as-is while scanning; the whirlpool +
|
||||
// status text below is enough feedback. (Two spinning indicators
|
||||
@@ -747,7 +901,7 @@ function initEndpointForm() {
|
||||
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
||||
wrap.appendChild(wp.element);
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = 'Scanning ports 8000-8020 for model servers...';
|
||||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
||||
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
wrap.appendChild(txt);
|
||||
msg.appendChild(wrap);
|
||||
@@ -758,7 +912,7 @@ function initEndpointForm() {
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, Ollama, or similar is running.';
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need OLLAMA_HOST=0.0.0.0:11434.';
|
||||
msg.className = 'admin-error';
|
||||
} else {
|
||||
// Auto-add each discovered endpoint
|
||||
@@ -767,7 +921,7 @@ function initEndpointForm() {
|
||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', base);
|
||||
fd.append('skip_probe', 'true');
|
||||
fd.append('skip_probe', 'false');
|
||||
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
||||
if (r.ok) {
|
||||
added++;
|
||||
@@ -813,6 +967,27 @@ function initEndpointForm() {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.adm-quickstart-section').forEach((sec) => {
|
||||
const head = sec.querySelector('.adm-quickstart-toggle');
|
||||
if (!head) return;
|
||||
const key = 'odysseus.addModels.' + sec.id + '.open';
|
||||
let open = false;
|
||||
try { open = localStorage.getItem(key) === '1'; } catch {}
|
||||
const apply = () => {
|
||||
sec.classList.toggle('collapsed', !open);
|
||||
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
apply();
|
||||
const toggle = () => {
|
||||
open = !open;
|
||||
try { localStorage.setItem(key, open ? '1' : '0'); } catch {}
|
||||
apply();
|
||||
};
|
||||
head.addEventListener('click', toggle);
|
||||
head.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user