mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-13 20:26:51 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1577,6 +1577,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
templateDescription: '描述',
|
||||
llmForExtraction: '默认提取模型',
|
||||
llmForExtractionRequired: '请选择 LLM 模型',
|
||||
llmForExtractionUnavailable: '之前选择的模型已被删除,请重新选择',
|
||||
templateKind: '类型',
|
||||
templateKindRequired: '请选择类型',
|
||||
entitySpecification: 'Entity specification',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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").
|
||||
|
||||
Reference in New Issue
Block a user