From 3a8ea35965c3314fc191f64f2f9a942908bdfe3d Mon Sep 17 00:00:00 2001 From: balibabu Date: Fri, 14 Aug 2026 19:07:57 +0800 Subject: [PATCH] Fix: If the model is removed from the supplier list, an error should occur when accessing the search and memory pages. (#18270) --- web/src/hooks/use-llm-request.tsx | 27 ++- web/src/locales/en.ts | 2 + web/src/locales/zh.ts | 1 + web/src/pages/memory/memory-setting/index.tsx | 37 ++-- .../memory-setting/memory-setting-hooks.ts | 102 ++++++++++ .../pages/next-search/search-setting-hooks.ts | 180 ++++++++++++++++++ web/src/pages/next-search/search-setting.tsx | 109 +++-------- 7 files changed, 356 insertions(+), 102 deletions(-) create mode 100644 web/src/pages/memory/memory-setting/memory-setting-hooks.ts create mode 100644 web/src/pages/next-search/search-setting-hooks.ts diff --git a/web/src/hooks/use-llm-request.tsx b/web/src/hooks/use-llm-request.tsx index bd10f71769..34e40bdacd 100644 --- a/web/src/hooks/use-llm-request.tsx +++ b/web/src/hooks/use-llm-request.tsx @@ -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(); diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 73ff2f6317..e5e2d7e955 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 5940d88614..a4172c5f0f 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -49,6 +49,7 @@ export default { s: '秒', pleaseSelect: '请选择', pleaseInput: '请输入', + modelUnavailable: '之前选择的模型已被删除,请重新选择', submit: '提交', clear: '清空', embedIntoSite: '嵌入网站', diff --git a/web/src/pages/memory/memory-setting/index.tsx b/web/src/pages/memory/memory-setting/index.tsx index 93ddb39cd7..1f2e3a14d9 100644 --- a/web/src/pages/memory/memory-setting/index.tsx +++ b/web/src/pages/memory/memory-setting/index.tsx @@ -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; 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({ - 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() { { - console.log('form-value', value); onSubmit(value as IMemory); }} > diff --git a/web/src/pages/memory/memory-setting/memory-setting-hooks.ts b/web/src/pages/memory/memory-setting/memory-setting-hooks.ts new file mode 100644 index 0000000000..1d0806e8bc --- /dev/null +++ b/web/src/pages/memory/memory-setting/memory-setting-hooks.ts @@ -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; + trigger: UseFormTrigger; + clearErrors: UseFormClearErrors; + 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]); +}; diff --git a/web/src/pages/next-search/search-setting-hooks.ts b/web/src/pages/next-search/search-setting-hooks.ts new file mode 100644 index 0000000000..5c7fee8745 --- /dev/null +++ b/web/src/pages/next-search/search-setting-hooks.ts @@ -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; + +/** + * 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; + trigger: UseFormTrigger; + 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 }; +}; diff --git a/web/src/pages/next-search/search-setting.tsx b/web/src/pages/next-search/search-setting.tsx index 6acabcccf7..db856a7663 100644 --- a/web/src/pages/next-search/search-setting.tsx +++ b/web/src/pages/next-search/search-setting.tsx @@ -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; -const SearchSetting: React.FC = ({ +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({ - resolver: zodResolver(SearchSettingFormSchema), - }); - const { t } = useTranslation(); + const { formSchema, modelsFetched } = useSearchSettingFormSchema(); + const formMethods = useForm({ + resolver: zodResolver(formSchema), + }); const descriptionDefaultValue = t('search.descriptionValue'); const resetForm = useCallback(() => { formMethods.reset({ @@ -197,15 +141,14 @@ const SearchSetting: React.FC = ({ } }, [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 = ({ // Reset top_k to 1024 only when user actively disables rerank (from true to false) const prevRerankEnabled = useRef(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 = ({ )} /> - {rerankModelDisabled && ( + {rerankModelEnabled && ( <> = ({ )} /> - {aiSummaryDisabled && ( + {aiSummaryEnabled && ( // = ({ ); -}; +} export { SearchSetting };