mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 17:55:26 -04:00
Improve Ollama setup and model endpoint handling
This commit is contained in:
+227
-52
@@ -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(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
|
||||
+4
-6
@@ -452,9 +452,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
} else {
|
||||
addMessage('assistant',
|
||||
'No chat session active. You can:\n\n' +
|
||||
'- Pick a model from the sidebar to start a chat\n' +
|
||||
'- Run `/setup` to configure an endpoint\n' +
|
||||
'- Run `/new` to create a session manually\n' +
|
||||
'- Open the model picker in the chat box and pick a model\n' +
|
||||
'- Use the `+` button in the model picker to add a model endpoint\n' +
|
||||
'- Use `/help` to see all available commands');
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
@@ -462,9 +461,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
} catch (e) {
|
||||
addMessage('assistant',
|
||||
'No chat session active. You can:\n\n' +
|
||||
'- Pick a model from the sidebar to start a chat\n' +
|
||||
'- Run `/setup` to configure an endpoint\n' +
|
||||
'- Run `/new` to create a session manually\n' +
|
||||
'- Open the model picker in the chat box and pick a model\n' +
|
||||
'- Use the `+` button in the model picker to add a model endpoint\n' +
|
||||
'- Use `/help` to see all available commands');
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
|
||||
+136
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
import { providerLogo } from './providers.js';
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
@@ -31,6 +32,20 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
|
||||
// Dependencies injected via initModelPicker()
|
||||
let _deps = null;
|
||||
let _autoSelectingDefault = false;
|
||||
|
||||
function _modelExists(modelId, url) {
|
||||
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
|
||||
const items = window.modelsModule.getCachedItems() || [];
|
||||
if (!items.length) return true;
|
||||
const targetUrl = (url || '').replace(/\/+$/, '');
|
||||
return items.some(item => {
|
||||
if (item.offline) return false;
|
||||
const itemUrl = (item.url || '').replace(/\/+$/, '');
|
||||
const models = (item.models || []).concat(item.models_extra || []);
|
||||
return models.includes(modelId) && (!targetUrl || itemUrl === targetUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the model picker dropdown.
|
||||
@@ -52,6 +67,7 @@ function _initModelPickerDropdown() {
|
||||
const menu = document.getElementById('model-picker-menu');
|
||||
const search = document.getElementById('model-picker-search');
|
||||
const listEl = document.getElementById('model-picker-list');
|
||||
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
|
||||
if (!wrap || !btn || !menu || !search || !listEl) return;
|
||||
|
||||
function _close() {
|
||||
@@ -76,6 +92,27 @@ function _initModelPickerDropdown() {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function _openPickerShortcut(kind) {
|
||||
_close();
|
||||
try {
|
||||
if (kind === 'cookbook') {
|
||||
if (window.cookbookModule && typeof window.cookbookModule.open === 'function') {
|
||||
window.cookbookModule.open();
|
||||
} else {
|
||||
const btn = document.getElementById('tool-cookbook-btn') || document.getElementById('rail-cookbook');
|
||||
if (btn) btn.click();
|
||||
else location.hash = '#cookbook';
|
||||
}
|
||||
} else if (kind === 'settings') {
|
||||
if (settingsModule && typeof settingsModule.open === 'function') settingsModule.open();
|
||||
} else if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open('services');
|
||||
} else if (settingsModule && typeof settingsModule.open === 'function') {
|
||||
settingsModule.open('services');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Local endpoint health — only probed for LOCAL endpoints, since
|
||||
// cloud APIs are essentially always up. Cached briefly on the
|
||||
// server side too (8s TTL). Picker opens trigger a refresh.
|
||||
@@ -126,6 +163,15 @@ function _initModelPickerDropdown() {
|
||||
listEl.innerHTML = '';
|
||||
const all = _getAllModels();
|
||||
const q = (filter || '').toLowerCase();
|
||||
const hasAnyModel = all.length > 0;
|
||||
listEl.classList.toggle('is-empty', !hasAnyModel);
|
||||
menu.classList.toggle('no-models', !hasAnyModel);
|
||||
if (search) {
|
||||
search.placeholder = hasAnyModel ? 'Search models…' : 'No models connected';
|
||||
}
|
||||
if (searchRow) {
|
||||
searchRow.classList.toggle('searching', !!filter);
|
||||
}
|
||||
|
||||
// Load favorites
|
||||
const favs = (function() { try { return JSON.parse(localStorage.getItem('odysseus-model-favorites') || '[]'); } catch { return []; } })();
|
||||
@@ -192,7 +238,11 @@ function _initModelPickerDropdown() {
|
||||
if (listEl.children.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'model-switch-empty';
|
||||
empty.textContent = 'No models available';
|
||||
if (hasAnyModel) {
|
||||
empty.textContent = 'No matching models';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
listEl.appendChild(empty);
|
||||
}
|
||||
}
|
||||
@@ -249,12 +299,62 @@ function _initModelPickerDropdown() {
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
}
|
||||
|
||||
document.addEventListener('odysseus:auto-select-model', async (e) => {
|
||||
const detail = (e && e.detail) || {};
|
||||
const currentSessionId = _deps.getCurrentSessionId();
|
||||
const sessions = _deps.getSessions();
|
||||
const current = sessions.find(x => x.id === currentSessionId);
|
||||
const pending = _deps.getPendingChat();
|
||||
if ((current && current.model) || (pending && pending.modelId)) return;
|
||||
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
try { await window.modelsModule.refreshModels(true); } catch (_) {}
|
||||
}
|
||||
const items = window.modelsModule && window.modelsModule.getCachedItems ? window.modelsModule.getCachedItems() : [];
|
||||
const targetEndpointId = detail.endpointId ? String(detail.endpointId) : '';
|
||||
const targetModel = detail.modelId || '';
|
||||
let match = null;
|
||||
for (const item of items) {
|
||||
if (item.offline) continue;
|
||||
if (targetEndpointId && String(item.endpoint_id || '') !== targetEndpointId) continue;
|
||||
const models = (item.models || []).concat(item.models_extra || []);
|
||||
const displays = (item.models_display || []).concat(item.models_extra_display || []);
|
||||
const idx = targetModel ? models.indexOf(targetModel) : (models.length ? 0 : -1);
|
||||
if (idx >= 0) {
|
||||
match = {
|
||||
mid: models[idx],
|
||||
display: (displays[idx] || models[idx]).split('/').pop(),
|
||||
url: item.url || detail.url || '',
|
||||
endpointId: item.endpoint_id || detail.endpointId || '',
|
||||
epName: item.endpoint_name || detail.endpointName || '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match && detail.modelId && detail.url) {
|
||||
match = {
|
||||
mid: detail.modelId,
|
||||
display: String(detail.modelId).split('/').pop(),
|
||||
url: detail.url,
|
||||
endpointId: detail.endpointId || '',
|
||||
epName: detail.endpointName || '',
|
||||
};
|
||||
}
|
||||
if (match) await _pick(match);
|
||||
});
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.classList.contains('hidden') || menu.classList.contains('closing')) {
|
||||
// Force-clear any in-progress close animation
|
||||
menu.classList.remove('closing', 'hidden');
|
||||
_populate('');
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true).then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Kick off a local-endpoint probe — when it returns, re-render
|
||||
// the list so stale local servers get dimmed. Cloud entries
|
||||
// aren't probed; they stay visible.
|
||||
@@ -275,6 +375,13 @@ function _initModelPickerDropdown() {
|
||||
search.addEventListener('keydown', (e) => {
|
||||
_handlePickerKeydown(e, listEl, '.model-switch-item', _close);
|
||||
});
|
||||
const addModelsBtn = document.getElementById('model-picker-add-models-btn');
|
||||
if (addModelsBtn) {
|
||||
addModelsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_openPickerShortcut('models');
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
|
||||
_close();
|
||||
@@ -310,8 +417,15 @@ export function updateModelPicker() {
|
||||
let modelId = null;
|
||||
if (s && s.model) {
|
||||
modelId = s.model;
|
||||
if (!_modelExists(modelId, s.endpoint_url || '')) {
|
||||
modelId = null;
|
||||
}
|
||||
} else if (_pendingChat && _pendingChat.modelId) {
|
||||
modelId = _pendingChat.modelId;
|
||||
if (!_modelExists(modelId, _pendingChat.url || '')) {
|
||||
_deps.setPendingChat(null);
|
||||
modelId = null;
|
||||
}
|
||||
}
|
||||
// SECURITY: deliberately NOT auto-injecting `odysseus-model-favorites[0]`
|
||||
// here. localStorage favorites are per-browser, not per-user, so on a
|
||||
@@ -338,6 +452,27 @@ export function updateModelPicker() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const items = window.modelsModule.getCachedItems();
|
||||
const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
|
||||
if (first) {
|
||||
const models = (first.models || []).concat(first.models_extra || []);
|
||||
modelId = models[0];
|
||||
if (!currentSessionId) {
|
||||
_deps.setPendingChat({ url: first.url, modelId, endpointId: first.endpoint_id });
|
||||
} else {
|
||||
if (s) { s.model = modelId; s.endpoint_url = first.url; }
|
||||
_autoSelectingDefault = true;
|
||||
const fd = new FormData();
|
||||
fd.append('model', modelId);
|
||||
fd.append('endpoint_url', first.url || '');
|
||||
if (first.endpoint_id) fd.append('endpoint_id', first.endpoint_id);
|
||||
fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd })
|
||||
.catch(() => {})
|
||||
.finally(() => { _autoSelectingDefault = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = modelId ? modelId.split('/').pop() : 'Select model';
|
||||
const logo = modelId ? providerLogo(modelId) : null;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
// All SVGs use viewBox="0 0 24 24" fill="currentColor"
|
||||
|
||||
const _PROVIDERS = [
|
||||
// Ollama
|
||||
[/ollama|:11434/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c-3.1 0-5.65 2.43-5.86 5.48A6.62 6.62 0 0 0 3 13.62C3 18 6.8 21.5 12 21.5s9-3.5 9-7.88a6.62 6.62 0 0 0-3.14-5.64C17.65 4.93 15.1 2.5 12 2.5Zm-2.7 8.25a1.15 1.15 0 1 1 0 2.3 1.15 1.15 0 0 1 0-2.3Zm5.4 0a1.15 1.15 0 1 1 0 2.3 1.15 1.15 0 0 1 0-2.3Zm-5.15 5.15c.75.7 1.55 1.04 2.45 1.04s1.7-.34 2.45-1.04c.26-.24.66-.23.9.03.24.26.23.66-.03.9-.98.91-2.08 1.37-3.32 1.37s-2.34-.46-3.32-1.37a.64.64 0 0 1-.03-.9.64.64 0 0 1 .9-.03Z"/></svg>'],
|
||||
|
||||
// OpenAI — GPT, o1, o3, dall-e, chatgpt
|
||||
[/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>'],
|
||||
|
||||
+14
-2
@@ -3117,13 +3117,14 @@ async function initUnifiedIntegrations() {
|
||||
<div class="settings-row"><label class="settings-label">Preset</label><select id="uf-api-preset" class="settings-select"><option value="">Custom (no preset)</option>${selectOpts}</select></div>
|
||||
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div>
|
||||
<div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div>
|
||||
<div id="uf-api-ntfy-hint" style="display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;"></div>
|
||||
<div class="settings-row"><label class="settings-label">Auth${_apiHint('How this service expects the credential to be sent. <b>Bearer</b> = sends "Authorization: Bearer YOUR_KEY" (most modern APIs, ntfy, OpenAI-style). <b>Header</b> = sends YOUR_KEY verbatim under a header name you choose (Miniflux uses X-Auth-Token). <b>Basic</b> = HTTP basic auth (user:pass). <b>None</b> = the API is open / no auth.')}</label><select id="uf-api-auth" class="settings-input"><option value="bearer">Bearer (most common)</option><option value="header">Header</option><option value="basic">Basic</option><option value="none">None</option></select></div>
|
||||
<div class="settings-row" id="uf-api-header-row"><label class="settings-label">Header${_apiHint('The HTTP header name the key goes under (Miniflux: X-Auth-Token; most others: Authorization). Only used when Auth = Header.')}</label><input id="uf-api-header" class="settings-input" placeholder="X-Auth-Token"></div>
|
||||
<div class="settings-row"><label class="settings-label">API Key${_apiHint('The secret token the service issued you (generated in its admin panel / settings). Used to prove your identity on each request. Required for any Auth mode except None.')}</label><input id="uf-api-key" class="settings-input" type="password" placeholder="Token/key"></div>
|
||||
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-api-save">Save</button><button class="admin-btn-sm" id="uf-api-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-api-cancel" style="opacity:0.7">Cancel</button><span id="uf-api-msg" style="font-size:11px"></span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key');
|
||||
const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint');
|
||||
let _editId = editId && editId !== 'new' ? editId : null;
|
||||
// Load existing
|
||||
if (_editId) {
|
||||
@@ -3138,12 +3139,23 @@ async function initUnifiedIntegrations() {
|
||||
// no typed-name → key lookup is needed (datalist-era leftover).
|
||||
const _applyPreset = () => {
|
||||
const p = presets[preset.value];
|
||||
const isNtfy = preset.value === 'ntfy' || (p && (p.name || '').toLowerCase() === 'ntfy');
|
||||
if (ntfyHint) {
|
||||
ntfyHint.style.display = isNtfy ? 'block' : 'none';
|
||||
if (isNtfy) {
|
||||
ntfyHint.innerHTML = 'Enter the ntfy server URL Odysseus can reach. Examples: <code>http://127.0.0.1:8091</code>, <code>http://100.x.y.z:8091</code>, or <code>https://ntfy.example.com</code>.';
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : 'http://localhost:8080';
|
||||
}
|
||||
if (!p) return;
|
||||
name.value = p.name || '';
|
||||
auth.value = p.auth_type || 'none';
|
||||
header.value = p.auth_header || '';
|
||||
};
|
||||
preset.addEventListener('change', _applyPreset);
|
||||
_applyPreset();
|
||||
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-api-save').addEventListener('click', async () => {
|
||||
const presetKey = preset.value || undefined;
|
||||
@@ -3176,7 +3188,7 @@ async function initUnifiedIntegrations() {
|
||||
el('uf-api-msg').textContent = d.message || 'Connected';
|
||||
el('uf-api-msg').style.color = 'var(--green,#50fa7b)';
|
||||
} else {
|
||||
el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 200);
|
||||
el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 360);
|
||||
el('uf-api-msg').style.color = 'var(--red)';
|
||||
}
|
||||
} catch (e) { el('uf-api-msg').textContent = 'Error: ' + e.message; el('uf-api-msg').style.color = 'var(--red)'; }
|
||||
|
||||
@@ -823,7 +823,7 @@ async function _cmdSessionNew(args, ctx) {
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (!endpointUrl || !model) {
|
||||
slashReply('No model available — pick one from the sidebar or run <code>/setup</code> to configure an endpoint');
|
||||
slashReply('No model available — open the model picker and use the <code>+</code> button to add a model endpoint.');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user