mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 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:
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user