From f83c4f97c1a7e4bca1a53fd7c2c510168d432e5d Mon Sep 17 00:00:00 2001 From: balibabu Date: Tue, 11 Aug 2026 13:45:04 +0800 Subject: [PATCH] Fix: After creating a wiki template and deleting the model instance, the default model appears empty, but it can still be edited and saved successfully. (#18075) --- web/jest-setup.ts | 6 ++ .../layout-recognize-form-field.tsx | 10 ++-- web/src/components/model-tree-select.tsx | 56 ++++++++++++++++--- web/src/components/tree-select.tsx | 41 ++++++++++++-- web/src/hooks/use-llm-request.tsx | 12 +++- web/src/locales/en.ts | 2 + web/src/locales/zh.ts | 1 + .../use-compilation-template-group-form.ts | 28 +++++++++- .../compilation-templates/edit-next/schema.ts | 19 +++++-- web/src/utils/llm-util.ts | 25 +++++++++ 10 files changed, 175 insertions(+), 25 deletions(-) diff --git a/web/jest-setup.ts b/web/jest-setup.ts index 7b0828bfa8..af3e171b27 100644 --- a/web/jest-setup.ts +++ b/web/jest-setup.ts @@ -1 +1,7 @@ import '@testing-library/jest-dom'; +import React from 'react'; + +// esbuild-jest compiles JSX with the classic runtime (React.createElement), +// while source files rely on the automatic runtime and never import React. +// Expose React globally so rendering components in tests works. +(globalThis as Record).React = React; diff --git a/web/src/components/layout-recognize-form-field.tsx b/web/src/components/layout-recognize-form-field.tsx index 29cbbd52fb..907eeed3c2 100644 --- a/web/src/components/layout-recognize-form-field.tsx +++ b/web/src/components/layout-recognize-form-field.tsx @@ -46,10 +46,11 @@ export function LayoutRecognizeFormField({ const form = useFormContext(); const { t } = useTranslate('knowledgeDetails'); - const { data: allAddedModels } = useFetchAllAddedModels( - undefined, - ownerTenantId, - ); + const { + data: allAddedModels, + isFetched: modelsFetched, + isError: modelsError, + } = useFetchAllAddedModels(undefined, ownerTenantId); const treeData = useMemo(() => { const list = optionsWithoutLLM @@ -118,6 +119,7 @@ export function LayoutRecognizeFormField({ testId={testId} showSearch defaultExpandAll + loading={!modelsFetched || modelsError} renderSelected={(node) => { if (!node) return null; return node.label ?? node.title; diff --git a/web/src/components/model-tree-select.tsx b/web/src/components/model-tree-select.tsx index aeb93b29f7..3cacb9e20d 100644 --- a/web/src/components/model-tree-select.tsx +++ b/web/src/components/model-tree-select.tsx @@ -9,9 +9,14 @@ import { } from '@/components/ui/form'; import { useFetchAllAddedModels } from '@/hooks/use-llm-request'; import { IAddedModel } from '@/interfaces/database/llm'; -import { buildModelValue, getRealModelName } from '@/utils/llm-util'; -import { forwardRef, useCallback, useMemo } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { + buildModelValue, + getRealModelName, + parseModelValue, +} from '@/utils/llm-util'; +import { TriangleAlert } from 'lucide-react'; +import { forwardRef, useCallback, useEffect, useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { TreeSelect, TreeSelectNode } from './tree-select'; @@ -135,10 +140,11 @@ export const ModelTreeSelect = forwardRef< }, ref, ) { - const { data: allAddedModels } = useFetchAllAddedModels( - undefined, - ownerTenantId, - ); + const { + data: allAddedModels, + isFetched: modelsFetched, + isError: modelsError, + } = useFetchAllAddedModels(undefined, ownerTenantId); const treeData = useMemo( () => buildModelTree(allAddedModels, modelTypes), @@ -189,6 +195,21 @@ export const ModelTreeSelect = forwardRef< [], ); + // The persisted model no longer matches any added model (e.g. it was + // deleted from the provider) — keep it visible with a warning marker + // instead of rendering a blank select. Prefer the readable model name over + // the raw composite id. + const renderMissingModel = useCallback((missingValue: string) => { + return ( + + + + {parseModelValue(missingValue)?.model_name ?? missingValue} + + + ); + }, []); + return ( ); }); + export interface ModelTreeSelectFormFieldProps extends ModelTreeSelectProps { name?: string; label?: string; @@ -223,6 +247,23 @@ export function ModelTreeSelectFormField({ }: ModelTreeSelectFormFieldProps) { const form = useFormContext(); const { t } = useTranslation(); + const { loading } = useFetchAllAddedModels(undefined, rest.ownerTenantId); + const value = useWatch({ control: form.control, name }); + + // `form` from context is a new object on every provider render, so it must + // not be an effect dependency — `trigger` is a stable control method. With + // `form` in the deps, each trigger() emits formState updates that re-render + // the provider, which recreates `form`, which refires this effect: an + // infinite validation loop whenever the field keeps failing validation. + const trigger = form.trigger; + + // A persisted 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. + useEffect(() => { + if (loading || !value) return; + trigger(name); + }, [trigger, loading, name, value]); return ( React.ReactNode; + /** + * Custom display for a value that matches no node in `data` (e.g. the + * referenced entity was deleted). Defaults to a warning icon plus the raw + * value. + */ + renderMissingValue?: (value: string) => React.ReactNode; + /** + * While true, an unmatched value is treated as not-yet-loaded instead of + * missing, so the missing-value display doesn't flash before `data` arrives. + */ + loading?: boolean; testId?: string; } @@ -44,6 +55,8 @@ export const TreeSelect = forwardRef( className, defaultExpandAll, renderSelected, + renderMissingValue, + loading, testId, }, ref, @@ -81,6 +94,11 @@ export const TreeSelect = forwardRef( return find(data); }, [data, value]); + // A value matching no node means the referenced option is gone (e.g. + // deleted) — unless `data` may simply not have loaded yet. + const missingValue = + value && !selectedNode && !loading ? value : undefined; + const isLeaf = useCallback( (node: TreeSelectNode) => !node.children?.length, [], @@ -229,11 +247,22 @@ export const TreeSelect = forwardRef( )} > - {renderSelected - ? renderSelected(selectedNode) - : selectedNode?.title || - placeholder || - t('common.pleaseSelect')} + {missingValue ? ( + renderMissingValue ? ( + renderMissingValue(missingValue) + ) : ( + + + {missingValue} + + ) + ) : renderSelected ? ( + renderSelected(selectedNode) + ) : ( + selectedNode?.title || + placeholder || + t('common.pleaseSelect') + )}
{allowClear && value ? ( diff --git a/web/src/hooks/use-llm-request.tsx b/web/src/hooks/use-llm-request.tsx index 574d17eb59..bd10f71769 100644 --- a/web/src/hooks/use-llm-request.tsx +++ b/web/src/hooks/use-llm-request.tsx @@ -120,7 +120,12 @@ export const useFetchAllAddedModels = ( modelType?: string, ownerTenantId?: string, ) => { - const { data, isFetching: loading } = useQuery({ + const { + data, + isFetching: loading, + isFetched, + isError, + } = useQuery({ queryKey: [...LlmKeys.allModels(modelType), ownerTenantId], initialData: [], gcTime: 0, @@ -138,7 +143,10 @@ export const useFetchAllAddedModels = ( }, }); - return { data, loading }; + // `data` is seeded with `initialData: []`, so it can't tell a real empty + // result apart from "fetch hasn't completed yet" — `isFetched` stays false + // until a genuine response (or error) arrives. + return { data, loading, isFetched, isError }; }; export function useFindLlmByUuid() { diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index e0eefa15e5..0634bcd3cc 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1889,6 +1889,8 @@ Example: Virtual Hosted Style`, templateDescription: 'Description', llmForExtraction: 'Default Model for extraction', llmForExtractionRequired: 'Please select an LLM model', + llmForExtractionUnavailable: + 'The previously selected model has been deleted, please select another one', templateKind: 'Kind', templateKindRequired: 'Please select a kind', entitySpecification: 'Entity specification', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 7194d2e806..dd160c3e3a 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1577,6 +1577,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 templateDescription: '描述', llmForExtraction: '默认提取模型', llmForExtractionRequired: '请选择 LLM 模型', + llmForExtractionUnavailable: '之前选择的模型已被删除,请重新选择', templateKind: '类型', templateKindRequired: '请选择类型', entitySpecification: 'Entity specification', diff --git a/web/src/pages/user-setting/compilation-templates/edit-next/hooks/use-compilation-template-group-form.ts b/web/src/pages/user-setting/compilation-templates/edit-next/hooks/use-compilation-template-group-form.ts index b4a66424d7..01372ecd30 100644 --- a/web/src/pages/user-setting/compilation-templates/edit-next/hooks/use-compilation-template-group-form.ts +++ b/web/src/pages/user-setting/compilation-templates/edit-next/hooks/use-compilation-template-group-form.ts @@ -15,11 +15,14 @@ */ import { zodResolver } from '@hookform/resolvers/zod'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { ModelTypeMap } from '@/components/model-tree-select'; +import { useFetchAllAddedModels } from '@/hooks/use-llm-request'; import { ICompilationTemplateGroup } from '@/interfaces/database/compilation-template'; +import { buildValidModelIds } from '@/utils/llm-util'; import { buildFormSchema, FormSchemaType } from '../schema'; import { DefaultValues } from '../constant'; @@ -38,8 +41,29 @@ export const useCompilationTemplateGroupForm = ({ }: UseCompilationTemplateGroupFormOptions) => { const { t } = useTranslation(); + const { + data: allAddedModels, + isFetched: modelsFetched, + isError: modelsError, + } = useFetchAllAddedModels(); + + // null until the model list has actually loaded (or if it failed) — while + // unknown, any persisted id is accepted as-is so validation never judges + // against the empty placeholder list. + const validModelIds = useMemo( + () => + modelsFetched && !modelsError + ? buildValidModelIds(allAddedModels, ModelTypeMap.llm_id) + : null, + [allAddedModels, modelsFetched, modelsError], + ); + const form = useForm({ - resolver: zodResolver(buildFormSchema(t)), + // useForm refreshes its options (including the resolver) on every render, + // so this closure always validates against the latest validModelIds. + resolver: zodResolver( + buildFormSchema(t, (id) => validModelIds?.has(id) ?? true), + ), defaultValues: DefaultValues, mode: 'onChange', }); diff --git a/web/src/pages/user-setting/compilation-templates/edit-next/schema.ts b/web/src/pages/user-setting/compilation-templates/edit-next/schema.ts index 4ad4390dcc..79bfc6fed3 100644 --- a/web/src/pages/user-setting/compilation-templates/edit-next/schema.ts +++ b/web/src/pages/user-setting/compilation-templates/edit-next/schema.ts @@ -42,12 +42,20 @@ export const buildSynthesisSchema = () => }) .passthrough(); -export const buildTemplateSchema = (t: (key: string) => string) => +export const buildTemplateSchema = ( + t: (key: string) => string, + isModelAvailable?: (id: string) => boolean, +) => z.object({ id: z.string().optional(), name: z.string().min(1, t('setting.templateNameRequired')), description: z.string().optional(), - llm_id: z.string().min(1, t('setting.llmForExtractionRequired')), + llm_id: z + .string() + .min(1, t('setting.llmForExtractionRequired')) + .refine((val) => !val || (isModelAvailable?.(val) ?? true), { + message: t('setting.llmForExtractionUnavailable'), + }), kind: z.string().min(1, t('setting.templateKindRequired')), config: z.record( z.union([ @@ -60,12 +68,15 @@ export const buildTemplateSchema = (t: (key: string) => string) => ), }); -export const buildFormSchema = (t: (key: string) => string) => +export const buildFormSchema = ( + t: (key: string) => string, + isModelAvailable?: (id: string) => boolean, +) => z.object({ name: z.string().optional(), description: z.string().optional(), avatar: z.string().optional(), - templates: z.array(buildTemplateSchema(t)).min(1), + templates: z.array(buildTemplateSchema(t, isModelAvailable)).min(1), }); export type TemplateSchemaType = z.infer< diff --git a/web/src/utils/llm-util.ts b/web/src/utils/llm-util.ts index 9037dae27d..a1a12f4113 100644 --- a/web/src/utils/llm-util.ts +++ b/web/src/utils/llm-util.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { IAddedModel } from '@/interfaces/database/llm'; import { getCachedLlmList } from './llm-cache'; // The names of the large models returned by the interface are similar to "deepseek-r1___OpenAI-API" @@ -59,6 +60,30 @@ export function buildModelValue(model: { return `${model.model_name}@${model.model_instance}@${model.model_provider}`; } +/** + * Collects every id under which an added model can be referenced — both the + * model_id form and the legacy "modelName@instanceName@providerName" form — + * so a persisted form value can be checked against the models that still + * exist. Mirrors the leaf ids produced by `buildModelTree`. + */ +export function buildValidModelIds( + allModels: IAddedModel[], + modelTypes: string[], +): Set { + const ids = new Set(); + for (const m of allModels) { + if (!m.model_type?.some((t) => modelTypes.includes(t))) continue; + const legacyId = buildModelValue({ + model_name: getRealModelName(m.name), + model_instance: m.instance_name, + model_provider: m.provider_name, + }); + ids.add(m.model_id || legacyId); + ids.add(legacyId); + } + return ids; +} + /** * Parse "modelName@instanceName@providerName" (or the 2-part * "modelName@providerName" form where the instance defaults to "default").