Feat: tenant llm provider (#14595)

### What problem does this PR solve?

Python implementation of the Go-based model_provider API suite.

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

---------

Co-authored-by: bill <yibie_jingnian@163.com>
This commit is contained in:
Lynn
2026-05-29 17:39:41 +08:00
committed by GitHub
parent b79f79d9b9
commit dc4b82523b
148 changed files with 6059 additions and 3075 deletions

View File

@@ -1,13 +1,13 @@
import { LlmModelType } from '@/constants/knowledge';
import { useTranslate } from '@/hooks/common-hooks';
import { useSelectLlmOptionsByModelType } from '@/hooks/use-llm-request';
import { useFetchAllAddedModels } from '@/hooks/use-llm-request';
import { cn } from '@/lib/utils';
import { camelCase } from 'lodash';
import { ReactNode, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { MinerUOptionsFormField } from './mineru-options-form-field';
import { SelectWithSearch } from './originui/select-with-search';
import { buildModelTree } from './model-tree-select';
import { PaddleOCROptionsFormField } from './paddleocr-options-form-field';
import { TreeSelect, TreeSelectNode } from './tree-select';
import {
FormControl,
FormField,
@@ -44,9 +44,9 @@ export function LayoutRecognizeFormField({
const form = useFormContext();
const { t } = useTranslate('knowledgeDetails');
const allOptions = useSelectLlmOptionsByModelType();
const { data: allAddedModels } = useFetchAllAddedModels();
const options = useMemo(() => {
const treeData = useMemo(() => {
const list = optionsWithoutLLM
? optionsWithoutLLM
: [
@@ -60,28 +60,28 @@ export function LayoutRecognizeFormField({
value: x,
}));
const image2TextList = [
...allOptions[LlmModelType.Image2text],
...allOptions[LlmModelType.Ocr],
].map((x) => {
return {
...x,
options: x.options.map((y) => {
return {
...y,
label: (
<div className="flex justify-between items-center gap-2">
{y.label}
<span className="text-red-500 text-sm">Experimental</span>
</div>
),
};
}),
};
});
const prependNodes: TreeSelectNode[] = list.map((x) => ({
id: x.value,
title: x.label,
}));
return [...list, ...image2TextList];
}, [allOptions, optionsWithoutLLM, t]);
const modelTree = buildModelTree(
allAddedModels,
['image2text', 'ocr'],
(node) => (
<div className="flex justify-between items-center gap-2 w-full">
<span className="flex items-center gap-1.5 truncate">
{node.label}
</span>
<span className="text-state-error text-sm flex-shrink-0">
Experimental
</span>
</div>
),
);
return [...prependNodes, ...modelTree];
}, [allAddedModels, optionsWithoutLLM, t]);
return (
<FormField
@@ -107,11 +107,17 @@ export function LayoutRecognizeFormField({
</FormLabel>
<div className={horizontal ? 'w-3/4' : 'w-full'}>
<FormControl>
<SelectWithSearch
<TreeSelect
{...field}
options={options}
data={treeData}
testId={testId}
></SelectWithSearch>
showSearch
defaultExpandAll
renderSelected={(node) => {
if (!node) return null;
return node.label ?? node.title;
}}
/>
</FormControl>
</div>
</div>

View File

@@ -1,21 +1,33 @@
import { getLLMIconName, getLlmNameAndFIdByLlmId } from '@/utils/llm-util';
import { parseModelValue } from '@/utils/llm-util';
import { memo } from 'react';
import { LlmIcon } from '../svg-icon';
interface IProps {
id?: string;
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
const LLMLabel = ({ value }: IProps) => {
const { llmName, fId } = getLlmNameAndFIdByLlmId(value);
export const LLMLabel = ({ value }: IProps) => {
const parsed = value ? parseModelValue(value) : null;
const modelName = parsed?.model_name;
const instanceName = parsed?.model_instance;
const iconName = parsed ? parsed.model_provider : '';
if (!modelName) return null;
return (
<div className="flex items-center gap-1 text-xs text-text-secondary">
<LlmIcon name={getLLMIconName(fId, llmName)} width={20} height={20} />
<span className="flex-1 truncate"> {llmName}</span>
<div className="flex items-center gap-1.5 min-w-0">
<LlmIcon
name={iconName}
width={22}
height={22}
imgClass="size-[22px] flex-shrink-0"
/>
<span className="font-medium truncate">{modelName}</span>
{instanceName && (
<span className="text-slate-400 truncate flex-shrink-0">
{instanceName}
</span>
)}
</div>
);
};

View File

@@ -1,11 +1,11 @@
import { LlmModelType } from '@/constants/knowledge';
import { useComposeLlmOptionsByModelTypes } from '@/hooks/use-llm-request';
import * as SelectPrimitive from '@radix-ui/react-select';
import { forwardRef, memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LlmSettingFieldItems } from '../llm-setting-items/next';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Select, SelectTrigger, SelectValue } from '../ui/select';
import LLMLabel from './llm-label';
export interface NextInnerLLMSelectProps {
id?: string;
@@ -51,8 +51,6 @@ const NextInnerLLMSelect = forwardRef<
}
}, [filter, ttsModel]);
const modelOptions = useComposeLlmOptionsByModelTypes(modelTypes);
return (
<Select disabled={disabled} value={value}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
@@ -66,17 +64,13 @@ const NextInnerLLMSelect = forwardRef<
data-testid={triggerTestId}
>
<SelectValue placeholder={t('common.pleaseSelect')}>
{
modelOptions
.flatMap((x) => x.options)
.find((x) => x.value === value)?.label
}
<LLMLabel value={value} />
</SelectValue>
</SelectTrigger>
</PopoverTrigger>
<PopoverContent side={'left'}>
<LlmSettingFieldItems
options={modelOptions}
modelTypes={modelTypes}
llmOptionTestIdPrefix={optionTestIdPrefix}
></LlmSettingFieldItems>
</PopoverContent>

View File

@@ -1,45 +1,24 @@
import { LlmModelType } from '@/constants/knowledge';
import { useComposeLlmOptionsByModelTypes } from '@/hooks/use-llm-request';
import { ModelTreeSelect } from '@/components/model-tree-select';
import { useTranslation } from 'react-i18next';
import { SelectWithSearch } from '../originui/select-with-search';
import { RAGFlowFormItem } from '../ragflow-form';
export type LLMFormFieldProps = {
options?: any[];
modelTypes?: string[];
name?: string;
testId?: string;
optionTestIdPrefix?: string;
config?: any;
};
export const useModelOptions = () => {
const modelOptions = useComposeLlmOptionsByModelTypes([
LlmModelType.Chat,
LlmModelType.Image2text,
]);
return {
modelOptions,
};
};
export function LLMFormField({
options,
name,
testId,
optionTestIdPrefix,
config,
}: LLMFormFieldProps) {
export function LLMFormField({ name, config, modelTypes }: LLMFormFieldProps) {
const { t } = useTranslation();
const { modelOptions } = useModelOptions();
return (
<RAGFlowFormItem name={name || 'llm_id'} label={t('chat.model')}>
<SelectWithSearch
options={options || modelOptions}
testId={testId}
optionTestIdPrefix={optionTestIdPrefix}
{...config}
></SelectWithSearch>
<ModelTreeSelect
allowClear={config?.allowClear ?? false}
modelTypes={modelTypes}
/>
</RAGFlowFormItem>
);
}

View File

@@ -27,7 +27,7 @@ import { useHandleFreedomChange } from './use-watch-change';
interface LlmSettingFieldItemsProps {
prefix?: string;
options?: any[];
modelTypes?: string[];
llmId?: string;
llmSelectTestId?: string;
llmOptionTestIdPrefix?: string;
@@ -69,7 +69,7 @@ export const LlmSettingSchema = {
export function LlmSettingFieldItems({
prefix,
options,
modelTypes,
llmSelectTestId,
llmOptionTestIdPrefix,
showFields = [
@@ -137,7 +137,7 @@ export function LlmSettingFieldItems({
return (
<div className="space-y-5">
<LLMFormField
options={options}
modelTypes={modelTypes}
name={llmId ?? getFieldWithPrefix('llm_id')}
testId={llmSelectTestId}
optionTestIdPrefix={llmOptionTestIdPrefix}

View File

@@ -0,0 +1,190 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { LlmIcon } from '@/components/svg-icon';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useFetchAllAddedModels } from '@/hooks/use-llm-request';
import { IAddedModel } from '@/interfaces/database/llm';
import { getRealModelName } from '@/utils/llm-util';
import { useCallback, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { TreeSelect, TreeSelectNode } from './tree-select';
/** Maps form field names to their supported model types */
export const ModelTypeMap: Record<string, string[]> = {
llm_id: ['chat', 'image2text'],
embd_id: ['embedding'],
img2txt_id: ['image2text'],
asr_id: ['speech2text'],
rerank_id: ['rerank'],
tts_id: ['tts'],
};
export function buildModelTree(
allModels: IAddedModel[],
modelTypes: string[],
renderLeafLabel?: (
node: TreeSelectNode,
model: IAddedModel,
) => React.ReactNode,
): TreeSelectNode[] {
const filtered = allModels.filter((m) =>
m.model_type?.some((t) => modelTypes.includes(t)),
);
const seenLeafIds = new Set<string>();
const providerMap = new Map<string, Map<string, IAddedModel[]>>();
for (const model of filtered) {
let instances = providerMap.get(model.provider_name);
if (!instances) {
instances = new Map();
providerMap.set(model.provider_name, instances);
}
let modelList = instances.get(model.instance_name);
if (!modelList) {
modelList = [];
instances.set(model.instance_name, modelList);
}
modelList.push(model);
}
return Array.from(providerMap.entries()).map(([provider, instances]) => ({
id: provider,
title: provider,
children: Array.from(instances.entries()).map(([instance, models]) => ({
id: `${provider}||${instance}`,
title: instance,
children: models.reduce<TreeSelectNode[]>((acc, m) => {
const modelName = getRealModelName(m.name);
const id = `${modelName}@${m.instance_name}@${m.provider_name}`;
if (seenLeafIds.has(id)) return acc;
seenLeafIds.add(id);
const leafNode: TreeSelectNode = {
id,
title: modelName,
label: (
<span className="flex items-center gap-1.5 truncate">
<LlmIcon
name={m.provider_name}
width={22}
height={22}
imgClass="size-[22px] flex-shrink-0"
/>
<span className="truncate">{modelName}</span>
</span>
),
data: {
provider_name: m.provider_name,
instance_name: m.instance_name,
model_name: modelName,
},
};
if (renderLeafLabel) {
leafNode.label = renderLeafLabel(leafNode, m);
}
acc.push(leafNode);
return acc;
}, []),
})),
}));
}
export interface ModelTreeSelectProps {
modelTypes?: string[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
showSearch?: boolean;
allowClear?: boolean;
className?: string;
renderSelected?: (node: TreeSelectNode | undefined) => React.ReactNode;
testId?: string;
}
export function ModelTreeSelect({
modelTypes = ModelTypeMap.llm_id,
value,
onChange,
disabled,
placeholder,
showSearch = true,
allowClear = false,
className,
renderSelected,
testId,
}: ModelTreeSelectProps) {
const { data: allAddedModels } = useFetchAllAddedModels();
const treeData = useMemo(
() => buildModelTree(allAddedModels, modelTypes),
[allAddedModels, modelTypes],
);
const defaultRenderSelected = useCallback(
(node: TreeSelectNode | undefined) => {
if (!node?.id) return null;
return <LLMLabel value={node.id} />;
},
[],
);
return (
<TreeSelect
data={treeData}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
showSearch={showSearch}
allowClear={allowClear}
defaultExpandAll
className={className}
renderSelected={renderSelected ?? defaultRenderSelected}
testId={testId}
/>
);
}
export interface ModelTreeSelectFormFieldProps extends ModelTreeSelectProps {
name?: string;
label?: string;
tooltip?: string;
}
export function ModelTreeSelectFormField({
name = 'llm_id',
label,
tooltip,
...rest
}: ModelTreeSelectFormFieldProps) {
const form = useFormContext();
const { t } = useTranslation();
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
{label && <FormLabel tooltip={tooltip}>{label}</FormLabel>}
<FormControl>
<ModelTreeSelect
{...rest}
value={field.value}
onChange={field.onChange}
placeholder={rest.placeholder ?? t('common.pleaseSelect')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -1,9 +1,7 @@
import { LlmModelType } from '@/constants/knowledge';
import { ModelTreeSelect } from '@/components/model-tree-select';
import { useTranslate } from '@/hooks/common-hooks';
import { useSelectLlmOptionsByModelType } from '@/hooks/use-llm-request';
import { useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { SelectWithSearch } from './originui/select-with-search';
import { SliderInputFormField } from './slider-input-form-field';
import {
FormControl,
@@ -26,8 +24,6 @@ const RerankId = 'rerank_id';
function RerankFormField() {
const form = useFormContext();
const { t } = useTranslate('knowledgeDetails');
const allOptions = useSelectLlmOptionsByModelType();
const options = allOptions[LlmModelType.Rerank];
return (
<FormField
@@ -37,12 +33,12 @@ function RerankFormField() {
<FormItem>
<FormLabel tooltip={t('rerankTip')}>{t('rerankModel')}</FormLabel>
<FormControl>
<SelectWithSearch
<ModelTreeSelect
modelTypes={['rerank']}
allowClear
placeholder={t('rerankPlaceholder')}
{...field}
options={options}
></SelectWithSearch>
/>
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -0,0 +1,269 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
export interface TreeSelectNode {
id: string;
title: string;
label?: React.ReactNode;
children?: TreeSelectNode[];
disabled?: boolean;
data?: Record<string, any>;
}
interface TreeSelectProps {
data: TreeSelectNode[];
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
allowClear?: boolean;
showSearch?: boolean;
className?: string;
defaultExpandAll?: boolean;
renderSelected?: (node: TreeSelectNode | undefined) => React.ReactNode;
testId?: string;
}
export function TreeSelect({
data,
value,
onChange,
placeholder,
disabled,
allowClear,
showSearch,
className,
defaultExpandAll,
renderSelected,
testId,
}: TreeSelectProps) {
const [open, setOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const { t } = useTranslation();
useEffect(() => {
if (!defaultExpandAll) return;
const ids = new Set<string>();
const walk = (nodes: TreeSelectNode[]) => {
for (const node of nodes) {
if (node.children?.length) {
ids.add(node.id);
walk(node.children);
}
}
};
walk(data);
setExpandedIds(ids);
}, [data, defaultExpandAll]);
const selectedNode = useMemo(() => {
const find = (nodes: TreeSelectNode[]): TreeSelectNode | undefined => {
for (const node of nodes) {
if (node.id === value) return node;
if (node.children) {
const found = find(node.children);
if (found) return found;
}
}
};
return find(data);
}, [data, value]);
const isLeaf = useCallback(
(node: TreeSelectNode) => !node.children?.length,
[],
);
const handleToggle = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleSelect = useCallback(
(node: TreeSelectNode) => {
if (node.disabled) return;
if (isLeaf(node)) {
onChange?.(node.id);
setOpen(false);
setSearchTerm('');
} else {
handleToggle(node.id);
}
},
[isLeaf, onChange, handleToggle],
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange?.('');
},
[onChange],
);
const filterTree = useCallback(
(nodes: TreeSelectNode[], term: string): TreeSelectNode[] => {
if (!term) return nodes;
return nodes.reduce<TreeSelectNode[]>((acc, node) => {
const titleMatch = node.title
.toLowerCase()
.includes(term.toLowerCase());
const filteredChildren = node.children
? filterTree(node.children, term)
: undefined;
if (titleMatch || filteredChildren?.length) {
acc.push({ ...node, children: filteredChildren ?? node.children });
}
return acc;
}, []);
},
[],
);
const filteredData = useMemo(
() => filterTree(data, searchTerm),
[data, searchTerm, filterTree],
);
const visibleExpandedIds = useMemo(() => {
if (!searchTerm) return expandedIds;
const ids = new Set<string>();
const walk = (nodes: TreeSelectNode[]) => {
for (const node of nodes) {
if (node.children?.length) {
ids.add(node.id);
walk(node.children);
}
}
};
walk(filteredData);
return ids;
}, [searchTerm, expandedIds, filteredData]);
const renderTree = useCallback(
(nodes: TreeSelectNode[], level = 0): React.ReactNode => {
return nodes.map((node) => {
const leaf = isLeaf(node);
const expanded = visibleExpandedIds.has(node.id);
const selected = value === node.id;
return (
<div key={node.id}>
<div
className={cn(
'flex items-center rounded-sm cursor-pointer text-sm',
'hover:bg-accent transition-colors',
!leaf && 'text-text-primary font-medium',
selected && 'bg-accent text-accent-foreground',
node.disabled && 'opacity-50 pointer-events-none',
)}
style={{
paddingLeft: `${level * 20 + 4}px`,
paddingRight: '8px',
paddingTop: '6px',
paddingBottom: '6px',
}}
onClick={() => handleSelect(node)}
>
<span className="w-4 h-4 mr-0.5 flex-shrink-0 flex items-center justify-center">
{!leaf && (
<>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-text-secondary" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-text-secondary" />
)}
</>
)}
</span>
<span className="truncate">{node.label ?? node.title}</span>
</div>
{!leaf && expanded && node.children && (
<div>{renderTree(node.children, level + 1)}</div>
)}
</div>
);
});
},
[isLeaf, visibleExpandedIds, value, handleSelect],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={disabled}>
<button
type="button"
data-testid={testId}
className={cn(
'flex items-center justify-between border border-border-button rounded-md px-3 py-1.5 w-full',
'bg-bg-input text-sm',
'hover:bg-border-button transition-colors',
disabled && 'opacity-50 cursor-not-allowed',
className,
)}
>
<span className={cn('truncate', !selectedNode && 'text-slate-400')}>
{renderSelected
? renderSelected(selectedNode)
: selectedNode?.title || placeholder || t('common.pleaseSelect')}
</span>
<div className="flex items-center ml-2 flex-shrink-0">
{allowClear && value ? (
<X
className="h-4 w-4 opacity-50 hover:opacity-100"
onClick={handleClear}
/>
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</div>
</button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
{showSearch && (
<div className="flex items-center border-b px-3 py-2">
<Search className="h-4 w-4 text-slate-400 mr-2 flex-shrink-0" />
<input
type="text"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400"
placeholder={t('common.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
)}
<div
className="max-h-60 overflow-auto p-1"
onWheel={(e) => e.stopPropagation()}
>
{filteredData.length > 0 ? (
renderTree(filteredData)
) : (
<div className="py-6 text-center text-sm text-slate-400">
{t('common.noData')}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}