feat(models): allow viewing and editing search vendor credentials

This commit is contained in:
zhayujie
2026-05-21 20:22:09 +08:00
parent b7734c3926
commit 90773ab69f
3 changed files with 131 additions and 45 deletions

View File

@@ -474,6 +474,11 @@
<i class="fas fa-microchip text-primary-500 text-sm"></i> <i class="fas fa-microchip text-primary-500 text-sm"></i>
</div> </div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">模型配置</h3> <h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">模型配置</h3>
<a class="ml-auto text-xs text-slate-500 dark:text-slate-400 hover:text-primary-500 dark:hover:text-primary-400 cursor-pointer transition-colors flex items-center gap-1"
onclick="navigateTo('models')">
<span data-i18n="config_model_advanced">高级配置</span>
<i class="fas fa-arrow-right text-[10px]"></i>
</a>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<!-- Provider --> <!-- Provider -->
@@ -1084,7 +1089,7 @@
</div> </div>
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl"> <div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="vendor-modal-clear" <button id="vendor-modal-clear"
class="px-3 py-2 rounded-lg text-xs font-medium class="px-3 py-2 rounded-lg text-xs
text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden" cursor-pointer transition-colors duration-150 hidden"
data-i18n="models_clear_credential">清除凭据</button> data-i18n="models_clear_credential">清除凭据</button>

View File

