mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
433 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|