feat: enhance excluded models management with UI components, internationalization, and data handling

This commit is contained in:
Supra4E8C
2025-12-03 00:27:45 +08:00
parent d3630373ed
commit c77527cd13
4 changed files with 110 additions and 3 deletions

View File

@@ -149,6 +149,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥',
'ai_providers.gemini_edit_modal_key_label': 'API密钥:',
'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗',
'ai_providers.excluded_models_label': '排除的模型 (可选):',
'ai_providers.excluded_models_placeholder': '用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': '留空表示不过滤;保存时会自动去重并忽略空白。',
'ai_providers.excluded_models_count': '排除 {count} 个模型',
'ai_providers.codex_title': 'Codex API 配置',
'ai_providers.codex_add_button': '添加配置',
@@ -755,6 +759,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key',
'ai_providers.gemini_edit_modal_key_label': 'API Key:',
'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?',
'ai_providers.excluded_models_label': 'Excluded models (optional):',
'ai_providers.excluded_models_placeholder': 'Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': 'Leave empty to allow all models; values are trimmed and deduplicated automatically.',
'ai_providers.excluded_models_count': 'Excluding {count} models',
'ai_providers.codex_title': 'Codex API Configuration',
'ai_providers.codex_add_button': 'Add Configuration',

View File

@@ -59,6 +59,59 @@ const normalizeModelList = (payload) => {
return [];
};
const normalizeExcludedModels = (input) => {
const rawList = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/[\n,]/) : []);
const seen = new Set();
const normalized = [];
rawList.forEach(item => {
if (item === undefined || item === null) {
return;
}
const trimmed = String(item).trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push(trimmed);
});
return normalized;
};
export function collectExcludedModels(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return [];
return normalizeExcludedModels(textarea.value);
}
export function setExcludedModelsValue(textareaId, models = []) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
textarea.value = normalizeExcludedModels(models).join('\n');
}
export function renderExcludedModelBadges(models) {
const normalized = normalizeExcludedModels(models);
if (!normalized.length) {
return '';
}
const badges = normalized.map(model => `
<span class="provider-model-tag excluded-model-tag">
<span class="model-name">${this.escapeHtml(model)}</span>
</span>
`).join('');
return `
<div class="item-subtitle">${i18n.t('ai_providers.excluded_models_count', { count: normalized.length })}</div>
<div class="provider-models excluded-models">
${badges}
</div>
`;
}
export async function loadGeminiKeys() {
try {
const config = await this.getConfig();
@@ -144,6 +197,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
const configJson = JSON.stringify(config).replace(/"/g, '&quot;');
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;');
const baseUrl = config['base-url'] || config['base_url'] || '';
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return `
<div class="key-item">
<div class="item-content">
@@ -151,6 +205,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
<div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div>
${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''}
${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -191,6 +246,11 @@ export function showAddGeminiKeyModal() {
<div id="new-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div>
<div class="form-group">
<label for="new-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button>
@@ -200,11 +260,13 @@ export function showAddGeminiKeyModal() {
modal.style.display = 'block';
this.populateGeminiKeyFields('new-gemini-keys-wrapper');
this.populateHeaderFields('new-gemini-headers-wrapper');
this.setExcludedModelsValue('new-gemini-excluded-models');
}
export async function addGeminiKey() {
const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper');
const headers = this.collectHeaderInputs('new-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-gemini-excluded-models');
if (!entries.length) {
this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error');
@@ -245,6 +307,7 @@ export async function addGeminiKey() {
} else {
delete newConfig['base-url'];
}
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers);
const nextKeys = [...currentKeys, newConfig];
@@ -371,6 +434,11 @@ export function editGeminiKey(index, config) {
<div id="edit-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div>
<div class="form-group">
<label for="edit-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateGeminiKey(${index})">${i18n.t('common.update')}</button>
@@ -380,6 +448,7 @@ export function editGeminiKey(index, config) {
modal.style.display = 'block';
this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false });
this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-gemini-excluded-models', config['excluded-models'] || []);
}
export async function updateGeminiKey(index) {
@@ -392,6 +461,7 @@ export async function updateGeminiKey(index) {
const newKey = entry['api-key'];
const baseUrl = entry['base-url'] || '';
const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-gemini-excluded-models');
if (!newKey) {
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
@@ -406,6 +476,7 @@ export async function updateGeminiKey(index) {
} else {
delete newConfig['base-url'];
}
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers);
await this.makeRequest('/gemini-api-key', {
@@ -477,6 +548,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 };
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;');
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return `
<div class="provider-item">
<div class="item-content">
@@ -485,6 +557,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -525,6 +598,11 @@ export function showAddCodexKeyModal() {
<label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label>
<input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}">
</div>
<div class="form-group">
<label for="new-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -539,6 +617,7 @@ export function showAddCodexKeyModal() {
modal.style.display = 'block';
this.populateHeaderFields('new-codex-headers-wrapper');
this.setExcludedModelsValue('new-codex-excluded-models');
}
export async function addCodexKey() {
@@ -546,6 +625,7 @@ export async function addCodexKey() {
const baseUrl = document.getElementById('new-codex-url').value.trim();
const proxyUrl = document.getElementById('new-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('new-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-codex-excluded-models');
if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -560,7 +640,7 @@ export async function addCodexKey() {
const data = await this.makeRequest('/codex-api-key');
const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item }));
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers);
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers, excludedModels);
currentKeys.push(newConfig);
@@ -596,6 +676,11 @@ export function editCodexKey(index, config) {
<label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label>
<input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}">
</div>
<div class="form-group">
<label for="edit-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -610,6 +695,7 @@ export function editCodexKey(index, config) {
modal.style.display = 'block';
this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-codex-excluded-models', config['excluded-models'] || []);
}
export async function updateCodexKey(index) {
@@ -617,6 +703,7 @@ export async function updateCodexKey(index) {
const baseUrl = document.getElementById('edit-codex-url').value.trim();
const proxyUrl = document.getElementById('edit-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('edit-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-codex-excluded-models');
if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -636,7 +723,7 @@ export async function updateCodexKey(index) {
}
const original = currentList[index] ? { ...currentList[index] } : {};
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers);
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers, excludedModels);
await this.makeRequest('/codex-api-key', {
method: 'PATCH',
@@ -1607,5 +1694,8 @@ export const aiProvidersModule = {
populateModelFields,
collectModelInputs,
renderModelBadges,
renderExcludedModelBadges,
collectExcludedModels,
setExcludedModelsValue,
validateOpenAIProviderInput
};

View File

@@ -230,7 +230,7 @@ export const apiKeysModule = {
},
// 构造Codex配置保持未展示的字段
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) {
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null, excludedModels = null) {
const result = {
...original,
'api-key': apiKey,
@@ -238,6 +238,9 @@ export const apiKeysModule = {
'proxy-url': proxyUrl || ''
};
this.applyHeadersToConfig(result, headers);
if (Array.isArray(excludedModels)) {
result['excluded-models'] = excludedModels;
}
return result;
},

View File

@@ -2221,6 +2221,12 @@ input:checked+.slider:before {
font-size: 0.85rem;
}
.provider-item .excluded-models .provider-model-tag {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
.provider-model-tag .model-name {
font-weight: 600;
}