Improve Ollama setup and model endpoint handling
This commit is contained in:
@@ -980,7 +980,12 @@
|
||||
<div class="model-picker-wrap" id="model-picker-wrap">
|
||||
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
|
||||
<div class="model-picker-menu hidden" id="model-picker-menu">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off">
|
||||
<div class="model-picker-search-row">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off">
|
||||
<button type="button" class="model-picker-action-btn primary" id="model-picker-add-models-btn" title="Add model endpoints" aria-label="Add model endpoints">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="model-picker-list" id="model-picker-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1963,7 +1968,7 @@
|
||||
<div data-settings-panel="services">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Add Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Connect to a cloud API or scan your network for a local model server.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Connect local models first, or add a cloud API.</div>
|
||||
|
||||
<!-- Local subsection -->
|
||||
<div class="adm-add-section collapsible collapsed" id="adm-add-local">
|
||||
@@ -1974,19 +1979,30 @@
|
||||
</div>
|
||||
<div class="admin-model-form">
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste an endpoint URL, e.g. http://localhost:8000/v1" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers
|
||||
</button>
|
||||
<span style="flex:1"></span>
|
||||
<select id="adm-epLocalType" style="padding:5px;width:80px;">
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1">
|
||||
<select id="adm-epLocalType" style="padding:5px;width:72px;flex-shrink:0;">
|
||||
<option value="llm">LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||
</div>
|
||||
<div class="adm-quickstart-section collapsed" id="adm-add-local-quickstart">
|
||||
<div class="adm-quickstart-toggle" role="button" tabindex="0" aria-expanded="false">
|
||||
<span>Quickstart</span>
|
||||
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<div class="adm-quickstart-body">
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint">Ollama</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2001,9 +2017,10 @@
|
||||
<!-- Custom picker (with logos). Hidden native <select> mirrors
|
||||
its value so the existing JS that reads adm-epProvider
|
||||
keeps working unchanged. -->
|
||||
<div class="adm-provider-picker" id="adm-provider-picker">
|
||||
<button type="button" class="adm-provider-btn" id="adm-provider-btn">
|
||||
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Custom URL</span></span>
|
||||
<div class="adm-provider-picker adm-provider-combo" id="adm-provider-picker">
|
||||
<input id="adm-epUrl" type="text" placeholder="Base URL or pick provider" autocomplete="off">
|
||||
<button type="button" class="adm-provider-btn" id="adm-provider-btn" title="Pick provider">
|
||||
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Provider</span></span>
|
||||
<svg class="adm-provider-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="adm-provider-menu hidden" id="adm-provider-menu"></div>
|
||||
@@ -2011,7 +2028,7 @@
|
||||
<select id="adm-epProvider" style="display:none">
|
||||
<option value="">Custom URL</option>
|
||||
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
|
||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek">DeepSeek</option>
|
||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
||||
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
|
||||
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
||||
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
||||
@@ -2022,21 +2039,19 @@
|
||||
<option value="https://api.x.ai/v1" data-logo="grok">xAI Grok</option>
|
||||
<option value="https://api.z.ai/api/paas/v4" data-logo="zhipu">Z.AI (Zhipu)</option>
|
||||
</select>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epUrl" type="text" placeholder="Base URL (e.g. https://api.example.com/v1)" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key">
|
||||
<select id="adm-epType" style="padding:5px;width:80px;">
|
||||
<option value="llm">LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="width:62px;text-align:center;">Cancel</button>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||
</div>
|
||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="adm-epMsg"></div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
131
static/style.css
131
static/style.css
@@ -2578,6 +2578,26 @@ body.bg-pattern-sparkles {
|
||||
animation: picker-roll-down 0.15s ease-in forwards;
|
||||
}
|
||||
.model-picker-menu.hidden { display: none; }
|
||||
.model-picker-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
transition: grid-template-columns 0.18s ease, gap 0.18s ease;
|
||||
}
|
||||
.model-picker-menu.no-models .model-picker-search-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.model-picker-search-row.searching {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
gap: 0;
|
||||
}
|
||||
.model-picker-search-row.searching .model-picker-action-btn {
|
||||
opacity: 0;
|
||||
transform: translateX(10px) scale(0.88);
|
||||
pointer-events: none;
|
||||
}
|
||||
.model-picker-menu input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -2589,8 +2609,8 @@ body.bg-pattern-sparkles {
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 4px;
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
transition: border-color 0.15s, padding 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.model-picker-menu input[type="text"]:focus {
|
||||
border-color: var(--red);
|
||||
@@ -2598,10 +2618,51 @@ body.bg-pattern-sparkles {
|
||||
.model-picker-menu input[type="text"]::placeholder {
|
||||
color: color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
}
|
||||
.model-picker-action-btn {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
color: color-mix(in srgb, var(--fg) 66%, transparent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transform: translateX(0) scale(1);
|
||||
transition: opacity 0.16s ease, transform 0.18s ease, border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.model-picker-action-btn:hover,
|
||||
.model-picker-action-btn:focus-visible {
|
||||
border-color: var(--red);
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--red) 10%, var(--panel));
|
||||
outline: none;
|
||||
}
|
||||
.model-picker-action-btn.primary {
|
||||
color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
}
|
||||
.model-picker-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
.model-picker-action-btn:hover svg,
|
||||
.model-picker-action-btn:focus-visible svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.model-picker-list {
|
||||
max-height: min(280px, 50dvh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.model-picker-list.is-empty {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-picker-list .model-switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2615,6 +2676,14 @@ body.bg-pattern-sparkles {
|
||||
.model-picker-list .model-switch-item:hover {
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
}
|
||||
.model-picker-list .model-switch-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 8px;
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.model-picker-list .mp-section-label {
|
||||
font-size: 0.72em;
|
||||
text-transform: uppercase;
|
||||
@@ -13102,9 +13171,54 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
/* Collapsed: hide the form body and point the caret right. */
|
||||
.adm-add-section.collapsed .admin-model-form { display: none; }
|
||||
.adm-add-section.collapsed .adm-section-caret { transform: rotate(-90deg); }
|
||||
.adm-quickstart-section {
|
||||
margin-top: 7px;
|
||||
}
|
||||
.adm-quickstart-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
opacity: 0.72;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: color-mix(in srgb, var(--fg) 3%, transparent);
|
||||
}
|
||||
.adm-quickstart-toggle:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--red);
|
||||
}
|
||||
.adm-quickstart-section:not(.collapsed) .adm-quickstart-toggle {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.adm-quickstart-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.adm-quickstart-section.collapsed .adm-quickstart-body { display: none; }
|
||||
.adm-quickstart-section.collapsed .adm-section-caret { transform: rotate(-90deg); }
|
||||
|
||||
/* Custom provider picker (logo + name) replacing the native <select> */
|
||||
.adm-provider-picker { position: relative; margin-bottom: 6px; }
|
||||
.adm-provider-combo {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.adm-provider-combo input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.adm-provider-btn {
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -13113,6 +13227,14 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
padding: 5px 8px; font-family: inherit; font-size: 12px;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.adm-provider-combo .adm-provider-btn {
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
border-left: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.adm-provider-btn:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--border)); }
|
||||
.adm-provider-current { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.adm-provider-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -13343,6 +13465,11 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
gap: 6px;
|
||||
}
|
||||
.admin-model-form-row input { flex: 1; }
|
||||
.adm-ep-inline-msg {
|
||||
min-height: 16px;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Endpoint items */
|
||||
.admin-ep-item {
|
||||
|
||||
Reference in New Issue
Block a user