feat: Added disabling features for some of the AI providers

This commit is contained in:
Supra4E8C
2025-12-14 12:21:54 +08:00
parent 20a69a25bc
commit aea1ceb6be
2 changed files with 248 additions and 43 deletions

View File

@@ -148,6 +148,8 @@
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", "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_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
"excluded_models_count": "Excluding {count} models", "excluded_models_count": "Excluding {count} models",
"config_toggle_label": "Enabled",
"config_disabled_badge": "Disabled",
"codex_title": "Codex API Configuration", "codex_title": "Codex API Configuration",
"codex_add_button": "Add Configuration", "codex_add_button": "Add Configuration",
"codex_empty_title": "No Codex Configuration", "codex_empty_title": "No Codex Configuration",
@@ -636,6 +638,8 @@
"claude_config_added": "Claude configuration added successfully", "claude_config_added": "Claude configuration added successfully",
"claude_config_updated": "Claude configuration updated successfully", "claude_config_updated": "Claude configuration updated successfully",
"claude_config_deleted": "Claude configuration deleted successfully", "claude_config_deleted": "Claude configuration deleted successfully",
"config_enabled": "Configuration enabled",
"config_disabled": "Configuration disabled",
"field_required": "Required fields cannot be empty", "field_required": "Required fields cannot be empty",
"openai_provider_required": "Please fill in provider name and Base URL", "openai_provider_required": "Please fill in provider name and Base URL",
"openai_provider_added": "OpenAI provider added successfully", "openai_provider_added": "OpenAI provider added successfully",

View File

