diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b532672..b88ed77 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -148,6 +148,8 @@ "excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", "excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.", "excluded_models_count": "Excluding {count} models", + "config_toggle_label": "Enabled", + "config_disabled_badge": "Disabled", "codex_title": "Codex API Configuration", "codex_add_button": "Add Configuration", "codex_empty_title": "No Codex Configuration", @@ -636,6 +638,8 @@ "claude_config_added": "Claude configuration added successfully", "claude_config_updated": "Claude configuration updated successfully", "claude_config_deleted": "Claude configuration deleted successfully", + "config_enabled": "Configuration enabled", + "config_disabled": "Configuration disabled", "field_required": "Required fields cannot be empty", "openai_provider_required": "Please fill in provider name and Base URL", "openai_provider_added": "OpenAI provider added successfully", diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index b946c77..bc05f74 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -54,6 +54,26 @@ interface AmpcodeFormState { mappingEntries: ModelEntry[]; } +const DISABLE_ALL_MODELS_RULE = '*'; + +const hasDisableAllModelsRule = (models?: string[]) => + Array.isArray(models) && models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE); + +const stripDisableAllModelsRule = (models?: string[]) => + Array.isArray(models) + ? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE) + : []; + +const withDisableAllModelsRule = (models?: string[]) => { + const base = stripDisableAllModelsRule(models); + return [...base, DISABLE_ALL_MODELS_RULE]; +}; + +const withoutDisableAllModelsRule = (models?: string[]) => { + const base = stripDisableAllModelsRule(models); + return base; +}; + const parseExcludedModels = (text: string): string[] => text .split(/[\n,]+/) @@ -213,6 +233,7 @@ export function AiProvidersPage() { const [openaiTestStatus, setOpenaiTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [openaiTestMessage, setOpenaiTestMessage] = useState(''); const [saving, setSaving] = useState(false); + const [configSwitchingKey, setConfigSwitchingKey] = useState(null); const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]); const filteredOpenaiDiscoveryModels = useMemo(() => { @@ -726,6 +747,90 @@ export function AiProvidersPage() { } }; + const setConfigEnabled = async ( + provider: 'gemini' | 'codex' | 'claude', + index: number, + enabled: boolean + ) => { + if (provider === 'gemini') { + const current = geminiKeys[index]; + if (!current) return; + + const switchingKey = `${provider}:${current.apiKey}`; + setConfigSwitchingKey(switchingKey); + + const previousList = geminiKeys; + const nextExcluded = enabled + ? withoutDisableAllModelsRule(current.excludedModels) + : withDisableAllModelsRule(current.excludedModels); + const nextItem: GeminiKeyConfig = { ...current, excludedModels: nextExcluded }; + const nextList = previousList.map((item, idx) => (idx === index ? nextItem : item)); + + setGeminiKeys(nextList); + updateConfigValue('gemini-api-key', nextList); + clearCache('gemini-api-key'); + + try { + await providersApi.saveGeminiKeys(nextList); + showNotification(enabled ? t('notification.config_enabled') : t('notification.config_disabled'), 'success'); + } catch (err: any) { + setGeminiKeys(previousList); + updateConfigValue('gemini-api-key', previousList); + clearCache('gemini-api-key'); + showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error'); + } finally { + setConfigSwitchingKey(null); + } + return; + } + + const source = provider === 'codex' ? codexConfigs : claudeConfigs; + const current = source[index]; + if (!current) return; + + const switchingKey = `${provider}:${current.apiKey}`; + setConfigSwitchingKey(switchingKey); + + const previousList = source; + const nextExcluded = enabled + ? withoutDisableAllModelsRule(current.excludedModels) + : withDisableAllModelsRule(current.excludedModels); + const nextItem: ProviderKeyConfig = { ...current, excludedModels: nextExcluded }; + const nextList = previousList.map((item, idx) => (idx === index ? nextItem : item)); + + if (provider === 'codex') { + setCodexConfigs(nextList); + updateConfigValue('codex-api-key', nextList); + clearCache('codex-api-key'); + } else { + setClaudeConfigs(nextList); + updateConfigValue('claude-api-key', nextList); + clearCache('claude-api-key'); + } + + try { + if (provider === 'codex') { + await providersApi.saveCodexConfigs(nextList); + } else { + await providersApi.saveClaudeConfigs(nextList); + } + showNotification(enabled ? t('notification.config_enabled') : t('notification.config_disabled'), 'success'); + } catch (err: any) { + if (provider === 'codex') { + setCodexConfigs(previousList); + updateConfigValue('codex-api-key', previousList); + clearCache('codex-api-key'); + } else { + setClaudeConfigs(previousList); + updateConfigValue('claude-api-key', previousList); + clearCache('claude-api-key'); + } + showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error'); + } finally { + setConfigSwitchingKey(null); + } + }; + const saveProvider = async (type: 'codex' | 'claude') => { const baseUrl = (providerForm.baseUrl ?? '').trim(); if (!baseUrl) { @@ -735,6 +840,8 @@ export function AiProvidersPage() { setSaving(true); try { + const source = type === 'codex' ? codexConfigs : claudeConfigs; + const payload: ProviderKeyConfig = { apiKey: providerForm.apiKey.trim(), baseUrl, @@ -744,7 +851,6 @@ export function AiProvidersPage() { excludedModels: parseExcludedModels(providerForm.excludedText) }; - const source = type === 'codex' ? codexConfigs : claudeConfigs; const nextList = modal?.type === type && modal.index !== null ? source.map((item, idx) => (idx === modal.index ? payload : item)) @@ -908,7 +1014,11 @@ export function AiProvidersPage() { onEdit: (index: number) => void, onDelete: (item: T) => void, addLabel: string, - deleteLabel?: string + deleteLabel?: string, + options?: { + getRowDisabled?: (item: T, index: number) => boolean; + renderExtraActions?: (item: T, index: number) => ReactNode; + } ) => { if (loading) { return
{t('common.loading')}
; @@ -930,19 +1040,33 @@ export function AiProvidersPage() { return (
- {items.map((item, index) => ( -
-
{renderContent(item, index)}
-
- - + {items.map((item, index) => { + const rowDisabled = options?.getRowDisabled ? options.getRowDisabled(item, index) : false; + return ( +
+
{renderContent(item, index)}
+
+ {options?.renderExtraActions ? options.renderExtraActions(item, index) : null} + + +
-
- ))} + ); + })}
); }; @@ -954,7 +1078,11 @@ export function AiProvidersPage() { openGeminiModal(null)} disabled={disableControls}> + } @@ -962,10 +1090,12 @@ export function AiProvidersPage() { {renderList( geminiKeys, (item) => item.apiKey, - (item, index) => { - const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); - const headerEntries = Object.entries(item.headers || {}); - return ( + (item, index) => { + const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); + const configDisabled = hasDisableAllModelsRule(item.excludedModels); + const excludedModels = item.excludedModels ?? []; + return (
{t('ai_providers.gemini_item_title')} #{index + 1} @@ -992,14 +1122,19 @@ export function AiProvidersPage() { ))}
)} + {configDisabled && ( +
+ {t('ai_providers.config_disabled_badge')} +
+ )} {/* 排除模型徽章 */} - {item.excludedModels?.length ? ( + {excludedModels.length ? (
- {t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} + {t('ai_providers.excluded_models_count', { count: excludedModels.length })}
- {item.excludedModels.map((model) => ( + {excludedModels.map((model) => ( {model} @@ -1021,14 +1156,30 @@ export function AiProvidersPage() { }, (index) => openGeminiModal(index), (item) => deleteGemini(item.apiKey), - t('ai_providers.gemini_add_button') + t('ai_providers.gemini_add_button'), + undefined, + { + getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels), + renderExtraActions: (item, index) => ( + void setConfigEnabled('gemini', index, value)} + /> + ) + } )} openProviderModal('codex', null)} disabled={disableControls}> + } @@ -1036,10 +1187,12 @@ export function AiProvidersPage() { {renderList( codexConfigs, (item) => item.apiKey, - (item, _index) => { - const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); - const headerEntries = Object.entries(item.headers || {}); - return ( + (item, _index) => { + const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); + const configDisabled = hasDisableAllModelsRule(item.excludedModels); + const excludedModels = item.excludedModels ?? []; + return (
{t('ai_providers.codex_item_title')}
{/* API Key 行 */} @@ -1071,14 +1224,19 @@ export function AiProvidersPage() { ))}
)} + {configDisabled && ( +
+ {t('ai_providers.config_disabled_badge')} +
+ )} {/* 排除模型徽章 */} - {item.excludedModels?.length ? ( + {excludedModels.length ? (
- {t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} + {t('ai_providers.excluded_models_count', { count: excludedModels.length })}
- {item.excludedModels.map((model) => ( + {excludedModels.map((model) => ( {model} @@ -1100,14 +1258,30 @@ export function AiProvidersPage() { }, (index) => openProviderModal('codex', index), (item) => deleteProviderEntry('codex', item.apiKey), - t('ai_providers.codex_add_button') + t('ai_providers.codex_add_button'), + undefined, + { + getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels), + renderExtraActions: (item, index) => ( + void setConfigEnabled('codex', index, value)} + /> + ) + } )} openProviderModal('claude', null)} disabled={disableControls}> + } @@ -1115,10 +1289,12 @@ export function AiProvidersPage() { {renderList( claudeConfigs, (item) => item.apiKey, - (item, _index) => { - const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); - const headerEntries = Object.entries(item.headers || {}); - return ( + (item, _index) => { + const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); + const headerEntries = Object.entries(item.headers || {}); + const configDisabled = hasDisableAllModelsRule(item.excludedModels); + const excludedModels = item.excludedModels ?? []; + return (
{t('ai_providers.claude_item_title')}
{/* API Key 行 */} @@ -1150,6 +1326,11 @@ export function AiProvidersPage() { ))}
)} + {configDisabled && ( +
+ {t('ai_providers.config_disabled_badge')} +
+ )} {/* 模型列表 */} {item.models?.length ? (
@@ -1167,13 +1348,13 @@ export function AiProvidersPage() {
) : null} {/* 排除模型徽章 */} - {item.excludedModels?.length ? ( + {excludedModels.length ? (
- {t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} + {t('ai_providers.excluded_models_count', { count: excludedModels.length })}
- {item.excludedModels.map((model) => ( + {excludedModels.map((model) => ( {model} @@ -1195,14 +1376,30 @@ export function AiProvidersPage() { }, (index) => openProviderModal('claude', index), (item) => deleteProviderEntry('claude', item.apiKey), - t('ai_providers.claude_add_button') + t('ai_providers.claude_add_button'), + undefined, + { + getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels), + renderExtraActions: (item, index) => ( + void setConfigEnabled('claude', index, value)} + /> + ) + } )} + } @@ -1259,7 +1456,11 @@ export function AiProvidersPage() { openOpenaiModal(null)} disabled={disableControls}> + }