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:
pewdiepie-archdaemon
2026-06-05 14:41:54 +09:00
parent fbd34334a5
commit 2ba77e3aa3
4 changed files with 114 additions and 22 deletions

View File

@@ -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();

View File

@@ -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', () => {

View File

@@ -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) {

View File

@@ -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> + &lt;&gt;.
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'],