@@ -54,6 +54,26 @@ interface AmpcodeFormState {
mappingEntries: ModelEntry[]; 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[] => const parseExcludedModels = (text: string): string[] =>
text text
.split(/[\n,]+/) .split(/[\n,]+/)
@@ -213,6 +233,7 @@ export function AiProvidersPage() {
const [openaiTestStatus, setOpenaiTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [openaiTestStatus, setOpenaiTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [openaiTestMessage, setOpenaiTestMessage] = useState(''); const [openaiTestMessage, setOpenaiTestMessage] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]); const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
const filteredOpenaiDiscoveryModels = useMemo(() => { 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 saveProvider = async (type: 'codex' | 'claude') => {
const baseUrl = (providerForm.baseUrl ?? '').trim(); const baseUrl = (providerForm.baseUrl ?? '').trim();
if (!baseUrl) { if (!baseUrl) {
@@ -735,6 +840,8 @@ export function AiProvidersPage() {
setSaving(true); setSaving(true);
try { try {
const source = type === 'codex' ? codexConfigs : claudeConfigs;
const payload: ProviderKeyConfig = { const payload: ProviderKeyConfig = {
apiKey: providerForm.apiKey.trim(), apiKey: providerForm.apiKey.trim(),
baseUrl, baseUrl,
@@ -744,7 +851,6 @@ export function AiProvidersPage() {
excludedModels: parseExcludedModels(providerForm.excludedText) excludedModels: parseExcludedModels(providerForm.excludedText)
}; };
const source = type === 'codex' ? codexConfigs : claudeConfigs;
const nextList = const nextList =
modal?.type === type && modal.index !== null modal?.type === type && modal.index !== null
? source.map((item, idx) => (idx === modal.index ? payload : item)) ? source.map((item, idx) => (idx === modal.index ? payload : item))
@@ -908,7 +1014,11 @@ export function AiProvidersPage() {
onEdit: (index: number) => void, onEdit: (index: number) => void,
onDelete: (item: T) => void, onDelete: (item: T) => void,
addLabel: string, addLabel: string,
deleteLabel?: string deleteLabel?: string,
options?: {
getRowDisabled?: (item: T, index: number) => boolean;
renderExtraActions?: (item: T, index: number) => ReactNode;
}
) => { ) => {
if (loading) { if (loading) {
return <div className="hint">{t('common.loading')}</div>; return <div className="hint">{t('common.loading')}</div>;
@@ -930,19 +1040,33 @@ export function AiProvidersPage() {
return ( return (
<div className="item-list"> <div className="item-list">
{items.map((item, index) => ( {items.map((item, index) => {
<div key={keyField(item)} className="item-row"> const rowDisabled = options?.getRowDisabled ? options.getRowDisabled(item, index) : false;
<div className="item-meta">{renderContent(item, index)}</div> return (
<div className="item-actions"> <div key={keyField(item)} className="item-row" style={rowDisabled ? { opacity: 0.6 } : undefined}>
<Button variant="secondary" size="sm" onClick={() => onEdit(index)} disabled={disableControls}> <div className="item-meta">{renderContent(item, index)}</div>
{t('common.edit')} <div className="item-actions">
</Button> {options?.renderExtraActions ? options.renderExtraActions(item, index) : null}
<Button variant="danger" size="sm" onClick={() => onDelete(item)} disabled={disableControls}> <Button
{deleteLabel || t('common.delete')} variant="secondary"
</Button> size="sm"
onClick={() => onEdit(index)}
disabled={disableControls || saving || Boolean(configSwitchingKey)}
>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(item)}
disabled={disableControls || saving || Boolean(configSwitchingKey)}
>
{deleteLabel || t('common.delete')}
</Button>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
); );
}; };
@@ -954,7 +1078,11 @@ export function AiProvidersPage() {
<Card <Card
title={t('ai_providers.gemini_title')} title={t('ai_providers.gemini_title')}
extra={ extra={
<Button size="sm" onClick={() => openGeminiModal(null)} disabled={disableControls}> <Button
size="sm"
onClick={() => openGeminiModal(null)}
disabled={disableControls || saving || Boolean(configSwitchingKey)}
>
{t('ai_providers.gemini_add_button')} {t('ai_providers.gemini_add_button')}
</Button> </Button>
} }
@@ -962,10 +1090,12 @@ export function AiProvidersPage() {
{renderList<GeminiKeyConfig>( {renderList<GeminiKeyConfig>(
geminiKeys, geminiKeys,
(item) => item.apiKey, (item) => item.apiKey,
(item, index) => { (item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
return ( const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
return (
<Fragment> <Fragment>
<div className="item-title"> <div className="item-title">
{t('ai_providers.gemini_item_title')} #{index + 1} {t('ai_providers.gemini_item_title')} #{index + 1}
@@ -992,14 +1122,19 @@ export function AiProvidersPage() {
))} ))}
</div> </div>
)} )}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{/* 排除模型徽章 */} {/* 排除模型徽章 */}
{item.excludedModels?.length ? ( {excludedModels.length ? (
<div className={styles.excludedModelsSection}> <div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}> <div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} {t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div> </div>
<div className={styles.modelTagList}> <div className={styles.modelTagList}>
{item.excludedModels.map((model) => ( {excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}> <span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span> <span className={styles.modelName}>{model}</span>
</span> </span>
@@ -1021,14 +1156,30 @@ export function AiProvidersPage() {
}, },
(index) => openGeminiModal(index), (index) => openGeminiModal(index),
(item) => deleteGemini(item.apiKey), (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) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={disableControls || loading || saving || Boolean(configSwitchingKey)}
onChange={(value) => void setConfigEnabled('gemini', index, value)}
/>
)
}
)} )}
</Card> </Card>
<Card <Card
title={t('ai_providers.codex_title')} title={t('ai_providers.codex_title')}
extra={ extra={
<Button size="sm" onClick={() => openProviderModal('codex', null)} disabled={disableControls}> <Button
size="sm"
onClick={() => openProviderModal('codex', null)}
disabled={disableControls || saving || Boolean(configSwitchingKey)}
>
{t('ai_providers.codex_add_button')} {t('ai_providers.codex_add_button')}
</Button> </Button>
} }
@@ -1036,10 +1187,12 @@ export function AiProvidersPage() {
{renderList<ProviderKeyConfig>( {renderList<ProviderKeyConfig>(
codexConfigs, codexConfigs,
(item) => item.apiKey, (item) => item.apiKey,
(item, _index) => { (item, _index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
return ( const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
return (
<Fragment> <Fragment>
<div className="item-title">{t('ai_providers.codex_item_title')}</div> <div className="item-title">{t('ai_providers.codex_item_title')}</div>
{/* API Key 行 */} {/* API Key 行 */}
@@ -1071,14 +1224,19 @@ export function AiProvidersPage() {
))} ))}
</div> </div>
)} )}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{/* 排除模型徽章 */} {/* 排除模型徽章 */}
{item.excludedModels?.length ? ( {excludedModels.length ? (
<div className={styles.excludedModelsSection}> <div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}> <div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} {t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div> </div>
<div className={styles.modelTagList}> <div className={styles.modelTagList}>
{item.excludedModels.map((model) => ( {excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}> <span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span> <span className={styles.modelName}>{model}</span>
</span> </span>
@@ -1100,14 +1258,30 @@ export function AiProvidersPage() {
}, },
(index) => openProviderModal('codex', index), (index) => openProviderModal('codex', index),
(item) => deleteProviderEntry('codex', item.apiKey), (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) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={disableControls || loading || saving || Boolean(configSwitchingKey)}
onChange={(value) => void setConfigEnabled('codex', index, value)}
/>
)
}
)} )}
</Card> </Card>
<Card <Card
title={t('ai_providers.claude_title')} title={t('ai_providers.claude_title')}
extra={ extra={
<Button size="sm" onClick={() => openProviderModal('claude', null)} disabled={disableControls}> <Button
size="sm"
onClick={() => openProviderModal('claude', null)}
disabled={disableControls || saving || Boolean(configSwitchingKey)}
>
{t('ai_providers.claude_add_button')} {t('ai_providers.claude_add_button')}
</Button> </Button>
} }
@@ -1115,10 +1289,12 @@ export function AiProvidersPage() {
{renderList<ProviderKeyConfig>( {renderList<ProviderKeyConfig>(
claudeConfigs, claudeConfigs,
(item) => item.apiKey, (item) => item.apiKey,
(item, _index) => { (item, _index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
return ( const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
return (
<Fragment> <Fragment>
<div className="item-title">{t('ai_providers.claude_item_title')}</div> <div className="item-title">{t('ai_providers.claude_item_title')}</div>
{/* API Key 行 */} {/* API Key 行 */}
@@ -1150,6 +1326,11 @@ export function AiProvidersPage() {
))} ))}
</div> </div>
)} )}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{/* 模型列表 */} {/* 模型列表 */}
{item.models?.length ? ( {item.models?.length ? (
<div className={styles.modelTagList}> <div className={styles.modelTagList}>
@@ -1167,13 +1348,13 @@ export function AiProvidersPage() {
</div> </div>
) : null} ) : null}
{/* 排除模型徽章 */} {/* 排除模型徽章 */}
{item.excludedModels?.length ? ( {excludedModels.length ? (
<div className={styles.excludedModelsSection}> <div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}> <div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: item.excludedModels.length })} {t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div> </div>
<div className={styles.modelTagList}> <div className={styles.modelTagList}>
{item.excludedModels.map((model) => ( {excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}> <span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span> <span className={styles.modelName}>{model}</span>
</span> </span>
@@ -1195,14 +1376,30 @@ export function AiProvidersPage() {
}, },
(index) => openProviderModal('claude', index), (index) => openProviderModal('claude', index),
(item) => deleteProviderEntry('claude', item.apiKey), (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) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={disableControls || loading || saving || Boolean(configSwitchingKey)}
onChange={(value) => void setConfigEnabled('claude', index, value)}
/>
)
}
)} )}
</Card> </Card>
<Card <Card
title={t('ai_providers.ampcode_title')} title={t('ai_providers.ampcode_title')}
extra={ extra={
<Button size="sm" onClick={openAmpcodeModal} disabled={disableControls}> <Button
size="sm"
onClick={openAmpcodeModal}
disabled={disableControls || saving || ampcodeSaving || Boolean(configSwitchingKey)}
>
{t('common.edit')} {t('common.edit')}
</Button> </Button>
} }
@@ -1259,7 +1456,11 @@ export function AiProvidersPage() {
<Card <Card
title={t('ai_providers.openai_title')} title={t('ai_providers.openai_title')}
extra={ extra={
<Button size="sm" onClick={() => openOpenaiModal(null)} disabled={disableControls}> <Button
size="sm"
onClick={() => openOpenaiModal(null)}
disabled={disableControls || saving || Boolean(configSwitchingKey)}
>
{t('ai_providers.openai_add_button')} {t('ai_providers.openai_add_button')}
</Button> </Button>
} }