feat(ai-providers): add Claude model discovery and connectivity test

This commit is contained in:
Supra4E8C
2026-02-17 01:22:45 +08:00
parent 7d4c400084
commit 3769447604
9 changed files with 1059 additions and 161 deletions

View File

@@ -0,0 +1,292 @@
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
type LocationState = { fromAiProviders?: boolean } | null;
type TestStatus = 'idle' | 'loading' | 'success' | 'error';
export type ClaudeEditOutletContext = {
hasIndexParam: boolean;
editIndex: number | null;
invalidIndexParam: boolean;
invalidIndex: boolean;
disableControls: boolean;
loading: boolean;
saving: boolean;
form: ProviderFormState;
setForm: Dispatch<SetStateAction<ProviderFormState>>;
testModel: string;
setTestModel: Dispatch<SetStateAction<string>>;
testStatus: TestStatus;
setTestStatus: Dispatch<SetStateAction<TestStatus>>;
testMessage: string;
setTestMessage: Dispatch<SetStateAction<string>>;
availableModels: string[];
handleBack: () => void;
handleSave: () => Promise<void>;
mergeDiscoveredModels: (selectedModels: ModelInfo[]) => void;
};
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
const parseIndexParam = (value: string | undefined) => {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
};
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
export function AiProvidersClaudeEditLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const params = useParams<{ index?: string }>();
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
const invalidIndexParam = hasIndexParam && editIndex === null;
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const disableControls = connectionStatus !== 'connected';
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<TestStatus>('idle');
const [testMessage, setTestMessage] = useState('');
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
return configs[editIndex];
}, [configs, editIndex]);
const invalidIndex = editIndex !== null && !initialData;
const availableModels = useMemo(
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
[form.modelEntries]
);
const handleBack = useCallback(() => {
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [location.state, navigate]);
useEffect(() => {
let cancelled = false;
const hasValidCache = isCacheValid('claude-api-key');
if (!hasValidCache) {
setLoading(true);
}
fetchConfig('claude-api-key')
.then((value) => {
if (cancelled) return;
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
})
.catch((err: unknown) => {
if (cancelled) return;
const message = getErrorMessage(err) || t('notification.refresh_failed');
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [fetchConfig, isCacheValid, showNotification, t]);
useEffect(() => {
if (loading) return;
if (initialData) {
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, loading]);
useEffect(() => {
if (loading) return;
if (availableModels.length === 0) {
if (testModel) {
setTestModel('');
setTestStatus('idle');
setTestMessage('');
}
return;
}
if (!testModel || !availableModels.includes(testModel)) {
setTestModel(availableModels[0]);
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, loading, testModel]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
if (!selectedModels.length) return;
let addedCount = 0;
setForm((prev) => {
const mergedMap = new Map<string, ModelEntry>();
prev.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
selectedModels.forEach((model) => {
const name = model.name.trim();
if (!name || mergedMap.has(name)) return;
mergedMap.set(name, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
return {
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
};
});
if (addedCount > 0) {
showNotification(t('ai_providers.claude_models_fetch_added', { count: addedCount }), 'success');
}
},
[showNotification, t]
);
const handleSave = useCallback(async () => {
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
if (!canSave) return;
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl: (form.baseUrl ?? '').trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: form.modelEntries
.map((entry) => {
const name = entry.name.trim();
if (!name) return null;
const alias = entry.alias.trim();
return { name, alias: alias || name };
})
.filter(Boolean) as ProviderKeyConfig['models'],
excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
editIndex !== null
? configs.map((item, idx) => (idx === editIndex ? payload : item))
: [...configs, payload];
await providersApi.saveClaudeConfigs(nextList);
setConfigs(nextList);
updateConfigValue('claude-api-key', nextList);
clearCache('claude-api-key');
showNotification(
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
'success'
);
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
} finally {
setSaving(false);
}
}, [
clearCache,
configs,
disableControls,
editIndex,
form,
handleBack,
invalidIndex,
invalidIndexParam,
loading,
saving,
showNotification,
t,
updateConfigValue,
]);
return (
<Outlet
context={{
hasIndexParam,
editIndex,
invalidIndexParam,
invalidIndex,
disableControls,
loading,
saving,
form,
setForm,
testModel,
setTestModel,
testStatus,
setTestStatus,
testMessage,
setTestMessage,
availableModels,
handleBack,
handleSave,
mergeDiscoveredModels,
} satisfies ClaudeEditOutletContext}
/>
);
}