fix(setting-model): fix instance display and api_key loss after auto-save (#16853)

This commit is contained in:
chanx
2026-07-13 15:20:01 +08:00
committed by GitHub
parent 2b9569ff51
commit f21368057b
11 changed files with 67 additions and 23 deletions

View File

@@ -281,6 +281,19 @@ export const useAddProviderInstance = () => {
true,
);
if (data.code === 0 && !params.verify) {
// Invalidate `addedProviders` so `has_instance` flips to `true`
// for providers that just gained their first instance. Without
// this, the parent page keeps `providerQueryName === ''` (the
// `has_instance` gate in index.tsx) and the `providerInstances`
// query stays disabled, so the newly-saved instance never
// appears. `exact: true` avoids cascading into every
// providerInstances / instanceModels query (they share the
// `['AddedProviders', ...]` prefix) - the dedicated invalidation
// below handles those.
queryClient.invalidateQueries({
queryKey: LlmKeys.addedProviders(),
exact: true,
});
queryClient.invalidateQueries({
queryKey: LlmKeys.providerInstances(params.llm_factory),
});

View File

@@ -19,6 +19,7 @@ import { useTranslate } from '@/hooks/common-hooks';
import {
LlmKeys,
useAddProviderInstance,
useFetchAddedProviders,
useFetchProviderInstances,
} from '@/hooks/use-llm-request';
import { IProviderInstance } from '@/interfaces/database/llm';
@@ -61,9 +62,13 @@ const SettingModelV2: FC = () => {
// for the current selection. Reset on every selection change.
const cancelledRef = useRef(false);
// Always re-fetch when the selection changes. Passing an empty string
// disables the query.
const providerQueryName = selection === 'default' ? '' : selection;
const { data: addedProviders } = useFetchAddedProviders();
const providerQueryName = useMemo(() => {
if (selection === 'default') return '';
return addedProviders.some((p) => p.name === selection && p.has_instance)
? selection
: '';
}, [selection, addedProviders]);
const { data: instances, loading: instancesLoading } =
useFetchProviderInstances(providerQueryName);
const queryClient = useQueryClient();

View File

@@ -63,7 +63,7 @@ export function SavedModeCard({
<CollapsibleTrigger asChild>
<div className="flex items-center gap-1 w-full mb-5">
<div
className="group w-[calc(100%-40px)] flex items-center flex-1 gap-2 px-2 py-1 cursor-pointer bg-bg-input rounded-md"
className="group w-[calc(100%-40px)] flex items-center flex-1 gap-2 px-2 py-1 cursor-pointer bg-bg-card rounded-md"
data-testid="instance-name-row"
>
<Button
@@ -130,6 +130,7 @@ export function SavedModeCard({
instance={instance}
hideActions={false}
hideIfEmpty={false}
instanceDetailsLoaded={instanceDetailsLoaded}
getFormValues={() => formRef.current?.getValues?.() ?? {}}
onBlurSuppressChange={(s) => {
blurSuppressRef.current = s;

View File

@@ -655,6 +655,7 @@ export function useSavedAutoSave({
if (isDraft) return;
const resolvedId = instanceDetails?.id || instanceId;
if (!resolvedId) return;
if (hasSyncedInstanceRef.current) return;
// Match the api_key shape performAutoSave produces (extra credential
// fields nested inside api_key) so the first blur after mount
// doesn't see a signature diff and fire a redundant save. model_info
@@ -802,12 +803,15 @@ export function useFormFields(
() => fields.filter((f) => f.name !== 'instance_name'),
[fields],
);
const defaultValuesKey = JSON.stringify(defaultValues);
const formDefaultValues = useMemo(() => {
const { instance_name: _ignored, ...rest } = (defaultValues ??
{}) as Record<string, any>;
void _ignored;
return rest;
}, [defaultValues]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValuesKey]);
return { formFields, formDefaultValues };
}

View File

@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { LLMFactory } from '@/constants/llm';
import {
useAddInstanceModel,
useDeleteInstanceModels,
@@ -122,6 +123,7 @@ interface UseModelsCatalogArgs {
isDraftInstance: boolean;
resolveCreds: () => ResolvedCreds;
instanceModels: IInstanceModel[] | undefined;
instanceDetailsLoaded: boolean;
}
export function useModelsCatalog({
@@ -131,6 +133,7 @@ export function useModelsCatalog({
isDraftInstance,
resolveCreds,
instanceModels,
instanceDetailsLoaded,
}: UseModelsCatalogArgs) {
const { listProviderModels } = useListProviderModels();
const [catalog, setCatalog] = useState<IProviderModelItem[]>([]);
@@ -141,9 +144,13 @@ export function useModelsCatalog({
// The result is merged into `catalog`; the displayed list then becomes
// the union of catalog + instance models.
const handleListModels = async () => {
const { apiKey, baseUrl } = resolveCreds();
if (providerName === LLMFactory.VolcEngine && !apiKey) {
setHasFetched(true);
return;
}
setManualListLoading(true);
try {
const { apiKey, baseUrl } = resolveCreds();
const ret = await listProviderModels({
provider_name: providerName,
api_key: apiKey,
@@ -163,16 +170,21 @@ export function useModelsCatalog({
// Auto-fetch the provider's available-models catalog when this section
// mounts (effectively "when the card is expanded"). Skipped for draft
// instances and catalog-preview-only hosts.
const requiresApiKey = providerName === LLMFactory.VolcEngine;
const credsReady = !requiresApiKey || instanceDetailsLoaded;
const hasAutoFetchedRef = useRef(false);
useEffect(() => {
if (hasAutoFetchedRef.current) return;
if (hideActions) return;
if (!providerName) return;
if (isDraftInstance) return;
if (!credsReady) return;
hasAutoFetchedRef.current = true;
handleListModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerName, instanceName, hideActions]);
}, [providerName, instanceName, hideActions, credsReady]);
// Mark `hasFetched` true once the per-instance query resolves — even if
// it returned an empty array — so `hideIfEmpty` can safely take effect.

View File

@@ -48,6 +48,7 @@ export function ModelsSection(props: ModelsSectionProps) {
instance,
hideActions = false,
hideIfEmpty = false,
instanceDetailsLoaded = false,
getFormValues,
onBlurSuppressChange,
onInstanceModelsChange,
@@ -80,6 +81,7 @@ export function ModelsSection(props: ModelsSectionProps) {
isDraftInstance,
resolveCreds,
instanceModels,
instanceDetailsLoaded,
});
// 4. Derived union list (instance catalog) + push to host.

View File

@@ -30,6 +30,15 @@ export interface ModelsSectionProps {
* draft state where there is no backend instance to query yet).
*/
hideActions?: boolean;
/**
* True once the lazy-loaded instance details (which carry `api_key` /
* `base_url` - the list endpoint omits them) have resolved. Providers
* whose upstream model-list endpoint requires an api_key (e.g.
* VolcEngine) use this to defer the auto-fetch until the credential
* is available in the host form, instead of firing a request that is
* guaranteed to fail and then refusing to retry.
*/
instanceDetailsLoaded?: boolean;
/**
* If true, the section renders nothing once the first catalog fetch
* completes and no models are available. Used by draft instances to

View File

@@ -62,9 +62,9 @@ const VerifyButton: React.FC<IVerifyButton> = ({
const [isVerifying, setIsVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
const contextForm = useFormContext();
const form = formRef?.current ?? contextForm;
const onHandleVerify = useCallback(async () => {
const form = formRef?.current ?? contextForm;
const formValid = await form?.trigger();
if (!formValid) {
return;
@@ -99,7 +99,7 @@ const VerifyButton: React.FC<IVerifyButton> = ({
} finally {
// setVerifyLoading(false);
}
}, [form, onVerify, params, verifyCallback]);
}, [formRef, contextForm, onVerify, params, verifyCallback]);
const handleVerify = useCallback(async () => {
setVerifyResult({
isValid: null,
@@ -157,13 +157,11 @@ const VerifyButton: React.FC<IVerifyButton> = ({
</div>
)}
</div>
{verifyResult && verifyResult.isValid !== null && (
{verifyResult && verifyResult.isValid === false && verifyResult.logs && (
<div className="space-y-2">
{verifyResult.logs && (
<div className="w-full whitespace-pre-line text-wrap bg-bg-card rounded-lg h-fit max-h-[250px] overflow-y-auto scrollbar-auto p-2.5">
{replaceText(verifyResult.logs)}
</div>
)}
<div className="w-full whitespace-pre-line text-wrap bg-bg-card rounded-lg h-fit max-h-[250px] overflow-y-auto scrollbar-auto p-2.5">
{replaceText(verifyResult.logs)}
</div>
</div>
)}
</div>

View File

@@ -37,7 +37,7 @@ export const GenericApiKeyConfig: ProviderConfig = {
{
name: 'api_key',
label: 'apiKey',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: true,
placeholder: 'apiKeyMessage',
validation: { message: 'apiKeyMessage' },

View File

@@ -216,7 +216,7 @@ function buildLocalConfig(
{
name: 'api_key',
label: 'apiKey',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: false,
placeholder: 'apiKeyMessage',
shouldRender: 'hideWhenInstanceExists',

View File

@@ -50,7 +50,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
{
name: 'api_key',
label: 'apiKey',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: false,
placeholder: 'apiKeyMessage',
shouldRender: 'hideWhenInstanceExists',
@@ -99,7 +99,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
{
name: 'api_key',
label: 'addArkApiKey',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: true,
placeholder: 'ArkApiKeyMessage',
shouldRender: 'hideWhenInstanceExists',
@@ -153,7 +153,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
{
name: 'google_service_account_key',
label: 'addGoogleServiceAccountKey',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: true,
placeholder: 'GoogleServiceAccountKeyMessage',
shouldRender: 'hideWhenInstanceExists',
@@ -246,7 +246,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
{
name: 'spark_api_password',
label: 'addSparkAPIPassword',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: true,
placeholder: 'SparkAPIPasswordMessage',
shouldRender: 'hideWhenInstanceExists',
@@ -429,7 +429,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
{
name: 'opendataloader_api_key',
label: 'apiKey',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: false,
placeholder: 'apiKeyPlaceholder',
},
@@ -491,7 +491,7 @@ export const ProviderConfigMap: Record<string, ProviderConfig> = {
{
name: 'paddleocr_access_token',
label: 'paddleocrAccessToken',
type: FormFieldType.Text,
type: FormFieldType.Password,
required: false,
placeholder: 'paddleocrAccessTokenPlaceholder',
validation: { message: 'paddleocrAccessTokenMessage' },