diff --git a/app.js b/app.js index 2124597..794db62 100644 --- a/app.js +++ b/app.js @@ -97,6 +97,10 @@ class CLIProxyManager { this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE; this.loadAuthFilePreferences(); + // OAuth 模型排除列表状态 + this.oauthExcludedModels = {}; + this._oauthExcludedLoading = false; + // Vertex AI credential import state this.vertexImportState = { file: null, @@ -385,6 +389,17 @@ class CLIProxyManager { this.bindAuthFilesPageSizeControl(); this.syncAuthFileControls(); + // OAuth 排除列表 + const oauthExcludedAdd = document.getElementById('oauth-excluded-add'); + const oauthExcludedRefresh = document.getElementById('oauth-excluded-refresh'); + + if (oauthExcludedAdd) { + oauthExcludedAdd.addEventListener('click', () => this.openOauthExcludedEditor()); + } + if (oauthExcludedRefresh) { + oauthExcludedRefresh.addEventListener('click', () => this.loadOauthExcludedModels(true)); + } + // Vertex AI credential import const vertexSelectFile = document.getElementById('vertex-select-file'); const vertexFileInput = document.getElementById('vertex-file-input'); diff --git a/i18n.js b/i18n.js index 608a30b..0a328ea 100644 --- a/i18n.js +++ b/i18n.js @@ -306,6 +306,40 @@ const i18n = { 'vertex_import.result_location': '区域', 'vertex_import.result_file': '存储文件', + // OAuth 排除模型 + 'oauth_excluded.title': 'OAuth 排除列表', + 'oauth_excluded.description': '按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。', + 'oauth_excluded.add': '新增排除', + 'oauth_excluded.add_title': '新增提供商排除列表', + 'oauth_excluded.edit_title': '编辑 {provider} 的排除列表', + 'oauth_excluded.refresh': '刷新', + 'oauth_excluded.refreshing': '刷新中...', + 'oauth_excluded.provider_label': '提供商', + 'oauth_excluded.provider_auto': '跟随当前过滤', + 'oauth_excluded.provider_placeholder': '例如 gemini-cli / openai', + 'oauth_excluded.provider_hint': '默认选中当前筛选的提供商,也可直接输入或选择其他名称。', + 'oauth_excluded.models_label': '排除的模型', + 'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview', + 'oauth_excluded.models_hint': '逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。', + 'oauth_excluded.save': '保存/更新', + 'oauth_excluded.saving': '正在保存...', + 'oauth_excluded.save_success': '排除列表已更新', + 'oauth_excluded.save_failed': '更新排除列表失败', + 'oauth_excluded.delete': '删除提供商', + 'oauth_excluded.delete_confirm': '确定要删除 {provider} 的排除列表吗?', + 'oauth_excluded.delete_success': '已删除该提供商的排除列表', + 'oauth_excluded.delete_failed': '删除排除列表失败', + 'oauth_excluded.deleting': '正在删除...', + 'oauth_excluded.no_models': '未配置排除模型', + 'oauth_excluded.model_count': '排除 {count} 个模型', + 'oauth_excluded.list_empty_all': '暂无任何提供商的排除列表,点击“新增排除”创建。', + 'oauth_excluded.list_empty_filtered': '当前筛选下没有排除项,点击“新增排除”添加。', + 'oauth_excluded.disconnected': '请先连接服务器以查看排除列表', + 'oauth_excluded.load_failed': '加载排除列表失败', + 'oauth_excluded.provider_required': '请先填写提供商名称', + 'oauth_excluded.scope_all': '当前范围:全局(显示所有提供商)', + 'oauth_excluded.scope_provider': '当前范围:{provider}', + // Codex OAuth 'auth_login.codex_oauth_title': 'Codex OAuth', @@ -878,6 +912,40 @@ const i18n = { 'vertex_import.result_location': 'Region', 'vertex_import.result_file': 'Persisted file', + // OAuth excluded models + 'oauth_excluded.title': 'OAuth Excluded Models', + 'oauth_excluded.description': 'Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.', + 'oauth_excluded.add': 'Add Exclusion', + 'oauth_excluded.add_title': 'Add provider exclusion', + 'oauth_excluded.edit_title': 'Edit exclusions for {provider}', + 'oauth_excluded.refresh': 'Refresh', + 'oauth_excluded.refreshing': 'Refreshing...', + 'oauth_excluded.provider_label': 'Provider', + 'oauth_excluded.provider_auto': 'Follow current filter', + 'oauth_excluded.provider_placeholder': 'e.g. gemini-cli', + 'oauth_excluded.provider_hint': 'Defaults to the current filter; pick an existing provider or type a new name.', + 'oauth_excluded.models_label': 'Models to exclude', + 'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview', + 'oauth_excluded.models_hint': 'Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.', + 'oauth_excluded.save': 'Save/Update', + 'oauth_excluded.saving': 'Saving...', + 'oauth_excluded.save_success': 'Excluded models updated', + 'oauth_excluded.save_failed': 'Failed to update excluded models', + 'oauth_excluded.delete': 'Delete Provider', + 'oauth_excluded.delete_confirm': 'Delete the exclusion list for {provider}?', + 'oauth_excluded.delete_success': 'Exclusion list removed', + 'oauth_excluded.delete_failed': 'Failed to delete exclusion list', + 'oauth_excluded.deleting': 'Deleting...', + 'oauth_excluded.no_models': 'No excluded models', + 'oauth_excluded.model_count': '{count} models excluded', + 'oauth_excluded.list_empty_all': 'No exclusions yet—use “Add Exclusion” to create one.', + 'oauth_excluded.list_empty_filtered': 'No exclusions in this scope; click “Add Exclusion” to add.', + 'oauth_excluded.disconnected': 'Connect to the server to view exclusions', + 'oauth_excluded.load_failed': 'Failed to load exclusion list', + 'oauth_excluded.provider_required': 'Please enter a provider first', + 'oauth_excluded.scope_all': 'Scope: All providers', + 'oauth_excluded.scope_provider': 'Scope: {provider}', + // Codex OAuth 'auth_login.codex_oauth_title': 'Codex OAuth', 'auth_login.codex_oauth_button': 'Start Codex Login', diff --git a/index.html b/index.html index 0bbf488..06494d6 100644 --- a/index.html +++ b/index.html @@ -557,6 +557,31 @@ + +
+
+
+

OAuth 排除列表

+
+
+
+ + +
+
+
+

为 OAuth/文件凭据配置模型黑名单,支持通配符。

+
+
+
正在加载...
+
+
+
+
diff --git a/src/modules/auth-files.js b/src/modules/auth-files.js index 2809ed4..cc47b3f 100644 --- a/src/modules/auth-files.js +++ b/src/modules/auth-files.js @@ -540,6 +540,7 @@ export const authFilesModule = { } this.refreshFilterButtonTexts(); + this.renderOauthExcludedModels(); }, generateDynamicTypeLabel(type) { @@ -1031,6 +1032,469 @@ export const authFilesModule = { } }, + normalizeOauthExcludedMap(payload = {}) { + const raw = (payload && (payload['oauth-excluded-models'] || payload.items)) || payload || {}; + if (!raw || typeof raw !== 'object') { + return {}; + } + const normalized = {}; + Object.entries(raw).forEach(([provider, models]) => { + const key = typeof provider === 'string' ? provider.trim() : ''; + if (!key) return; + const list = Array.isArray(models) + ? models.map(item => String(item || '').trim()).filter(Boolean) + : []; + normalized[key.toLowerCase()] = list; + }); + return normalized; + }, + + getFilteredOauthExcludedMap(filterType = this.currentAuthFileFilter) { + const map = this.oauthExcludedModels || {}; + if (!map || typeof map !== 'object') { + return {}; + } + const type = (filterType || 'all').toLowerCase(); + if (type === 'all') { + return map; + } + const result = {}; + Object.entries(map).forEach(([provider, models]) => { + if ((provider || '').toLowerCase() === type) { + result[provider] = models; + } + }); + return result; + }, + + findOauthExcludedEntry(provider) { + if (!provider || provider === 'all') { + return null; + } + const normalized = provider.toLowerCase(); + const map = this.oauthExcludedModels || {}; + for (const [key, models] of Object.entries(map)) { + if ((key || '').toLowerCase() === normalized) { + return { provider: key, models: Array.isArray(models) ? models : [] }; + } + } + return null; + }, + + setOauthExcludedForm(provider = '', models = null) { + const providerSelect = document.getElementById('oauth-excluded-provider-select'); + const modelsInput = document.getElementById('oauth-excluded-models'); + + const normalizedProvider = (provider || '').trim(); + if (providerSelect) { + const options = Array.from(providerSelect.options || []); + let match = options.find(opt => (opt.value || '').toLowerCase() === normalizedProvider.toLowerCase()); + if (!match && normalizedProvider) { + match = new Option(this.generateDynamicTypeLabel(normalizedProvider) || normalizedProvider, normalizedProvider); + providerSelect.appendChild(match); + } + if (normalizedProvider && match) { + providerSelect.value = match.value; + } else { + providerSelect.value = 'auto'; + } + } + + if (modelsInput && models !== null && models !== undefined) { + const list = Array.isArray(models) ? models : []; + modelsInput.value = list.map(item => item || '').join('\n'); + } + }, + + syncOauthExcludedFormWithFilter(overrideModels = false) { + const filterType = (this.currentAuthFileFilter || 'all').toLowerCase(); + const entry = this.findOauthExcludedEntry(filterType); + + if (filterType === 'all') { + if (overrideModels) { + if (entry) { + this.setOauthExcludedForm(entry.provider, entry.models); + } else { + this.setOauthExcludedForm('', ''); + } + } + return; + } + + if (overrideModels) { + this.setOauthExcludedForm(filterType, entry ? entry.models : []); + } else { + this.setOauthExcludedForm(filterType); + } + }, + + getOauthExcludedProviderValue() { + const providerSelect = document.getElementById('oauth-excluded-provider-select'); + const filterFallback = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all') + ? this.currentAuthFileFilter + : ''; + + let selected = (providerSelect && providerSelect.value) ? providerSelect.value.trim() : ''; + if (!selected || selected === 'auto') { + return filterFallback; + } + return selected; + }, + + refreshOauthProviderOptions() { + const providerSelect = document.getElementById('oauth-excluded-provider-select'); + if (!providerSelect) return; + + const allowedProviders = ['gemini-cli', 'vertex', 'aistudio', 'antigravity', 'claude', 'codex', 'qwen', 'iflow']; + const mapProviders = Object.keys(this.oauthExcludedModels || {}); + const filterType = (this.currentAuthFileFilter || '').toLowerCase(); + const providers = Array.from(new Set([...allowedProviders, ...mapProviders].filter(Boolean))); + + const prevValue = providerSelect.value || 'auto'; + + providerSelect.innerHTML = ''; + const addOption = (value, textKey, fallbackText = null) => { + const opt = document.createElement('option'); + opt.value = value; + if (textKey) { + opt.setAttribute('data-i18n-text', textKey); + opt.textContent = i18n.t(textKey); + } else { + opt.textContent = fallbackText || value; + } + providerSelect.appendChild(opt); + }; + + addOption('auto', 'oauth_excluded.provider_auto'); + providers.sort((a, b) => a.localeCompare(b)).forEach(item => addOption(item, null, this.generateDynamicTypeLabel(item) || item)); + + const restoreValue = (() => { + if (prevValue && Array.from(providerSelect.options).some(opt => opt.value === prevValue)) { + return prevValue; + } + if (filterType && Array.from(providerSelect.options).some(opt => opt.value === filterType)) { + return filterType; + } + return 'auto'; + })(); + providerSelect.value = restoreValue; + }, + + parseOauthExcludedModelsInput(input = '') { + const tokens = (input || '').split(/[\n,]/).map(token => token.trim()).filter(Boolean); + const unique = []; + tokens.forEach(token => { + if (!unique.includes(token)) { + unique.push(token); + } + }); + return unique; + }, + + openOauthExcludedEditor(provider = '', models = null) { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + if (!modal || !modalBody) return; + + const normalizedProvider = (provider || '').trim(); + const fallbackProvider = normalizedProvider + || ((this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all') ? this.currentAuthFileFilter : ''); + let targetModels = models; + + if ((targetModels === null || targetModels === undefined) && fallbackProvider) { + const existing = this.findOauthExcludedEntry(fallbackProvider); + if (existing) { + targetModels = existing.models; + } + } + + modalBody.innerHTML = ` +

${fallbackProvider + ? i18n.t('oauth_excluded.edit_title', { provider: this.generateDynamicTypeLabel(fallbackProvider) }) + : i18n.t('oauth_excluded.add_title') + }

+
+
+
+ + +

${i18n.t('oauth_excluded.provider_hint')}

+
+
+ + +

${i18n.t('oauth_excluded.models_hint')}

+
+
+
+ + `; + + this.refreshOauthProviderOptions(); + this.setOauthExcludedForm(fallbackProvider, targetModels != null ? targetModels : []); + this.showModal(); + + const saveBtn = document.getElementById('oauth-excluded-save'); + if (saveBtn) { + saveBtn.onclick = () => this.saveOauthExcludedEntry(); + } + const deleteBtn = document.getElementById('oauth-excluded-delete'); + if (deleteBtn) { + deleteBtn.onclick = () => this.deleteOauthExcludedEntry(); + } + + const providerSelect = document.getElementById('oauth-excluded-provider-select'); + const syncDeleteState = () => { + if (deleteBtn) { + deleteBtn.disabled = !this.getOauthExcludedProviderValue(); + } + }; + if (providerSelect) { + providerSelect.addEventListener('change', syncDeleteState); + } + syncDeleteState(); + this.updateOauthExcludedButtonsState(false); + }, + + buildOauthExcludedItem(provider, models = []) { + const providerLabel = this.generateDynamicTypeLabel(provider) || provider; + const normalizedModels = Array.isArray(models) ? models.filter(Boolean) : []; + const tags = normalizedModels.length + ? normalizedModels.map(model => `${this.escapeHtml(String(model))}`).join('') + : `${i18n.t('oauth_excluded.no_models')}`; + const modelCount = normalizedModels.length; + + return ` +
+
+
${this.escapeHtml(providerLabel)}
+
+ + ${modelCount > 0 + ? i18n.t('oauth_excluded.model_count', { count: modelCount }) + : i18n.t('oauth_excluded.no_models')} + +
+
+ ${tags} +
+
+
+ + +
+
+ `; + }, + + renderOauthExcludedModels(filterType = this.currentAuthFileFilter) { + const container = document.getElementById('oauth-excluded-list'); + const scopeEl = document.getElementById('oauth-excluded-scope'); + if (!container) return; + + const currentType = (filterType || 'all').toLowerCase(); + const map = this.getFilteredOauthExcludedMap(currentType); + const providers = Object.keys(map || {}); + + if (scopeEl) { + const label = currentType === 'all' + ? i18n.t('oauth_excluded.scope_all') + : i18n.t('oauth_excluded.scope_provider', { provider: this.generateDynamicTypeLabel(currentType) }); + scopeEl.textContent = label; + } + + if (!this.isConnected) { + container.innerHTML = `
${i18n.t('oauth_excluded.disconnected')}
`; + return; + } + + if (this._oauthExcludedLoading) { + container.innerHTML = `
${i18n.t('common.loading')}
`; + return; + } + + if (!providers.length) { + const emptyKey = currentType === 'all' + ? 'oauth_excluded.list_empty_all' + : 'oauth_excluded.list_empty_filtered'; + container.innerHTML = `
${i18n.t(emptyKey)}
`; + return; + } + + const itemsHtml = providers + .sort((a, b) => a.localeCompare(b)) + .map(provider => this.buildOauthExcludedItem(provider, map[provider])) + .join(''); + container.innerHTML = itemsHtml; + this.refreshOauthProviderOptions(); + this.bindOauthExcludedActionEvents(); + }, + + bindOauthExcludedActionEvents() { + const container = document.getElementById('oauth-excluded-list'); + if (!container) return; + + if (container._oauthExcludedListener) { + container.removeEventListener('click', container._oauthExcludedListener); + } + + const listener = (event) => { + const button = event.target.closest('button[data-action]'); + if (!button || !container.contains(button)) return; + const provider = button.dataset.provider; + if (!provider) return; + + const entry = this.findOauthExcludedEntry(provider); + if (button.dataset.action === 'edit') { + this.openOauthExcludedEditor(provider, entry ? entry.models : []); + } else if (button.dataset.action === 'delete') { + this.deleteOauthExcludedEntry(provider); + } + }; + + container._oauthExcludedListener = listener; + container.addEventListener('click', listener); + }, + + updateOauthExcludedButtonsState(isLoading = false) { + const refreshBtn = document.getElementById('oauth-excluded-refresh'); + const saveBtn = document.getElementById('oauth-excluded-save'); + const deleteBtn = document.getElementById('oauth-excluded-delete'); + const addBtn = document.getElementById('oauth-excluded-add'); + const disabled = isLoading || !this.isConnected; + [refreshBtn, saveBtn, deleteBtn, addBtn].forEach(btn => { + if (btn) { + btn.disabled = disabled; + } + }); + }, + + setOauthExcludedStatus(message = '') { + const statusEl = document.getElementById('oauth-excluded-status'); + if (statusEl) { + statusEl.textContent = message || ''; + } + }, + + async loadOauthExcludedModels(forceRefresh = false) { + if (!this.isConnected) { + this.renderOauthExcludedModels(); + this.updateOauthExcludedButtonsState(); + return; + } + + if (this._oauthExcludedLoading) { + return; + } + + this._oauthExcludedLoading = true; + this.updateOauthExcludedButtonsState(true); + this.setOauthExcludedStatus(i18n.t('oauth_excluded.refreshing')); + this.renderOauthExcludedModels(); + + try { + const data = await this.makeRequest('/oauth-excluded-models'); + this.oauthExcludedModels = this.normalizeOauthExcludedMap(data); + this.refreshOauthProviderOptions(); + this.setOauthExcludedStatus(''); + } catch (error) { + console.error('加载 OAuth 排除列表失败:', error); + const message = `${i18n.t('oauth_excluded.load_failed')}: ${error.message}`; + this.setOauthExcludedStatus(message); + this.showNotification(message, 'error'); + } finally { + this._oauthExcludedLoading = false; + this.updateOauthExcludedButtonsState(false); + this.renderOauthExcludedModels(); + } + }, + + async saveOauthExcludedEntry() { + if (!this.isConnected) { + this.showNotification(i18n.t('notification.connection_required'), 'error'); + return; + } + const modelsInput = document.getElementById('oauth-excluded-models'); + if (!modelsInput) return; + + const providerValue = this.getOauthExcludedProviderValue(); + if (!providerValue) { + this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error'); + return; + } + + const models = this.parseOauthExcludedModelsInput(modelsInput.value); + this.updateOauthExcludedButtonsState(true); + this.setOauthExcludedStatus(i18n.t('oauth_excluded.saving')); + + try { + await this.makeRequest('/oauth-excluded-models', { + method: 'PATCH', + body: JSON.stringify({ + provider: providerValue, + models + }) + }); + const successKey = models.length === 0 ? 'oauth_excluded.delete_success' : 'oauth_excluded.save_success'; + this.showNotification(i18n.t(successKey), 'success'); + this.clearCache('oauth-excluded-models'); + await this.loadOauthExcludedModels(true); + this.closeModal(); + } catch (error) { + this.showNotification(`${i18n.t('oauth_excluded.save_failed')}: ${error.message}`, 'error'); + } finally { + this.setOauthExcludedStatus(''); + this.updateOauthExcludedButtonsState(false); + } + }, + + async deleteOauthExcludedEntry(providerOverride = null) { + if (!this.isConnected) { + this.showNotification(i18n.t('notification.connection_required'), 'error'); + return; + } + const providerValue = (providerOverride || this.getOauthExcludedProviderValue() || '').trim(); + + if (!providerValue) { + this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error'); + return; + } + + if (!confirm(i18n.t('oauth_excluded.delete_confirm', { provider: providerValue }))) { + return; + } + + this.updateOauthExcludedButtonsState(true); + this.setOauthExcludedStatus(i18n.t('oauth_excluded.deleting')); + + try { + await this.makeRequest(`/oauth-excluded-models?provider=${encodeURIComponent(providerValue)}`, { method: 'DELETE' }); + this.showNotification(i18n.t('oauth_excluded.delete_success'), 'success'); + this.clearCache('oauth-excluded-models'); + await this.loadOauthExcludedModels(true); + this.closeModal(); + } catch (error) { + this.showNotification(`${i18n.t('oauth_excluded.delete_failed')}: ${error.message}`, 'error'); + } finally { + this.setOauthExcludedStatus(''); + this.updateOauthExcludedButtonsState(false); + } + }, + registerAuthFilesListeners() { if (!this.events || typeof this.events.on !== 'function') { return; @@ -1043,6 +1507,21 @@ export const authFilesModule = { } catch (error) { console.error('加载认证文件失败:', error); } + try { + await this.loadOauthExcludedModels(true); + } catch (error) { + console.error('加载 OAuth 排除列表失败:', error); + } + }); + + this.events.on('connection:status-changed', (event) => { + const detail = event?.detail || {}; + this.updateOauthExcludedButtonsState(false); + if (detail.isConnected) { + this.loadOauthExcludedModels(true); + } else { + this.renderOauthExcludedModels(); + } }); } }; diff --git a/src/modules/login.js b/src/modules/login.js index c3db73e..d40dae7 100644 --- a/src/modules/login.js +++ b/src/modules/login.js @@ -101,6 +101,11 @@ export const loginModule = { this.stopStatusUpdateTimer(); this.resetVersionInfo(); this.setManagementKey('', { persist: false }); + this.oauthExcludedModels = {}; + this._oauthExcludedLoading = false; + if (typeof this.renderOauthExcludedModels === 'function') { + this.renderOauthExcludedModels('all'); + } localStorage.removeItem('isLoggedIn'); secureStorage.removeItem('managementKey'); diff --git a/styles.css b/styles.css index 7c148f8..22d2f84 100644 --- a/styles.css +++ b/styles.css @@ -1748,6 +1748,71 @@ input:checked+.slider:before { white-space: nowrap; } +.oauth-excluded-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; + margin-top: 8px; +} + +.oauth-excluded-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + gap: 10px; + margin-top: 12px; +} + +.oauth-excluded-scope { + color: var(--text-secondary); + font-weight: 600; + margin: 0; +} + +.oauth-excluded-list { + margin-top: 12px; +} + +.oauth-excluded-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; + align-items: stretch; +} + +.oauth-excluded-card { + height: 100%; +} + +.oauth-excluded-card .provider-models { + margin-top: 6px; +} + +.oauth-excluded-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.oauth-excluded-card .item-actions { + top: 14px; + right: 14px; + transform: none; +} + +.oauth-excluded-editor-card .item-content { + padding-right: 0; +} + +.oauth-excluded-editor-card textarea { + min-height: 140px; +} + +.oauth-excluded-empty { + color: var(--text-tertiary); + font-size: 0.95rem; +} + /* 认证文件工具栏 */ .auth-file-toolbar { display: flex;