mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
feat(models): allow viewing and editing search vendor credentials
This commit is contained in:
@@ -474,6 +474,11 @@
|
||||
<i class="fas fa-microchip text-primary-500 text-sm"></i>
|
||||
</div>
|
||||
<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 class="space-y-5">
|
||||
<!-- Provider -->
|
||||
@@ -1084,7 +1089,7 @@
|
||||
</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">
|
||||
<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
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
data-i18n="models_clear_credential">清除凭据</button>
|
||||
|
||||
@@ -58,7 +58,8 @@ const I18N = {
|
||||
models_search_add_provider: '添加厂商',
|
||||
models_search_add_desc: '选择一个搜索厂商进行配置',
|
||||
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_set_via_env: '通过环境变量启用',
|
||||
models_dim_label: '维度',
|
||||
@@ -93,6 +94,7 @@ const I18N = {
|
||||
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
|
||||
config_model: '模型配置', config_agent: 'Agent 配置',
|
||||
config_model_advanced: '高级配置',
|
||||
config_channel: '通道配置',
|
||||
config_agent_enabled: 'Agent 模式',
|
||||
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_desc: 'Pick a search provider to configure',
|
||||
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_set_via_env: 'enable via environment variable',
|
||||
models_dim_label: 'dim',
|
||||
@@ -265,6 +268,7 @@ const I18N = {
|
||||
input_placeholder: 'Type a message, or press / for commands',
|
||||
config_title: 'Configuration', config_desc: 'Manage model and agent settings',
|
||||
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
|
||||
config_model_advanced: 'Advanced',
|
||||
config_channel: 'Channel Configuration',
|
||||
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',
|
||||
@@ -4094,10 +4098,13 @@ function _renderSearchSummary(body, cap) {
|
||||
`;
|
||||
} else {
|
||||
const chips = configured.map(p => `
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md
|
||||
bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<button type="button" data-search-edit-provider="${p.id}"
|
||||
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)}
|
||||
</span>
|
||||
</button>
|
||||
`).join('');
|
||||
host.innerHTML = `
|
||||
<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');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
const addBtnEl = host.querySelector('#cap-search-add-btn');
|
||||
if (addBtnEl) {
|
||||
addBtnEl.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
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
|
||||
@@ -4168,9 +4183,9 @@ function openSearchAddProviderPicker(missingProviders) {
|
||||
});
|
||||
}
|
||||
|
||||
function _launchSearchProviderConfig(providerId) {
|
||||
function _launchSearchProviderConfig(providerId, providerMeta) {
|
||||
if (providerId === 'bocha') {
|
||||
openSearchBochaModal();
|
||||
openSearchBochaModal(providerMeta);
|
||||
} else {
|
||||
openVendorModal(providerId, () => loadModelsView({ preserveScroll: true }));
|
||||
}
|
||||
@@ -4204,54 +4219,106 @@ function saveSearchCapability() {
|
||||
// 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
|
||||
// model vendor), so we render a tiny dedicated dialog.
|
||||
function openSearchBochaModal() {
|
||||
function openSearchBochaModal(providerMeta) {
|
||||
const existing = document.getElementById('search-bocha-modal');
|
||||
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');
|
||||
modal.id = 'search-bocha-modal';
|
||||
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm';
|
||||
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">
|
||||
<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">
|
||||
${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>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4">${t('models_search_bocha_desc')}</p>
|
||||
<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
|
||||
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-..." />
|
||||
<div class="flex items-center justify-end gap-3 mt-5">
|
||||
<button type="button" onclick="document.getElementById('search-bocha-modal').remove()"
|
||||
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
|
||||
${t('cancel')}
|
||||
</button>
|
||||
<button type="button" onclick="_saveBochaKey()"
|
||||
class="px-4 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors">
|
||||
${t('save')}
|
||||
</button>
|
||||
<div class="flex items-center justify-between gap-3 mt-5">
|
||||
<div>${clearBtnHtml}</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" onclick="document.getElementById('search-bocha-modal').remove()"
|
||||
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
|
||||
${t('cancel')}
|
||||
</button>
|
||||
<button type="button" onclick="_saveBochaKey()"
|
||||
class="px-4 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors">
|
||||
${t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('search-bocha-key');
|
||||
if (input) input.focus();
|
||||
}, 50);
|
||||
|
||||
// Reset masked sentinel as soon as the user starts editing so the save
|
||||
// handler can tell apart "kept the existing key" vs "typed a new one".
|
||||
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() {
|
||||
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 (input) input.focus();
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (def.id === 'search') {
|
||||
renderSearchCapability(def, cap, body);
|
||||
@@ -5042,12 +5123,7 @@ function fillVendorModalForProvider(providerId) {
|
||||
baseWrap.classList.remove('hidden');
|
||||
baseInput.placeholder = meta.api_base_default || meta.api_base_placeholder || '';
|
||||
baseInput.value = meta.api_base || '';
|
||||
if (meta.api_base_default) {
|
||||
baseHint.classList.remove('hidden');
|
||||
baseHint.querySelector('span').textContent = `${t('models_base_default')}: ${meta.api_base_default}`;
|
||||
} else {
|
||||
baseHint.classList.add('hidden');
|
||||
}
|
||||
baseHint.classList.add('hidden');
|
||||
} else {
|
||||
baseWrap.classList.add('hidden');
|
||||
baseInput.value = '';
|
||||
|
||||
@@ -2252,6 +2252,7 @@ class ModelsHandler:
|
||||
configured_ids = []
|
||||
for pid in cls._SEARCH_PROVIDERS:
|
||||
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({
|
||||
"id": pid,
|
||||
"label": cls._SEARCH_PROVIDER_LABELS.get(pid, pid),
|
||||
@@ -2260,6 +2261,7 @@ class ModelsHandler:
|
||||
# piggy-back on a model-vendor credential. Frontend uses
|
||||
# this hint to decide which credential editor to surface.
|
||||
"needs_dedicated_key": pid == "bocha",
|
||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if raw_key else "",
|
||||
})
|
||||
if ok:
|
||||
configured_ids.append(pid)
|
||||
@@ -2394,8 +2396,11 @@ class ModelsHandler:
|
||||
for field_name in (meta.get("api_key_field"), meta.get("api_base_key")):
|
||||
if not field_name:
|
||||
continue
|
||||
if field_name in local_config:
|
||||
local_config[field_name] = ""
|
||||
# Always write the key — even if it was absent before — so the
|
||||
# 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] = ""
|
||||
cleared.append(field_name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user