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)

This commit is contained in:
balibabu
2026-08-11 13:45:04 +08:00
committed by GitHub
parent f1641228e2
commit f83c4f97c1
10 changed files with 175 additions and 25 deletions

View File

@@ -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<string, unknown>).React = React;

View File

@@ -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;

View File

@@ -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 (
<span className="flex items-center gap-1.5 text-text-disabled">
<TriangleAlert className="size-4 flex-shrink-0" />
<span className="truncate">
{parseModelValue(missingValue)?.model_name ?? missingValue}
</span>
</span>
);
}, []);
return (
<TreeSelect
ref={ref}
@@ -202,11 +223,14 @@ export const ModelTreeSelect = forwardRef<
defaultExpandAll
className={className}
renderSelected={renderSelected ?? defaultRenderSelected}
renderMissingValue={renderMissingModel}
loading={!modelsFetched || modelsError}
testId={testId}
/>
);
});
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 (
<FormField
@@ -238,6 +279,7 @@ export function ModelTreeSelectFormField({
<FormControl>
<ModelTreeSelect
{...rest}
ref={field.ref}
value={field.value}
onChange={field.onChange}
placeholder={rest.placeholder ?? t('common.pleaseSelect')}

View File

@@ -4,7 +4,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react';
import { ChevronDown, ChevronRight, Search, TriangleAlert, X } from 'lucide-react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -28,6 +28,17 @@ interface TreeSelectProps {
className?: string;
defaultExpandAll?: boolean;
renderSelected?: (node: TreeSelectNode | undefined) => 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<HTMLButtonElement, TreeSelectProps>(
className,
defaultExpandAll,
renderSelected,
renderMissingValue,
loading,
testId,
},
ref,
@@ -81,6 +94,11 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(
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<HTMLButtonElement, TreeSelectProps>(
)}
>
<span className={cn('truncate', !selectedNode && 'text-slate-400')}>
{renderSelected
? renderSelected(selectedNode)
: selectedNode?.title ||
placeholder ||
t('common.pleaseSelect')}
{missingValue ? (
renderMissingValue ? (
renderMissingValue(missingValue)
) : (
<span className="flex items-center gap-1.5">
<TriangleAlert className="size-4 flex-shrink-0" />
<span className="truncate">{missingValue}</span>
</span>
)
) : renderSelected ? (
renderSelected(selectedNode)
) : (
selectedNode?.title ||
placeholder ||
t('common.pleaseSelect')
)}
</span>
<div className="flex items-center ml-2 flex-shrink-0">
{allowClear && value ? (

View File

@@ -120,7 +120,12 @@ export const useFetchAllAddedModels = (
modelType?: string,
ownerTenantId?: string,
) => {
const { data, isFetching: loading } = useQuery<IAddedModel[]>({
const {
data,
isFetching: loading,
isFetched,
isError,
} = useQuery<IAddedModel[]>({
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() {

View File

@@ -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',

View File

@@ -1577,6 +1577,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
templateDescription: '描述',
llmForExtraction: '默认提取模型',
llmForExtractionRequired: '请选择 LLM 模型',
llmForExtractionUnavailable: '之前选择的模型已被删除,请重新选择',
templateKind: '类型',
templateKindRequired: '请选择类型',
entitySpecification: 'Entity specification',

View File

@@ -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<FormSchemaType>({
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',
});

View File

@@ -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<

View File

@@ -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<string> {
const ids = new Set<string>();
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").