Files
Cli-Proxy-API-Management-Ce…/src/pages/AiProvidersPage.tsx

433 lines
15 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
AmpcodeSection,
ClaudeSection,
CodexSection,
GeminiSection,
OpenAISection,
VertexSection,
ProviderNav,
useProviderStats,
} from '@/components/providers';
import {
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const hasMounted = useRef(false);
const [loading, setLoading] = useState(() => !isCacheValid());
const [error, setError] = useState('');
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>(
() => config?.geminiApiKeys || []
);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>(
() => config?.codexApiKeys || []
);
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>(
() => config?.claudeApiKeys || []
);
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>(
() => config?.vertexApiKeys || []
);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>(
() => config?.openaiCompatibility || []
);
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
const { keyStats, usageDetails, loadKeyStats } = useProviderStats();
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const loadConfigs = useCallback(async () => {
const hasValidCache = isCacheValid();
if (!hasValidCache) {
setLoading(true);
}
setError('');
try {
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
fetchConfig(),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value;
setGeminiKeys(data?.geminiApiKeys || []);
setCodexConfigs(data?.codexApiKeys || []);
setClaudeConfigs(data?.claudeApiKeys || []);
setVertexConfigs(data?.vertexApiKeys || []);
setOpenaiProviders(data?.openaiCompatibility || []);
if (vertexResult.status === 'fulfilled') {
setVertexConfigs(vertexResult.value || []);
updateConfigValue('vertex-api-key', vertexResult.value || []);
clearCache('vertex-api-key');
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
clearCache('ampcode');
}
} catch (err: unknown) {
const message = getErrorMessage(err) || t('notification.refresh_failed');
setError(message);
} finally {
setLoading(false);
}
}, [clearCache, fetchConfig, isCacheValid, t, updateConfigValue]);
useEffect(() => {
if (hasMounted.current) return;
hasMounted.current = true;
loadConfigs();
loadKeyStats();
}, [loadConfigs, loadKeyStats]);
useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
}, [
config?.geminiApiKeys,
config?.codexApiKeys,
config?.claudeApiKeys,
config?.vertexApiKeys,
config?.openaiCompatibility,
]);
const openEditor = useCallback(
(path: string) => {
navigate(path, { state: { fromAiProviders: true } });
},
[navigate]
);
const deleteGemini = async (index: number) => {
const entry = geminiKeys[index];
if (!entry) return;
showConfirmation({
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
message: t('ai_providers.gemini_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteGeminiKey(entry.apiKey);
const next = geminiKeys.filter((_, idx) => idx !== index);
setGeminiKeys(next);
updateConfigValue('gemini-api-key', next);
clearCache('gemini-api-key');
showNotification(t('notification.gemini_key_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
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: unknown) {
const message = getErrorMessage(err);
setGeminiKeys(previousList);
updateConfigValue('gemini-api-key', previousList);
clearCache('gemini-api-key');
showNotification(`${t('notification.update_failed')}: ${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: unknown) {
const message = getErrorMessage(err);
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')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
};
const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => {
const source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index];
if (!entry) return;
showConfirmation({
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
message: t(`ai_providers.${type}_delete_confirm`),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey);
const next = codexConfigs.filter((_, idx) => idx !== index);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(entry.apiKey);
const next = claudeConfigs.filter((_, idx) => idx !== index);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
clearCache('claude-api-key');
showNotification(t('notification.claude_config_deleted'), 'success');
}
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index];
if (!entry) return;
showConfirmation({
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
message: t('ai_providers.vertex_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteVertexConfig(entry.apiKey);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
updateConfigValue('vertex-api-key', next);
clearCache('vertex-api-key');
showNotification(t('notification.vertex_config_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
const deleteOpenai = async (index: number) => {
const entry = openaiProviders[index];
if (!entry) return;
showConfirmation({
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
message: t('ai_providers.openai_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteOpenAIProvider(entry.name);
const next = openaiProviders.filter((_, idx) => idx !== index);
setOpenaiProviders(next);
updateConfigValue('openai-compatibility', next);
clearCache('openai-compatibility');
showNotification(t('notification.openai_provider_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
};
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>}
<div id="provider-gemini">
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/gemini/new')}
onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)}
onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
/>
</div>
<div id="provider-codex">
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
onAdd={() => openEditor('/ai-providers/codex/new')}
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
/>
</div>
<div id="provider-claude">
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/claude/new')}
onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)}
onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
/>
</div>
<div id="provider-vertex">
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/vertex/new')}
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
onDelete={deleteVertex}
/>
</div>
<div id="provider-ampcode">
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onEdit={() => openEditor('/ai-providers/ampcode')}
/>
</div>
<div id="provider-openai">
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
onAdd={() => openEditor('/ai-providers/openai/new')}
onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
onDelete={deleteOpenai}
/>
</div>
</div>
<ProviderNav />
</div>
);
}