Improve Ollama setup and model endpoint handling

This commit is contained in:
pewdiepie-archdaemon
2026-06-01 10:00:15 +09:00
parent 051751adcd
commit fc7f107b22
22 changed files with 982 additions and 131 deletions
+227 -52
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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)'; }
+1 -1
View File
@@ -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;
}