@@ -58,7 +58,8 @@ const I18N = {
models_search_add_provider: '添加厂商', models_search_add_provider: '添加厂商',
models_search_add_desc: '选择一个搜索厂商进行配置', models_search_add_desc: '选择一个搜索厂商进行配置',
models_search_bocha_title: '配置博查 API Key', models_search_bocha_title: '配置博查 API Key',
models_search_bocha_desc: '前往博查开放平台创建 API Key', models_search_bocha_desc: '前往 <a href="https://open.bochaai.com" target="_blank" class="text-primary-500 hover:text-primary-600 underline">博查开放平台</a> 创建 API Key',
models_search_edit_hint: '点击修改配置',
models_unavailable: '不可用', models_unavailable: '不可用',
models_set_via_env: '通过环境变量启用', models_set_via_env: '通过环境变量启用',
models_dim_label: '维度', models_dim_label: '维度',
@@ -93,6 +94,7 @@ const I18N = {
input_placeholder: '输入消息,或输入 / 使用指令', input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置',
config_model_advanced: '高级配置',
config_channel: '通道配置', config_channel: '通道配置',
config_agent_enabled: 'Agent 模式', config_agent_enabled: 'Agent 模式',
config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理', config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理',
@@ -230,7 +232,8 @@ const I18N = {
models_search_add_provider: 'Add provider', models_search_add_provider: 'Add provider',
models_search_add_desc: 'Pick a search provider to configure', models_search_add_desc: 'Pick a search provider to configure',
models_search_bocha_title: 'Configure Bocha API Key', models_search_bocha_title: 'Configure Bocha API Key',
models_search_bocha_desc: 'Create a key at the Bocha open platform: ', models_search_bocha_desc: 'Create a key at the <a href="https://open.bochaai.com" target="_blank" class="text-primary-500 hover:text-primary-600 underline">Bocha open platform</a>.',
models_search_edit_hint: 'Click to edit',
models_unavailable: 'unavailable', models_unavailable: 'unavailable',
models_set_via_env: 'enable via environment variable', models_set_via_env: 'enable via environment variable',
models_dim_label: 'dim', models_dim_label: 'dim',
@@ -265,6 +268,7 @@ const I18N = {
input_placeholder: 'Type a message, or press / for commands', input_placeholder: 'Type a message, or press / for commands',
config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_title: 'Configuration', config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration', config_agent: 'Agent Configuration', config_model: 'Model Configuration', config_agent: 'Agent Configuration',
config_model_advanced: 'Advanced',
config_channel: 'Channel Configuration', config_channel: 'Channel Configuration',
config_agent_enabled: 'Agent Mode', config_agent_enabled: 'Agent Mode',
config_max_tokens: 'Max Context Tokens', config_max_tokens_hint: 'Max tokens the Agent can input per conversation, auto-compressed when exceeded', config_max_tokens: 'Max Context Tokens', config_max_tokens_hint: 'Max tokens the Agent can input per conversation, auto-compressed when exceeded',
@@ -4094,10 +4098,13 @@ function _renderSearchSummary(body, cap) {
`; `;
} else { } else {
const chips = configured.map(p => ` const chips = configured.map(p => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md <button type="button" data-search-edit-provider="${p.id}"
bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"> title="${t('models_search_edit_hint')}"
class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md cursor-pointer
bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400
hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors">
<i class="fas fa-check text-[10px]"></i>${escapeHtml(p.label)} <i class="fas fa-check text-[10px]"></i>${escapeHtml(p.label)}
</span> </button>
`).join(''); `).join('');
host.innerHTML = ` host.innerHTML = `
<div class="flex items-center flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400"> <div class="flex items-center flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
@@ -4108,13 +4115,21 @@ function _renderSearchSummary(body, cap) {
`; `;
} }
const btn = host.querySelector('#cap-search-add-btn'); const addBtnEl = host.querySelector('#cap-search-add-btn');
if (btn) { if (addBtnEl) {
btn.addEventListener('click', (ev) => { addBtnEl.addEventListener('click', (ev) => {
ev.preventDefault(); ev.preventDefault();
openSearchAddProviderPicker(missing); openSearchAddProviderPicker(missing);
}); });
} }
host.querySelectorAll('[data-search-edit-provider]').forEach(el => {
el.addEventListener('click', (ev) => {
ev.preventDefault();
const pid = el.getAttribute('data-search-edit-provider');
const meta = (cap.providers || []).find(p => p.id === pid);
_launchSearchProviderConfig(pid, meta);
});
});
} }
// Two-step add flow: click "+ 添加厂商" -> chooser dialog -> per-provider // Two-step add flow: click "+ 添加厂商" -> chooser dialog -> per-provider
@@ -4168,9 +4183,9 @@ function openSearchAddProviderPicker(missingProviders) {
}); });
} }
function _launchSearchProviderConfig(providerId) { function _launchSearchProviderConfig(providerId, providerMeta) {
if (providerId === 'bocha') { if (providerId === 'bocha') {
openSearchBochaModal(); openSearchBochaModal(providerMeta);
} else { } else {
openVendorModal(providerId, () => loadModelsView({ preserveScroll: true })); openVendorModal(providerId, () => loadModelsView({ preserveScroll: true }));
} }
@@ -4204,54 +4219,106 @@ function saveSearchCapability() {
// Minimal bocha API-key modal. Reuses the existing vendor-modal markup // Minimal bocha API-key modal. Reuses the existing vendor-modal markup
// helpers would be nice, but bocha isn't in PROVIDER_MODELS (it's not a // helpers would be nice, but bocha isn't in PROVIDER_MODELS (it's not a
// model vendor), so we render a tiny dedicated dialog. // model vendor), so we render a tiny dedicated dialog.
function openSearchBochaModal() { function openSearchBochaModal(providerMeta) {
const existing = document.getElementById('search-bocha-modal'); const existing = document.getElementById('search-bocha-modal');
if (existing) existing.remove(); if (existing) existing.remove();
let masked = (providerMeta && providerMeta.api_key_masked) || '';
if (!masked) {
const searchCap = (modelsState && modelsState.capabilities && modelsState.capabilities.search) || {};
const bocha = (searchCap.providers || []).find(p => p.id === 'bocha');
if (bocha && bocha.api_key_masked) masked = bocha.api_key_masked;
}
const hasKey = !!masked;
const clearBtnHtml = hasKey
? `<button type="button" id="search-bocha-clear"
class="px-3 py-1.5 rounded-md text-xs text-red-500 dark:text-red-400
hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors">
${t('models_clear_credential')}
</button>`
: '';
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.id = 'search-bocha-modal'; modal.id = 'search-bocha-modal';
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm'; modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 <div id="search-bocha-modal-card"
class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10
w-full max-w-md mx-4 p-6 shadow-xl"> w-full max-w-md mx-4 p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_bocha_title')}</h3> <h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_bocha_title')}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4"> <p class="text-xs text-slate-500 dark:text-slate-400 mb-4">${t('models_search_bocha_desc')}</p>
${t('models_search_bocha_desc')}
<a href="https://open.bochaai.com" target="_blank"
class="text-primary-500 hover:text-primary-600 underline">open.bochaai.com</a>
</p>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label> <label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<input id="search-bocha-key" type="password" autocomplete="off" <input id="search-bocha-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono" focus:outline-none focus:border-primary-500 font-mono ${hasKey ? 'cfg-key-masked' : ''}"
value="${escapeHtml(masked)}"
data-masked="${hasKey ? '1' : ''}"
placeholder="sk-..." /> placeholder="sk-..." />
<div class="flex items-center justify-end gap-3 mt-5"> <div class="flex items-center justify-between gap-3 mt-5">
<button type="button" onclick="document.getElementById('search-bocha-modal').remove()" <div>${clearBtnHtml}</div>
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300 <div class="flex items-center gap-3">
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"> <button type="button" onclick="document.getElementById('search-bocha-modal').remove()"
${t('cancel')} class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
</button> hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
<button type="button" onclick="_saveBochaKey()" ${t('cancel')}
class="px-4 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium </button>
cursor-pointer transition-colors"> <button type="button" onclick="_saveBochaKey()"
${t('save')} class="px-4 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
</button> cursor-pointer transition-colors">
${t('save')}
</button>
</div>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
setTimeout(() => {
const input = document.getElementById('search-bocha-key'); // Reset masked sentinel as soon as the user starts editing so the save
if (input) input.focus(); // handler can tell apart "kept the existing key" vs "typed a new one".
}, 50); const input = document.getElementById('search-bocha-key');
if (input) {
const unmask = () => {
if (input.dataset.masked === '1') {
input.value = '';
input.dataset.masked = '';
input.classList.remove('cfg-key-masked');
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Tab' || e.key === 'Escape') return;
unmask();
});
input.addEventListener('paste', unmask);
if (!hasKey) setTimeout(() => input.focus(), 50);
}
const clearBtn = document.getElementById('search-bocha-clear');
if (clearBtn) clearBtn.addEventListener('click', _clearBochaKey);
modal.addEventListener('mousedown', (e) => {
if (e.target === modal) modal.remove();
});
const onKey = (e) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', onKey);
}
};
document.addEventListener('keydown', onKey);
} }
function _saveBochaKey() { function _saveBochaKey() {
const input = document.getElementById('search-bocha-key'); const input = document.getElementById('search-bocha-key');
const apiKey = input ? input.value.trim() : ''; if (!input) return;
// Untouched masked value => no change requested; close silently.
if (input.dataset.masked === '1') {
const modal = document.getElementById('search-bocha-modal');
if (modal) modal.remove();
return;
}
const apiKey = input.value.trim();
if (!apiKey) { if (!apiKey) {
if (input) input.focus(); input.focus();
return; return;
} }
fetch('/api/models', { fetch('/api/models', {
@@ -4267,6 +4334,20 @@ function _saveBochaKey() {
}); });
} }
function _clearBochaKey() {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_search_credential', api_key: '' }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
const modal = document.getElementById('search-bocha-modal');
if (modal) modal.remove();
loadModelsView({ preserveScroll: true });
}
});
}
function renderCapabilityBody(def, cap, body) { function renderCapabilityBody(def, cap, body) {
if (def.id === 'search') { if (def.id === 'search') {
renderSearchCapability(def, cap, body); renderSearchCapability(def, cap, body);
@@ -5042,12 +5123,7 @@ function fillVendorModalForProvider(providerId) {
baseWrap.classList.remove('hidden'); baseWrap.classList.remove('hidden');
baseInput.placeholder = meta.api_base_default || meta.api_base_placeholder || ''; baseInput.placeholder = meta.api_base_default || meta.api_base_placeholder || '';
baseInput.value = meta.api_base || ''; baseInput.value = meta.api_base || '';
if (meta.api_base_default) { baseHint.classList.add('hidden');
baseHint.classList.remove('hidden');
baseHint.querySelector('span').textContent = `${t('models_base_default')}: ${meta.api_base_default}`;
} else {
baseHint.classList.add('hidden');
}
} else { } else {
baseWrap.classList.add('hidden'); baseWrap.classList.add('hidden');
baseInput.value = ''; baseInput.value = '';

View File

@@ -2252,6 +2252,7 @@ class ModelsHandler:
configured_ids = [] configured_ids = []
for pid in cls._SEARCH_PROVIDERS: for pid in cls._SEARCH_PROVIDERS:
ok = cls._is_real_key(cls._search_provider_key(pid, local_config)) ok = cls._is_real_key(cls._search_provider_key(pid, local_config))
raw_key = cls._search_provider_key(pid, local_config) if ok else ""
providers.append({ providers.append({
"id": pid, "id": pid,
"label": cls._SEARCH_PROVIDER_LABELS.get(pid, pid), "label": cls._SEARCH_PROVIDER_LABELS.get(pid, pid),
@@ -2260,6 +2261,7 @@ class ModelsHandler:
# piggy-back on a model-vendor credential. Frontend uses # piggy-back on a model-vendor credential. Frontend uses
# this hint to decide which credential editor to surface. # this hint to decide which credential editor to surface.
"needs_dedicated_key": pid == "bocha", "needs_dedicated_key": pid == "bocha",
"api_key_masked": ConfigHandler._mask_key(raw_key) if raw_key else "",
}) })
if ok: if ok:
configured_ids.append(pid) configured_ids.append(pid)
@@ -2394,8 +2396,11 @@ class ModelsHandler:
for field_name in (meta.get("api_key_field"), meta.get("api_base_key")): for field_name in (meta.get("api_key_field"), meta.get("api_base_key")):
if not field_name: if not field_name:
continue continue
if field_name in local_config: # Always write the key — even if it was absent before — so the
local_config[field_name] = "" # in-memory conf() reflects the cleared state without needing a
# restart. (`in local_config` was too strict: provider keys that
# were ever set then deleted manually wouldn't get reset.)
local_config[field_name] = ""
file_cfg[field_name] = "" file_cfg[field_name] = ""
cleared.append(field_name) cleared.append(field_name)