mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(web): integrate custom providers into the provider credentials section
- Merge the separate custom-providers section into the unified provider grid; "Custom" in the add-provider picker now acts as an add-new action (trailing + mark) and opens the dedicated modal, supporting multiple OpenAI-compatible endpoints - Simplify the custom provider modal: drop the default-model field, add an inline delete button, align colors with the theme - Keep the legacy single "custom" card visible (models page, chat dropdown and legacy config page) while custom_api_key/custom_api_base is still in use, so existing single-provider setups don't disappear - Unify user-facing wording from "vendor" to "provider" in UI and docs - Restructure custom provider docs (zh/en/ja) around Web console and config file usage
This commit is contained in:
@@ -32,21 +32,13 @@ const I18N = {
|
||||
models_clear_credential: '清除凭据',
|
||||
models_base_default_hint: '留空将使用官方默认地址',
|
||||
models_base_default: '默认',
|
||||
models_custom_section: '自定义厂商',
|
||||
models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换',
|
||||
models_custom_add: '添加自定义厂商',
|
||||
models_custom_vendor_label: '自定义',
|
||||
models_custom_name: '名称',
|
||||
models_custom_name_placeholder: '例如 siliconflow',
|
||||
models_custom_default_model: '默认模型(可选)',
|
||||
models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3',
|
||||
models_custom_active: '使用中',
|
||||
models_custom_set_active: '设为使用中',
|
||||
models_custom_delete: '删除',
|
||||
models_custom_delete_confirm_title: '删除自定义厂商',
|
||||
models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。',
|
||||
models_custom_name_required: '请填写名称',
|
||||
models_custom_base_required: '请填写 API Base',
|
||||
models_custom_empty: '尚未配置自定义厂商,点击添加',
|
||||
models_custom_edit_title: '编辑自定义厂商',
|
||||
models_custom_add_title: '添加自定义厂商',
|
||||
models_capability_chat: '主模型',
|
||||
@@ -240,10 +232,10 @@ const I18N = {
|
||||
menu_logs: 'Logs',
|
||||
models_title: 'Models',
|
||||
models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place',
|
||||
models_section_vendors: 'Vendor Credentials',
|
||||
models_section_vendors: 'Provider Credentials',
|
||||
models_section_vendors_desc: 'Configured once, shared by multiple model capabilities',
|
||||
models_section_capabilities: 'Capabilities',
|
||||
models_add_vendor: 'Add Vendor',
|
||||
models_add_vendor: 'Add Provider',
|
||||
models_provider: 'Provider',
|
||||
models_model: 'Model',
|
||||
models_voice: 'Voice',
|
||||
@@ -253,21 +245,13 @@ const I18N = {
|
||||
models_clear_credential: 'Clear credentials',
|
||||
models_base_default_hint: 'Leave blank to use the official default base URL',
|
||||
models_base_default: 'Default',
|
||||
models_custom_section: 'Custom Providers',
|
||||
models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely',
|
||||
models_custom_add: 'Add custom provider',
|
||||
models_custom_vendor_label: 'Custom',
|
||||
models_custom_name: 'Name',
|
||||
models_custom_name_placeholder: 'e.g. siliconflow',
|
||||
models_custom_default_model: 'Default model (optional)',
|
||||
models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3',
|
||||
models_custom_active: 'Active',
|
||||
models_custom_set_active: 'Set active',
|
||||
models_custom_delete: 'Delete',
|
||||
models_custom_delete_confirm_title: 'Delete custom provider',
|
||||
models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.',
|
||||
models_custom_name_required: 'Name is required',
|
||||
models_custom_base_required: 'API Base is required',
|
||||
models_custom_empty: 'No custom providers yet, click to add',
|
||||
models_custom_edit_title: 'Edit custom provider',
|
||||
models_custom_add_title: 'Add custom provider',
|
||||
models_capability_chat: 'Main Model',
|
||||
@@ -311,8 +295,8 @@ const I18N = {
|
||||
models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.',
|
||||
models_embedding_saved_ok: 'Go',
|
||||
models_pick_provider: 'Pick a provider',
|
||||
models_clear_confirm_title: 'Clear vendor credentials',
|
||||
models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.',
|
||||
models_clear_confirm_title: 'Clear provider credentials',
|
||||
models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
ok: 'OK',
|
||||
@@ -4766,12 +4750,12 @@ function renderModelsView() {
|
||||
const container = document.getElementById('models-content');
|
||||
container.innerHTML = '';
|
||||
container.appendChild(renderVendorsSection());
|
||||
container.appendChild(renderCustomProvidersSection());
|
||||
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
|
||||
}
|
||||
|
||||
// True when a provider card is one of the expanded custom (OpenAI-compatible)
|
||||
// providers — these are managed in their own section, not the vendor grid.
|
||||
// providers (id "custom:<id>") — shown in the vendor grid alongside built-in
|
||||
// vendors, but edited via the dedicated custom-provider modal.
|
||||
function isCustomProviderCard(p) {
|
||||
return !!(p && p.is_custom && p.custom_name);
|
||||
}
|
||||
@@ -4782,10 +4766,9 @@ function renderVendorsSection() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
|
||||
|
||||
// Expanded custom providers live in their own section; keep the built-in
|
||||
// vendor grid focused on the canonical (field-based) providers.
|
||||
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
|
||||
const configured = builtinProviders.filter(p => p.configured);
|
||||
// Custom providers always show once created (even without an api key,
|
||||
// e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured.
|
||||
const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p));
|
||||
|
||||
const header = `
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
@@ -4796,7 +4779,6 @@ function renderVendorsSection() {
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_section_vendors')}</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${builtinProviders.length}</span>
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
@@ -4822,8 +4804,13 @@ function renderVendorsSection() {
|
||||
function renderVendorChip(p) {
|
||||
// The masked API key is intentionally not surfaced here; it is shown
|
||||
// inside the edit modal so the chip stays uncluttered and scannable.
|
||||
// Custom providers open their dedicated modal (name + base + key);
|
||||
// their ids are server-generated hex, safe to inline.
|
||||
const onclick = isCustomProviderCard(p)
|
||||
? `openCustomProviderModal('${escapeHtml(p.custom_id)}')`
|
||||
: `openVendorModal('${escapeHtml(p.id)}')`;
|
||||
return `
|
||||
<button onclick="openVendorModal('${escapeHtml(p.id)}')"
|
||||
<button onclick="${onclick}"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border border-slate-200 dark:border-white/10
|
||||
bg-slate-50 dark:bg-white/5 hover:border-primary-300 dark:hover:border-primary-500/50
|
||||
cursor-pointer transition-colors duration-150 text-left">
|
||||
@@ -4856,112 +4843,10 @@ function renderProviderLogo(p, sizePx) {
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// ---------- Custom providers section (multiple OpenAI-compatible) -------
|
||||
// Renders the user-defined OpenAI-compatible providers as a dedicated,
|
||||
// independently managed list: add / edit / delete / activate. The backend
|
||||
// expands `custom_providers` into provider cards with id="custom:<id>",
|
||||
// is_custom=true, custom_id, custom_name and an `active` flag (see
|
||||
// ModelsHandler._custom_provider_cards / _provider_overview).
|
||||
// All button interactions use data-* attributes + event delegation (no inline
|
||||
// onclick) to avoid XSS via user-supplied names.
|
||||
|
||||
function getCustomProviderCards() {
|
||||
return modelsState.providers.filter(isCustomProviderCard);
|
||||
}
|
||||
|
||||
function renderCustomProvidersSection() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
|
||||
|
||||
const customs = getCustomProviderCards();
|
||||
|
||||
const header = `
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="w-9 h-9 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sliders text-violet-500 text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_custom_section')}</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_custom_section_desc')}</p>
|
||||
</div>
|
||||
<button data-action="add-custom"
|
||||
class="mt-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-900/50 cursor-pointer transition-colors flex-shrink-0">
|
||||
<i class="fas fa-plus text-[10px] mr-1"></i>${t('models_custom_add')}
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
if (customs.length === 0) {
|
||||
body = `
|
||||
<div class="flex flex-col items-center justify-center py-8 px-4 rounded-lg border border-dashed border-slate-200 dark:border-white/10">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('models_custom_empty')}</p>
|
||||
</div>`;
|
||||
} else {
|
||||
body = `<div class="space-y-2.5">
|
||||
${customs.map(renderCustomProviderRow).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
wrap.innerHTML = header + body;
|
||||
// Event delegation — handles all custom-provider actions via data-action attrs.
|
||||
wrap.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute('data-action');
|
||||
const providerId = btn.getAttribute('data-provider-id') || '';
|
||||
if (action === 'add-custom') openCustomProviderModal('');
|
||||
else if (action === 'edit-custom') openCustomProviderModal(providerId);
|
||||
else if (action === 'delete-custom') deleteCustomProvider(providerId);
|
||||
else if (action === 'set-active-custom') setActiveCustomProvider(providerId);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderCustomProviderRow(p) {
|
||||
const id = p.custom_id || '';
|
||||
const name = p.custom_name || '';
|
||||
const nameEsc = escapeHtml(name);
|
||||
// The active provider gets a highlighted ring + badge; others show a
|
||||
// "set active" affordance via data-attributes (no inline onclick — XSS safe).
|
||||
const activeBadge = p.active
|
||||
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 flex-shrink-0">
|
||||
<i class="fas fa-check text-[9px] mr-0.5"></i>${t('models_custom_active')}</span>`
|
||||
: `<button data-action="set-active-custom" data-provider-id="${escapeHtml(id)}"
|
||||
class="px-2 py-0.5 rounded-full text-[10px] font-medium border border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:border-emerald-300 hover:text-emerald-500 cursor-pointer transition-colors flex-shrink-0">
|
||||
${t('models_custom_set_active')}</button>`;
|
||||
|
||||
const ring = p.active
|
||||
? 'border-emerald-300 dark:border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-900/10'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5';
|
||||
|
||||
const model = p.model
|
||||
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.model)}</span>`
|
||||
: '';
|
||||
const base = p.api_base
|
||||
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.api_base)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border ${ring} transition-colors" data-provider-id="${escapeHtml(id)}">
|
||||
${renderProviderLogo(p, 28)}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${nameEsc}</span>
|
||||
${activeBadge}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">${base}${model ? '<span class="text-slate-300 dark:text-slate-600">·</span>' + model : ''}</div>
|
||||
</div>
|
||||
<button data-action="edit-custom" data-provider-id="${escapeHtml(id)}" title="${t('models_custom_edit_title')}"
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-slate-500 hover:text-primary-500 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors flex-shrink-0">
|
||||
<i class="fas fa-pen-to-square text-[12px]"></i>
|
||||
</button>
|
||||
<button data-action="delete-custom" data-provider-id="${escapeHtml(id)}" title="${t('models_custom_delete')}"
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-slate-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors flex-shrink-0">
|
||||
<i class="fas fa-trash-can text-[12px]"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- Capability cards (Layer 2) ---------------------------------
|
||||
|
||||
function renderCapabilityCard(def) {
|
||||
@@ -5776,6 +5661,14 @@ function decorateVendorModalPicker(ddEl, opts) {
|
||||
// Tag the row so the global active-row ✓ rule is suppressed in CSS
|
||||
// (otherwise configured AND selected rows would render two checks).
|
||||
item.classList.add('vendor-picker-item');
|
||||
if (opt._isAddNew) {
|
||||
// "Custom" is an add-new action (multiple entries allowed),
|
||||
// so show a trailing + instead of the configured ✓.
|
||||
const plus = document.createElement('i');
|
||||
plus.className = 'fas fa-plus vendor-picker-add-mark';
|
||||
item.appendChild(plus);
|
||||
return;
|
||||
}
|
||||
if (!opt._configured) return;
|
||||
const check = document.createElement('i');
|
||||
check.className = 'fas fa-check vendor-picker-configured-mark';
|
||||
@@ -6083,22 +5976,42 @@ function openVendorModal(providerId, onSaved) {
|
||||
// currently selected vendor via its own background highlight, so we
|
||||
// intentionally suppress the global active-row ✓ for this picker
|
||||
// (see CSS) — otherwise configured + selected rows would show two.
|
||||
// Custom (OpenAI-compatible) providers are managed in their own
|
||||
// section with a dedicated modal, so they are excluded from this
|
||||
// built-in vendor picker.
|
||||
// Expanded custom provider cards ("custom:<id>") are edited via their
|
||||
// dedicated modal, so they are excluded from this picker. Picking the
|
||||
// "custom" entry creates a *new* custom provider via that modal —
|
||||
// this is how multiple OpenAI-compatible endpoints are added.
|
||||
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
|
||||
const unconfigured = builtinProviders.filter(p => !p.configured);
|
||||
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || '';
|
||||
pickerWrap.classList.remove('hidden');
|
||||
const pickerEl = document.getElementById('vendor-modal-picker');
|
||||
const pickerOpts = builtinProviders.map(p => ({
|
||||
value: p.id,
|
||||
label: localizedLabel(p.label),
|
||||
_configured: !!p.configured,
|
||||
}));
|
||||
initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val));
|
||||
// In multi-provider mode the backend replaces the bare "custom" card
|
||||
// with the expanded ones; re-add it here so the entry stays available.
|
||||
if (!pickerOpts.some(o => o.value === 'custom')) {
|
||||
pickerOpts.push({ value: 'custom', label: t('models_custom_vendor_label'), _configured: false });
|
||||
}
|
||||
// "Custom" always behaves as an add-new action (multiple entries
|
||||
// allowed), so it shows a + mark instead of the configured ✓.
|
||||
pickerOpts.forEach(o => { if (o.value === 'custom') { o._isAddNew = true; o._configured = false; } });
|
||||
const unconfigured = builtinProviders.filter(p => !p.configured);
|
||||
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || 'custom';
|
||||
pickerWrap.classList.remove('hidden');
|
||||
const pickerEl = document.getElementById('vendor-modal-picker');
|
||||
const onPick = (val) => {
|
||||
if (val === 'custom') {
|
||||
// "Custom" in the add flow always creates a new
|
||||
// OpenAI-compatible provider entry via the dedicated modal
|
||||
// (name + base + key), supporting multiple custom endpoints.
|
||||
closeVendorModal();
|
||||
openCustomProviderModal('');
|
||||
return;
|
||||
}
|
||||
fillVendorModalForProvider(val);
|
||||
};
|
||||
initDropdown(pickerEl, pickerOpts, defaultId, onPick);
|
||||
decorateVendorModalPicker(pickerEl, pickerOpts);
|
||||
fillVendorModalForProvider(defaultId);
|
||||
onPick(defaultId);
|
||||
} else {
|
||||
pickerWrap.classList.add('hidden');
|
||||
fillVendorModalForProvider(providerId);
|
||||
@@ -6276,11 +6189,9 @@ function openCustomProviderModal(providerId) {
|
||||
const nameInput = document.getElementById('custom-provider-name');
|
||||
const baseInput = document.getElementById('custom-provider-base');
|
||||
const keyInput = document.getElementById('custom-provider-key');
|
||||
const modelInput = document.getElementById('custom-provider-model');
|
||||
|
||||
nameInput.value = card ? (card.custom_name || '') : '';
|
||||
baseInput.value = card ? (card.api_base || '') : '';
|
||||
modelInput.value = card ? (card.model || '') : '';
|
||||
|
||||
// Surface the masked key as the value for configured providers so the
|
||||
// "already set" state is unambiguous; an untouched masked value means
|
||||
@@ -6307,6 +6218,13 @@ function openCustomProviderModal(providerId) {
|
||||
document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal;
|
||||
document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal;
|
||||
|
||||
// Delete is only available when editing an existing provider.
|
||||
const deleteBtn = document.getElementById('custom-provider-modal-delete');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.classList.toggle('hidden', !editing);
|
||||
deleteBtn.onclick = editing ? () => deleteCustomProvider(providerId) : null;
|
||||
}
|
||||
|
||||
function onOverlayClick(e) {
|
||||
if (e.target === overlay) {
|
||||
closeCustomProviderModal();
|
||||
@@ -6325,7 +6243,6 @@ function closeCustomProviderModal() {
|
||||
function saveCustomProviderModal() {
|
||||
const name = document.getElementById('custom-provider-name').value.trim();
|
||||
const apiBase = document.getElementById('custom-provider-base').value.trim();
|
||||
const model = document.getElementById('custom-provider-model').value.trim();
|
||||
const keyInput = document.getElementById('custom-provider-key');
|
||||
|
||||
if (!name) {
|
||||
@@ -6350,7 +6267,6 @@ function saveCustomProviderModal() {
|
||||
action: 'set_custom_provider',
|
||||
name: name,
|
||||
api_base: apiBase,
|
||||
model: model,
|
||||
};
|
||||
if (apiKey) payload.api_key = apiKey;
|
||||
if (editing) payload.id = customProviderModalState.editId;
|
||||
@@ -6375,16 +6291,6 @@ function saveCustomProviderModal() {
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveCustomProvider(providerId) {
|
||||
fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') loadModelsView();
|
||||
}).catch(() => { /* noop */ });
|
||||
}
|
||||
|
||||
function deleteCustomProvider(providerId) {
|
||||
showConfirmDialog({
|
||||
title: t('models_custom_delete_confirm_title'),
|
||||
@@ -6397,7 +6303,10 @@ function deleteCustomProvider(providerId) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') loadModelsView();
|
||||
if (data.status === 'success') {
|
||||
closeCustomProviderModal();
|
||||
loadModelsView();
|
||||
}
|
||||
}).catch(() => { /* noop */ });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user