Settings polish: /setup provider subs, Add API defaults to api kind, picker shows offline endpoints, doc library tracks sub-tab
- /setup gains explicit provider subcommands (deepseek, openai, anthropic, openrouter, groq, gemini, xai, ollama, copilot, local, endpoint) so the autocomplete popup surfaces "/setup de…" suggestions with format hints, and bare-provider invocations still prompt for the key. - Add API endpoint defaults to kind=api (auto-refresh /v1/models) instead of kind=proxy. Proxy was a frequent footgun for OpenAI- compatible endpoints that DO serve /v1/models — the user got an empty model list and had to flip the dropdown. - Model picker now includes offline endpoints with stale:true so a briefly-down local server doesn't vanish from the picker (it dims and shows the offline pill, clickable anyway). Dedup prefers the online entry when the same model is exposed by both. - Document library modal header reflects the active sub-tab via _TAB_HEADERS so it no longer shows the wrong section name when switching between Documents / Skills / Templates.
This commit is contained in:
@@ -731,14 +731,14 @@ function initEndpointForm() {
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
provider.value = '';
|
||||
if (kindSel) kindSel.value = 'proxy';
|
||||
if (kindSel) kindSel.value = 'api';
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
}
|
||||
});
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy');
|
||||
if (kindSel) kindSel.value = kindSel.value || 'api';
|
||||
function _apiEndpointKind() {
|
||||
return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy');
|
||||
return (kindSel && kindSel.value) ? kindSel.value : 'api';
|
||||
}
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let u = raw.trim();
|
||||
|
||||
@@ -1598,7 +1598,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Library</h4>
|
||||
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
|
||||
Documents / Research / Archive) so the user sees ONE icon at
|
||||
the top representing the section they're in, with the tab
|
||||
strip below as sub-navigation. _switchLibTab() updates this. -->
|
||||
<h4 id="doclib-header-title"><span id="doclib-header-icon" style="vertical-align:-2px;margin-right:4px;display:inline-flex;"></span><span id="doclib-header-text">Library</span></h4>
|
||||
<button class="close-btn" id="doclib-close">\u2716</button>
|
||||
</div>
|
||||
<div class="lib-tabs" id="doclib-lib-tabs" style="padding:0 10px;">
|
||||
@@ -1831,6 +1835,27 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
grid.parentElement.appendChild(btn);
|
||||
}
|
||||
|
||||
// SVG markup + label for each tab — used to keep the modal header
|
||||
// in sync with whichever sub-tab the user is on.
|
||||
const _TAB_HEADERS = {
|
||||
chats: {
|
||||
label: 'Chats',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
},
|
||||
documents: {
|
||||
label: 'Documents',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>',
|
||||
},
|
||||
research: {
|
||||
label: 'Research',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
||||
},
|
||||
archive: {
|
||||
label: 'Archive',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
|
||||
},
|
||||
};
|
||||
|
||||
function _switchLibTab(tab) {
|
||||
_activeLibTab = tab;
|
||||
_tabBtns.forEach(b => b.classList.toggle('active', b.dataset.doclibTab === tab));
|
||||
@@ -1841,6 +1866,14 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
p.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Sync the modal header icon + label to match the active sub-tab.
|
||||
const hdr = _TAB_HEADERS[tab];
|
||||
if (hdr) {
|
||||
const ico = document.getElementById('doclib-header-icon');
|
||||
const txt = document.getElementById('doclib-header-text');
|
||||
if (ico) ico.innerHTML = hdr.svg;
|
||||
if (txt) txt.textContent = hdr.label;
|
||||
}
|
||||
if (tab === 'chats') _renderLibChats();
|
||||
else if (tab === 'archive') _renderLibArchive();
|
||||
else if (tab === 'research') _renderLibResearch();
|
||||
@@ -3121,8 +3154,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Switch to initial tab if not documents
|
||||
if (_activeLibTab !== 'documents') _switchLibTab(_activeLibTab);
|
||||
// Switch to the initial tab. Always call this — even when the
|
||||
// default ('documents') matches — so the modal header's icon + label
|
||||
// sync from "Library" to the active sub-tab on first open.
|
||||
_switchLibTab(_activeLibTab);
|
||||
|
||||
const searchInput = document.getElementById('doclib-search');
|
||||
searchInput.addEventListener('input', () => {
|
||||
|
||||
@@ -179,14 +179,23 @@ function _initModelPickerDropdown() {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
items.forEach(item => {
|
||||
if (item.offline) return;
|
||||
// Previously: offline endpoints were skipped entirely, so a server
|
||||
// that briefly went down disappeared from the picker — confusing
|
||||
// when the user can still see it (offline-tagged) in Settings.
|
||||
// Now: include offline-endpoint models too but flag them
|
||||
// `stale: true` so the row renderer dims them + shows the offline
|
||||
// pill. The user can still click and try anyway (matches the
|
||||
// existing "local server appears offline" path on line 301).
|
||||
const epOffline = !!item.offline;
|
||||
const allModels = (item.models || []).concat(item.models_extra || []);
|
||||
const allDisplay = (item.models_display || []).concat(item.models_extra_display || []);
|
||||
// Mark local endpoints whose live probe failed.
|
||||
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
|
||||
const isLocalDead = !!(probeResult && probeResult.alive === false);
|
||||
allModels.forEach((mid, i) => {
|
||||
// Deduplicate by model ID — prefer DB endpoints over env-discovered
|
||||
// Deduplicate by model ID — prefer ONLINE endpoint entries over
|
||||
// offline duplicates so the user gets a working endpoint first
|
||||
// when the same model is exposed by both.
|
||||
if (seen.has(mid)) return;
|
||||
seen.add(mid);
|
||||
result.push({
|
||||
@@ -201,8 +210,11 @@ function _initModelPickerDropdown() {
|
||||
item.host || '',
|
||||
item.url || '',
|
||||
].filter(Boolean).join(' '),
|
||||
stale: isLocalDead,
|
||||
staleReason: isLocalDead ? (probeResult.error || 'not responding') : '',
|
||||
stale: isLocalDead || epOffline,
|
||||
staleReason: epOffline
|
||||
? (item.ping_error || 'endpoint offline')
|
||||
: (isLocalDead ? (probeResult.error || 'not responding') : ''),
|
||||
offline: epOffline,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -377,22 +389,35 @@ function _initModelPickerDropdown() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Browse mode: Recent (auto) + Favorites (manual). No flat "All" dump. ──
|
||||
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
|
||||
// Rules:
|
||||
// 1. Never list the same model twice in the dropdown. Favorites
|
||||
// win over Recent (if you favorited it, that's where it
|
||||
// belongs — Recent shouldn't show it again as duplicate).
|
||||
// 2. Small catalogs (≤ BROWSE_ALL_LIMIT total) skip the Recent
|
||||
// section entirely — when there's only ~10 models, the whole
|
||||
// list fits below as "All models" and a separate Recent
|
||||
// section just duplicates rows.
|
||||
const shown = new Set();
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, RECENT_MAX);
|
||||
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
|
||||
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
if (favModels.length) {
|
||||
_addSection('Favorites');
|
||||
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
// Recent: only render when the catalog is big enough that surfacing
|
||||
// a recency shortlist is actually useful, AND only models that
|
||||
// aren't already in Favorites (dedupe).
|
||||
if (all.length > BROWSE_ALL_LIMIT) {
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.filter(m => !shown.has(m.mid))
|
||||
.slice(0, RECENT_MAX);
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
}
|
||||
|
||||
// Small catalogs: still list everything so users aren't forced to search.
|
||||
if (all.length <= BROWSE_ALL_LIMIT) {
|
||||
|
||||
@@ -4815,7 +4815,15 @@ async function _cmdSetup(args, ctx) {
|
||||
} else {
|
||||
pendingSetupProvider = provider;
|
||||
setupMode = 'endpoint-key-for-provider';
|
||||
await _setupReply(`Paste your ${provider.name} API key.`);
|
||||
// Show the canonical "/setup <provider> <key>" usage so the user
|
||||
// learns the one-shot form instead of relying on the pasted-key
|
||||
// mode that always greets them with a generic prompt.
|
||||
// _setupReply renders as plain text (no HTML) — use markdown
|
||||
// backticks for the inline code instead of <code> + <>.
|
||||
const _slug = (topic || '').toLowerCase();
|
||||
await _setupReply(
|
||||
`Paste your ${provider.name} API key, or run \`/setup ${_slug} <api-key>\` to set it in one step.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -5538,7 +5546,31 @@ const COMMANDS = {
|
||||
category: 'Getting started',
|
||||
help: 'Add local or API model endpoints',
|
||||
handler: _cmdSetup,
|
||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint'
|
||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint',
|
||||
// Provider subs so the autocomplete popup surfaces "/setup deepseek",
|
||||
// "/setup openai", etc. when the user types "/setup de". Each sub's
|
||||
// handler is a thin wrapper that re-prepends the sub name and
|
||||
// re-dispatches into _cmdSetup, which already knows how to handle
|
||||
// bare-provider (prompts for the key) AND provider-with-key (saves it).
|
||||
// Without the explicit handler, the slash-dispatcher errors with
|
||||
// "subDef.handler is not a function".
|
||||
subs: {
|
||||
deepseek: { help: 'DeepSeek', usage: '/setup deepseek sk-...', handler: (a, c) => _cmdSetup(['deepseek', ...a], c) },
|
||||
openai: { help: 'OpenAI', usage: '/setup openai sk-proj-...', handler: (a, c) => _cmdSetup(['openai', ...a], c) },
|
||||
anthropic: { help: 'Anthropic', usage: '/setup anthropic sk-ant-...',handler: (a, c) => _cmdSetup(['anthropic', ...a], c) },
|
||||
openrouter: { help: 'OpenRouter', usage: '/setup openrouter sk-or-...',handler: (a, c) => _cmdSetup(['openrouter', ...a], c) },
|
||||
groq: { help: 'Groq', usage: '/setup groq gsk_...', handler: (a, c) => _cmdSetup(['groq', ...a], c) },
|
||||
gemini: { help: 'Google Gemini', alias: ['google'], usage: '/setup gemini AIza...', handler: (a, c) => _cmdSetup(['gemini', ...a], c) },
|
||||
xai: { help: 'xAI (Grok)', alias: ['grok'], usage: '/setup xai xai-...', handler: (a, c) => _cmdSetup(['xai', ...a], c) },
|
||||
ollama: { help: 'Ollama Cloud', usage: '/setup ollama KEY', handler: (a, c) => _cmdSetup(['ollama', ...a], c) },
|
||||
copilot: { help: 'GitHub Copilot', usage: '/setup copilot', handler: (a, c) => _cmdSetup(['copilot', ...a], c) },
|
||||
local: { help: 'Local model server (vLLM / LM Studio / llama.cpp / Ollama)',
|
||||
usage: '/setup local http://localhost:8000/v1',
|
||||
handler: (a, c) => _cmdSetup(['local', ...a], c) },
|
||||
endpoint: { help: 'Open the endpoint manager in Settings',
|
||||
usage: '/setup endpoint',
|
||||
handler: (a, c) => _cmdSetup(['endpoint', ...a], c) },
|
||||
},
|
||||
},
|
||||
demo: {
|
||||
alias: ['tour'],
|
||||
|
||||
Reference in New Issue
Block a user