🐛 fix(settings): add instant UI feedback for provider config switches (#11362)

*  feat(provider): Enhance Checker and ProviderConfig components for improved state management and UI responsiveness

* 🐛 fix(provider): Enhance credential watching in ProviderConfig for better authentication handling
This commit is contained in:
sxjeru
2026-01-15 15:04:27 +08:00
committed by GitHub
parent 244318983b
commit a758d012ed
3 changed files with 158 additions and 36 deletions

View File

@@ -6,7 +6,7 @@ import { ModelIcon } from '@lobehub/icons';
import { Alert, Button, Flexbox, Highlighter, Icon, Select } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import { type ReactNode, memo, useState } from 'react';
import { type ReactNode, memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useProviderName } from '@/hooks/useProviderName';
@@ -58,9 +58,10 @@ const Checker = memo<ConnectionCheckerProps>(
({ model, provider, checkErrorRender: CheckErrorRender, onBeforeCheck, onAfterCheck }) => {
const { t } = useTranslation('setting');
const isProviderConfigUpdating = useAiInfraStore(
aiProviderSelectors.isProviderConfigUpdating(provider),
);
const [isProviderConfigUpdating, updateAiProviderConfig] = useAiInfraStore((s) => [
aiProviderSelectors.isProviderConfigUpdating(provider)(s),
s.updateAiProviderConfig,
]);
const totalModels = useAiInfraStore(aiModelSelectors.aiProviderChatModelListIds);
const [loading, setLoading] = useState(false);
@@ -69,6 +70,11 @@ const Checker = memo<ConnectionCheckerProps>(
const [error, setError] = useState<ChatMessageError | undefined>();
// Sync checkModel state when model prop changes
useEffect(() => {
setCheckModel(model);
}, [model]);
const checkConnection = async () => {
// Clear previous check results immediately
setPass(false);
@@ -131,11 +137,14 @@ const Checker = memo<ConnectionCheckerProps>(
<Select
listItemHeight={36}
onSelect={async (value) => {
// Changing the check model should be a local UI concern only.
// Persisting it to provider config would trigger global refresh/revalidation.
// Update local state
setCheckModel(value);
setPass(false);
setError(undefined);
// Persist the selected model to provider config
// This allows the model to be retained after page refresh
await updateAiProviderConfig(provider, { checkModel: value });
}}
optionRender={({ value }) => {
return (
@@ -177,9 +186,9 @@ const Checker = memo<ConnectionCheckerProps>(
style={
pass
? {
borderColor: cssVar.colorSuccess,
color: cssVar.colorSuccess,
}
borderColor: cssVar.colorSuccess,
color: cssVar.colorSuccess,
}
: undefined
}
>

View File

@@ -13,7 +13,7 @@ import {
} from '@lobehub/ui';
import { Center, Flexbox, Skeleton } from '@lobehub/ui';
import { useDebounceFn } from 'ahooks';
import { Switch } from 'antd';
import { Form as AntdForm, Switch } from 'antd';
import { createStaticStyles, cssVar, cx, responsive } from 'antd-style';
import { Loader2Icon, LockIcon } from 'lucide-react';
import Link from 'next/link';
@@ -150,26 +150,72 @@ const ProviderConfig = memo<ProviderConfigProps>(
enabled,
isLoading,
configUpdating,
enableResponseApi,
isProviderEndpointNotEmpty,
isProviderApiKeyNotEmpty,
providerRuntimeConfig,
] = useAiInfraStore((s) => [
aiProviderSelectors.providerDetailById(id)(s),
s.updateAiProviderConfig,
aiProviderSelectors.isProviderEnabled(id)(s),
aiProviderSelectors.isAiProviderConfigLoading(id)(s),
aiProviderSelectors.isProviderConfigUpdating(id)(s),
aiProviderSelectors.isProviderEnableResponseApi(id)(s),
aiProviderSelectors.isActiveProviderEndpointNotEmpty(s),
aiProviderSelectors.isActiveProviderApiKeyNotEmpty(s),
aiProviderSelectors.providerConfigById(id)(s),
]);
// Watch form values in real-time to show/hide switches immediately
// Watch nested form values for endpoints
const formBaseURL = AntdForm.useWatch(['keyVaults', 'baseURL'], form);
const formEndpoint = AntdForm.useWatch(['keyVaults', 'endpoint'], form);
// Watch all possible credential fields for different providers
const formApiKey = AntdForm.useWatch(['keyVaults', 'apiKey'], form);
const formAccessKeyId = AntdForm.useWatch(['keyVaults', 'accessKeyId'], form);
const formSecretAccessKey = AntdForm.useWatch(['keyVaults', 'secretAccessKey'], form);
const formUsername = AntdForm.useWatch(['keyVaults', 'username'], form);
const formPassword = AntdForm.useWatch(['keyVaults', 'password'], form);
// Check if provider has endpoint and apiKey based on runtime config
// Fallback to data.keyVaults if runtime config is not yet loaded
const keyVaults = providerRuntimeConfig?.keyVaults || data?.keyVaults;
// Use form values first (for immediate update), fallback to stored values
const isProviderEndpointNotEmpty =
!!formBaseURL || !!formEndpoint || !!keyVaults?.baseURL || !!keyVaults?.endpoint;
// Check if any credential is present for different authentication types:
// - Standard apiKey (OpenAI, Azure, Cloudflare, VertexAI, etc.)
// - AWS Bedrock credentials (accessKeyId, secretAccessKey)
// - ComfyUI basic auth (username and password)
const isProviderApiKeyNotEmpty = !!(
formApiKey ||
keyVaults?.apiKey ||
formAccessKeyId ||
keyVaults?.accessKeyId ||
formSecretAccessKey ||
keyVaults?.secretAccessKey ||
(formUsername && formPassword) ||
(keyVaults?.username && keyVaults?.password)
);
// Track the last initialized provider ID to avoid resetting form during edits
const lastInitializedIdRef = useRef<string | null>(null);
useLayoutEffect(() => {
if (isLoading) return;
// set the first time
form.setFieldsValue(data);
}, [isLoading, id, data]);
// Only initialize form when:
// 1. First load (lastInitializedIdRef.current === null)
// 2. Provider ID changed (switching between providers)
const shouldInitialize = lastInitializedIdRef.current !== id;
if (!shouldInitialize) return;
// Merge data from both sources to ensure all fields are initialized correctly
// data: contains basic info like apiKey, baseURL, fetchOnClient
// providerRuntimeConfig: contains nested config like enableResponseApi
const mergedData = {
...data,
...(providerRuntimeConfig?.config && { config: providerRuntimeConfig.config }),
};
// Set form values and mark as initialized
form.setFieldsValue(mergedData);
lastInitializedIdRef.current = id;
}, [isLoading, id, data, providerRuntimeConfig, form]);
// 标记是否正在进行连接测试
const isCheckingConnection = useRef(false);
@@ -298,24 +344,23 @@ const ProviderConfig = memo<ProviderConfigProps>(
(defaultShowBrowserRequest ||
(showEndpoint && isProviderEndpointNotEmpty) ||
(showApiKey && isProviderApiKeyNotEmpty));
const clientFetchItem = showClientFetch && {
children: isLoading ? <SkeletonSwitch /> : <Switch loading={configUpdating} />,
desc: t('providerModels.config.fetchOnClient.desc'),
label: t('providerModels.config.fetchOnClient.title'),
minWidth: undefined,
name: 'fetchOnClient',
};
const clientFetchItem = showClientFetch
? {
children: isLoading ? <SkeletonSwitch /> : <Switch loading={configUpdating} />,
desc: t('providerModels.config.fetchOnClient.desc'),
label: t('providerModels.config.fetchOnClient.title'),
minWidth: undefined,
name: 'fetchOnClient',
}
: undefined;
const configItems = [
...apiKeyItem,
endpointItem,
supportResponsesApi
? {
children: isLoading ? (
<Skeleton.Button active />
) : (
<Switch loading={configUpdating} value={enableResponseApi} />
),
children: isLoading ? <Skeleton.Button active /> : <Switch loading={configUpdating} />,
desc: t('providerModels.config.responsesApi.desc'),
label: t('providerModels.config.responsesApi.title'),
minWidth: undefined,
@@ -364,7 +409,7 @@ const ProviderConfig = memo<ProviderConfigProps>(
{isCustom && <UpdateProviderInfo />}
{canDeactivate && !(ENABLE_BUSINESS_FEATURES && id === 'lobehub') && (
<EnableSwitch id={id} />
<EnableSwitch id={id} key={id} />
)}
</Flexbox>
),

View File

@@ -77,10 +77,10 @@ export const normalizeImageModel = async (
const fallbackParametersPromise = model.parameters
? Promise.resolve<ModelParamsSchema | undefined>(model.parameters)
: getModelPropertyWithFallback<ModelParamsSchema | undefined>(
model.id,
'parameters',
model.providerId,
);
model.id,
'parameters',
model.providerId,
);
const modelWithPricing = model as AIImageModelCard;
const fallbackPricingPromise = modelWithPricing.pricing
@@ -260,6 +260,19 @@ export const createAiProviderSlice: StateCreator<
toggleProviderEnabled: async (id: string, enabled: boolean) => {
get().internal_toggleAiProviderLoading(id, true);
await aiProviderService.toggleProviderEnabled(id, enabled);
// Immediately update local aiProviderList to reflect the change
// This ensures the switch displays correctly without waiting for SWR refresh
set(
(state) => ({
aiProviderList: state.aiProviderList.map((item) =>
item.id === id ? { ...item, enabled } : item,
),
}),
false,
'toggleProviderEnabled/syncEnabled',
);
await get().refreshAiProviderList();
get().internal_toggleAiProviderLoading(id, false);
@@ -277,6 +290,61 @@ export const createAiProviderSlice: StateCreator<
updateAiProviderConfig: async (id, value) => {
get().internal_toggleAiProviderConfigUpdating(id, true);
await aiProviderService.updateAiProviderConfig(id, value);
// Immediately update local state for instant UI feedback
set(
(state) => {
const currentRuntimeConfig = state.aiProviderRuntimeConfig[id];
const currentDetailConfig = state.aiProviderDetailMap[id];
const updates: Partial<typeof currentRuntimeConfig> = {};
const detailUpdates: Partial<typeof currentDetailConfig> = {};
// Update fetchOnClient if changed
if (typeof value.fetchOnClient !== 'undefined') {
// Convert null to undefined to match the interface definition
const fetchOnClientValue = value.fetchOnClient === null ? undefined : value.fetchOnClient;
updates.fetchOnClient = fetchOnClientValue;
detailUpdates.fetchOnClient = fetchOnClientValue;
}
// Update config.enableResponseApi if changed
if (value.config?.enableResponseApi !== undefined && currentRuntimeConfig?.config) {
updates.config = {
...currentRuntimeConfig.config,
enableResponseApi: value.config.enableResponseApi,
};
}
return {
// Update detail map for form display
aiProviderDetailMap:
currentDetailConfig && Object.keys(detailUpdates).length > 0
? {
...state.aiProviderDetailMap,
[id]: {
...currentDetailConfig,
...detailUpdates,
},
}
: state.aiProviderDetailMap,
// Update runtime config for selectors
aiProviderRuntimeConfig:
currentRuntimeConfig && Object.keys(updates).length > 0
? {
...state.aiProviderRuntimeConfig,
[id]: {
...currentRuntimeConfig,
...updates,
},
}
: state.aiProviderRuntimeConfig,
};
},
false,
'updateAiProviderConfig/syncChanges',
);
await get().refreshAiProviderDetail();
get().internal_toggleAiProviderConfigUpdating(id, false);