Fix: If the model is removed from the supplier list, an error should occur when accessing the search and memory pages. (#18270)

This commit is contained in:
balibabu
2026-08-14 19:07:57 +08:00
committed by GitHub
parent 2678a0c5f5
commit 3a8ea35965
7 changed files with 356 additions and 102 deletions

View File

@@ -45,7 +45,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { buildModelValue, parseModelValue } from '@/utils/llm-util';
import {
buildModelValue,
buildValidModelIds,
parseModelValue,
} from '@/utils/llm-util';
import { useWarnEmptyModel } from './use-warn-empty-model';
export const enum LLMApiAction {
@@ -149,6 +153,27 @@ export const useFetchAllAddedModels = (
return { data, loading, isFetched, isError };
};
/**
* The set of ids under which added models of the given types can be
* referenced (both the model_id form and the legacy composite form), for
* validating that a persisted form value still points at an existing model.
* `isFetched` must be checked before trusting `validIds` — while the model
* list is loading it is empty and every value would look missing.
*/
export const useModelValidIds = (
modelTypes: string[],
ownerTenantId?: string,
) => {
const { data, isFetched } = useFetchAllAddedModels(undefined, ownerTenantId);
const validIds = useMemo(
() => buildValidModelIds(data, modelTypes),
[data, modelTypes],
);
return { validIds, isFetched };
};
export function useFindLlmByUuid() {
const { data: models } = useFetchAllAddedModels();

View File

@@ -60,6 +60,8 @@ export default {
s: 'S',
pleaseSelect: 'Please select',
pleaseInput: 'Please input',
modelUnavailable:
'The previously selected model has been deleted, please select another one',
submit: 'Submit',
clear: 'Clear',
embedIntoSite: 'Embed into webpage',

View File

@@ -49,6 +49,7 @@ export default {
s: '秒',
pleaseSelect: '请选择',
pleaseInput: '请输入',
modelUnavailable: '之前选择的模型已被删除,请重新选择',
submit: '提交',
clear: '清空',
embedIntoSite: '嵌入网站',

View File

@@ -9,33 +9,28 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useFetchMemoryBaseConfiguration } from '../hooks/use-memory-setting';
import {
AdvancedSettingsForm,
advancedSettingsFormSchema,
defaultAdvancedSettingsForm,
} from './advanced-settings-form';
import { BasicInfo, basicInfoSchema, defaultBasicInfo } from './basic-form';
import { BasicInfo, defaultBasicInfo } from './basic-form';
import { useUpdateMemoryConfig } from './hook';
import { MemoryModelForm, defaultMemoryModelForm } from './memory-model-form';
import {
MemoryModelForm,
defaultMemoryModelForm,
memoryModelFormSchema,
} from './memory-model-form';
useMemoryFormSchema,
useRevalidatePersistedModels,
} from './memory-setting-hooks';
import { MemorySettingProvider } from './memory-setting-context';
// type MemoryMessageForm = z.infer<typeof MemoryMessageSchema>;
export default function MemoryMessage() {
const { t } = useTranslation();
const MemoryMessageSchema = z.object({
id: z.string(),
...basicInfoSchema,
...memoryModelFormSchema(t),
...advancedSettingsFormSchema,
});
const { data } = useFetchMemoryBaseConfiguration();
const { formSchema, modelsFetched, modelsEditable } = useMemoryFormSchema(
data?.tenant_id,
);
const form = useForm<IMemory>({
resolver: zodResolver(MemoryMessageSchema),
resolver: zodResolver(formSchema),
defaultValues: {
id: '',
...defaultBasicInfo,
@@ -43,7 +38,6 @@ export default function MemoryMessage() {
...defaultAdvancedSettingsForm,
} as unknown as IMemory,
});
const { data } = useFetchMemoryBaseConfiguration();
const { onMemoryRenameOk, loading } = useUpdateMemoryConfig();
useEffect(() => {
@@ -64,8 +58,16 @@ export default function MemoryMessage() {
permissions: data?.permissions || 'me',
});
}, [data, form]);
useRevalidatePersistedModels({
control: form.control,
trigger: form.trigger,
clearErrors: form.clearErrors,
modelsFetched,
modelsEditable,
});
const onSubmit = (data: IMemory) => {
console.log('data', data);
onMemoryRenameOk(data);
};
return (
@@ -102,7 +104,6 @@ export default function MemoryMessage() {
<DynamicForm.SavingButton
submitLoading={loading}
submitFunc={(value) => {
console.log('form-value', value);
onSubmit(value as IMemory);
}}
></DynamicForm.SavingButton>

View File

@@ -0,0 +1,102 @@
import { ModelTypeMap } from '@/components/model-tree-select';
import { useModelValidIds } from '@/hooks/use-llm-request';
import { IMemory } from '@/pages/memories/interface';
import { useEffect, useMemo } from 'react';
import {
Control,
UseFormClearErrors,
UseFormTrigger,
useWatch,
} from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useFetchMemoryMessageList } from '../memory-message/hook';
import { advancedSettingsFormSchema } from './advanced-settings-form';
import { basicInfoSchema } from './basic-form';
import { memoryModelFormSchema } from './memory-model-form';
/**
* Extends the static schema with an existence check against the added-model
* list: a persisted value may reference a model that has since been deleted.
* The model selects become read-only once messages exist, so a stale
* persisted model can only be fixed while the memory is still empty —
* blocking validation applies only then; afterwards the select's warning
* marker is the only signal.
*/
export const useMemoryFormSchema = (tenantId?: string) => {
const { t } = useTranslation();
const { data: messageData } = useFetchMemoryMessageList();
const modelsEditable = !messageData?.messages?.total_count;
const { validIds: validLlmIds, isFetched: modelsFetched } = useModelValidIds(
ModelTypeMap.llm_id,
tenantId,
);
const { validIds: validEmbdIds } = useModelValidIds(
ModelTypeMap.embd_id,
tenantId,
);
const formSchema = useMemo(
() =>
z
.object({
id: z.string(),
...basicInfoSchema,
...memoryModelFormSchema(t),
...advancedSettingsFormSchema,
})
.superRefine((values, ctx) => {
if (!modelsFetched || !modelsEditable) return;
if (values.llm_id && !validLlmIds.has(values.llm_id)) {
ctx.addIssue({
path: ['llm_id'],
message: t('common.modelUnavailable'),
code: z.ZodIssueCode.custom,
});
}
if (values.embd_id && !validEmbdIds.has(values.embd_id)) {
ctx.addIssue({
path: ['embd_id'],
message: t('common.modelUnavailable'),
code: z.ZodIssueCode.custom,
});
}
}),
[t, modelsFetched, modelsEditable, validLlmIds, validEmbdIds],
);
return { formSchema, modelsFetched, modelsEditable };
};
/**
* A persisted model value never fires onChange validation, so revalidate
* once the model list has loaded — it may reference a since-deleted model,
* and the error should be visible before submit. When the selects turn
* read-only (messages exist), drop any stale error instead.
*/
export const useRevalidatePersistedModels = ({
control,
trigger,
clearErrors,
modelsFetched,
modelsEditable,
}: {
control: Control<IMemory>;
trigger: UseFormTrigger<IMemory>;
clearErrors: UseFormClearErrors<IMemory>;
modelsFetched: boolean;
modelsEditable: boolean;
}) => {
const llmId = useWatch({ control, name: 'llm_id' });
const embdId = useWatch({ control, name: 'embd_id' });
useEffect(() => {
if (!modelsFetched) return;
if (!modelsEditable) {
clearErrors(['llm_id', 'embd_id']);
return;
}
if (llmId) trigger('llm_id');
if (embdId) trigger('embd_id');
}, [trigger, clearErrors, modelsFetched, modelsEditable, llmId, embdId]);
};

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// src/pages/next-search/search-setting-hooks.ts
import {
LLMIdFormField,
LlmSettingEnabledSchema,
LlmSettingFieldSchema,
} from '@/components/llm-setting-items/next';
import { MetadataFilterSchema } from '@/components/metadata-filter';
import { ModelTypeMap } from '@/components/model-tree-select';
import { useModelValidIds } from '@/hooks/use-llm-request';
import { useEffect, useMemo } from 'react';
import { Control, UseFormTrigger, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
export const SearchSettingFormSchema = z
.object({
search_id: z.string().optional(),
name: z.string().min(1, 'Name is required'),
avatar: z.string().optional(),
description: z.string().optional(),
search_config: z.object({
kb_ids: z.array(z.string()).min(1, 'At least one dataset is required'),
vector_similarity_weight: z.number().min(0).max(1),
web_search: z.boolean(),
similarity_threshold: z.number(),
use_kg: z.boolean(),
rerank_id: z.string(),
use_rerank: z.boolean(),
top_k: z.number(),
summary: z.boolean(),
llm_setting: z.object({ ...LlmSettingFieldSchema, ...LLMIdFormField }),
related_search: z.boolean(),
query_mindmap: z.boolean(),
doc_ids: z.array(z.string()),
chat_id: z.string(),
highlight: z.boolean(),
keyword: z.boolean(),
chat_settingcross_languages: z.array(z.string()),
reference_metadata: z
.object({
include: z.boolean().optional(),
fields: z.array(z.string()).optional(),
})
.optional(),
...MetadataFilterSchema,
}),
...LlmSettingEnabledSchema,
})
.superRefine((data, ctx) => {
if (data.search_config.use_rerank && !data.search_config.rerank_id) {
ctx.addIssue({
path: ['search_config', 'rerank_id'],
message: 'Rerank model is required when rerank is enabled',
code: z.ZodIssueCode.custom,
});
}
if (data.search_config.summary && !data.search_config.llm_setting?.llm_id) {
ctx.addIssue({
path: ['search_config', 'llm_setting', 'llm_id'],
message: 'Model is required when AI Summary is enabled',
code: z.ZodIssueCode.custom,
});
}
});
export type SearchSettingFormData = z.infer<typeof SearchSettingFormSchema>;
/**
* Extends the static schema with an existence check against the added-model
* list: a persisted value may reference a model that has since been deleted.
* Gated on the same switches as the required checks — a value hidden behind
* a disabled switch is stripped on submit and must not block it.
*/
export const useSearchSettingFormSchema = () => {
const { t } = useTranslation();
const { validIds: validRerankIds, isFetched: modelsFetched } =
useModelValidIds(ModelTypeMap.rerank_id);
const { validIds: validLlmIds } = useModelValidIds(ModelTypeMap.llm_id);
const formSchema = useMemo(
() =>
SearchSettingFormSchema.superRefine((formData, ctx) => {
if (!modelsFetched) return;
const { use_rerank, rerank_id, summary, llm_setting } =
formData.search_config;
if (use_rerank && rerank_id && !validRerankIds.has(rerank_id)) {
ctx.addIssue({
path: ['search_config', 'rerank_id'],
message: t('common.modelUnavailable'),
code: z.ZodIssueCode.custom,
});
}
if (
summary &&
llm_setting?.llm_id &&
!validLlmIds.has(llm_setting.llm_id)
) {
ctx.addIssue({
path: ['search_config', 'llm_setting', 'llm_id'],
message: t('common.modelUnavailable'),
code: z.ZodIssueCode.custom,
});
}
}),
[modelsFetched, validRerankIds, validLlmIds, t],
);
return { formSchema, modelsFetched };
};
/**
* A persisted model value never fires onChange validation, so once the
* model list has loaded, revalidate explicitly — it may reference a model
* that has since been deleted, and the error should be visible before
* submit. Gated on the switches for the same reason as the schema.
*
* The watched switch states are returned so the component can reuse them
* for conditional rendering without subscribing to the same fields twice.
*/
export const useRevalidatePersistedModels = ({
control,
trigger,
modelsFetched,
}: {
control: Control<SearchSettingFormData>;
trigger: UseFormTrigger<SearchSettingFormData>;
modelsFetched: boolean;
}) => {
const rerankModelEnabled = useWatch({
control,
name: 'search_config.use_rerank',
});
const aiSummaryEnabled = useWatch({
control,
name: 'search_config.summary',
});
const rerankId = useWatch({
control,
name: 'search_config.rerank_id',
});
const summaryLlmId = useWatch({
control,
name: 'search_config.llm_setting.llm_id',
});
useEffect(() => {
if (!modelsFetched) return;
if (rerankModelEnabled && rerankId) trigger('search_config.rerank_id');
if (aiSummaryEnabled && summaryLlmId) {
trigger('search_config.llm_setting.llm_id');
}
}, [
trigger,
modelsFetched,
rerankModelEnabled,
aiSummaryEnabled,
rerankId,
summaryLlmId,
]);
return { rerankModelEnabled, aiSummaryEnabled };
};

View File

@@ -18,16 +18,8 @@
import AvatarNameDescription from '@/components/avatar-name-description';
import { KnowledgeBaseFormField } from '@/components/knowledge-base-item';
import {
LLMIdFormField,
LlmSettingEnabledSchema,
LlmSettingFieldItems,
LlmSettingFieldSchema,
} from '@/components/llm-setting-items/next';
import {
MetadataFilter,
MetadataFilterSchema,
} from '@/components/metadata-filter';
import { LlmSettingFieldItems } from '@/components/llm-setting-items/next';
import { MetadataFilter } from '@/components/metadata-filter';
import { ModelTreeSelect } from '@/components/model-tree-select';
import { SimilaritySliderFormField } from '@/components/similarity-slider';
import { Button } from '@/components/ui/button';
@@ -52,13 +44,17 @@ import { X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
ISearchAppDetailProps,
IUpdateSearchProps,
IllmSettingProps,
useUpdateSearch,
} from '../next-searches/hooks';
import {
SearchSettingFormData,
useRevalidatePersistedModels,
useSearchSettingFormSchema,
} from './search-setting-hooks';
interface SearchSettingProps {
open: boolean;
@@ -67,72 +63,20 @@ interface SearchSettingProps {
data: ISearchAppDetailProps;
}
const SearchSettingFormSchema = z
.object({
search_id: z.string().optional(),
name: z.string().min(1, 'Name is required'),
avatar: z.string().optional(),
description: z.string().optional(),
search_config: z.object({
kb_ids: z.array(z.string()).min(1, 'At least one dataset is required'),
vector_similarity_weight: z.number().min(0).max(1),
web_search: z.boolean(),
similarity_threshold: z.number(),
use_kg: z.boolean(),
rerank_id: z.string(),
use_rerank: z.boolean(),
top_k: z.number(),
summary: z.boolean(),
llm_setting: z.object({ ...LlmSettingFieldSchema, ...LLMIdFormField }),
related_search: z.boolean(),
query_mindmap: z.boolean(),
doc_ids: z.array(z.string()),
chat_id: z.string(),
highlight: z.boolean(),
keyword: z.boolean(),
chat_settingcross_languages: z.array(z.string()),
reference_metadata: z
.object({
include: z.boolean().optional(),
fields: z.array(z.string()).optional(),
})
.optional(),
...MetadataFilterSchema,
}),
...LlmSettingEnabledSchema,
})
.superRefine((data, ctx) => {
if (data.search_config.use_rerank && !data.search_config.rerank_id) {
ctx.addIssue({
path: ['search_config', 'rerank_id'],
message: 'Rerank model is required when rerank is enabled',
code: z.ZodIssueCode.custom,
});
}
if (data.search_config.summary && !data.search_config.llm_setting?.llm_id) {
ctx.addIssue({
path: ['search_config', 'llm_setting', 'llm_id'],
message: 'Model is required when AI Summary is enabled',
code: z.ZodIssueCode.custom,
});
}
});
type SearchSettingFormData = z.infer<typeof SearchSettingFormSchema>;
const SearchSetting: React.FC<SearchSettingProps> = ({
function SearchSetting({
open = false,
setOpen,
className,
data,
}) => {
}: SearchSettingProps) {
const [width0, setWidth0] = useState('w-[440px]');
const { search_config } = data || {};
const { llm_setting } = search_config || {};
const formMethods = useForm<SearchSettingFormData>({
resolver: zodResolver(SearchSettingFormSchema),
});
const { t } = useTranslation();
const { formSchema, modelsFetched } = useSearchSettingFormSchema();
const formMethods = useForm<SearchSettingFormData>({
resolver: zodResolver(formSchema),
});
const descriptionDefaultValue = t('search.descriptionValue');
const resetForm = useCallback(() => {
formMethods.reset({
@@ -197,15 +141,14 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
}
}, [open]);
const rerankModelDisabled = useWatch({
control: formMethods.control,
name: 'search_config.use_rerank',
});
const { rerankModelEnabled, aiSummaryEnabled } = useRevalidatePersistedModels(
{
control: formMethods.control,
trigger: formMethods.trigger,
modelsFetched,
},
);
const aiSummaryDisabled = useWatch({
control: formMethods.control,
name: 'search_config.summary',
});
const selectedKbIds = useWatch({
control: formMethods.control,
name: 'search_config.kb_ids',
@@ -259,11 +202,11 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
// Reset top_k to 1024 only when user actively disables rerank (from true to false)
const prevRerankEnabled = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (prevRerankEnabled.current === true && rerankModelDisabled === false) {
if (prevRerankEnabled.current === true && rerankModelEnabled === false) {
formMethods.setValue('search_config.top_k', 1024);
}
prevRerankEnabled.current = rerankModelDisabled;
}, [rerankModelDisabled, formMethods]);
prevRerankEnabled.current = rerankModelEnabled;
}, [rerankModelEnabled, formMethods]);
const { updateSearch } = useUpdateSearch();
const [formSubmitLoading, setFormSubmitLoading] = useState(false);
@@ -452,7 +395,7 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
</FormItem>
)}
/>
{rerankModelDisabled && (
{rerankModelEnabled && (
<>
<FormField
control={formMethods.control}
@@ -525,7 +468,7 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
</FormItem>
)}
/>
{aiSummaryDisabled && (
{aiSummaryEnabled && (
// <LlmSettingFieldItems
// prefix="search_config.llm_setting"
// options={aiSummeryModelOptions}
@@ -618,6 +561,6 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
</div>
</div>
);
};
}
export { SearchSetting };