mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 21:37:33 +08:00
Feat: batch verify models in provider instance card (#17552)
This commit is contained in:
@@ -2314,6 +2314,9 @@ Example: Virtual Hosted Style`,
|
||||
addCustomModelTitle: 'Add custom model',
|
||||
batchAddModels: 'Add all visible models',
|
||||
batchRemoveModels: 'Remove all visible models',
|
||||
batchVerifyModels: 'Batch verify',
|
||||
selectAllFiltered: 'Select all',
|
||||
batchVerifySelected: 'Verify {{count}} model(s)',
|
||||
editCustomModelTitle: 'Edit model',
|
||||
modelMaxTokens: 'Max tokens',
|
||||
modelFeatures: 'Model features',
|
||||
|
||||
@@ -1956,6 +1956,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
addCustomModelTitle: '添加自定义模型',
|
||||
batchAddModels: '批量添加当前模型',
|
||||
batchRemoveModels: '批量移除当前模型',
|
||||
batchVerifyModels: '批量验证',
|
||||
selectAllFiltered: '全选',
|
||||
batchVerifySelected: '验证 {{count}} 个模型',
|
||||
editCustomModelTitle: '编辑模型',
|
||||
modelMaxTokens: '最大 Token 数',
|
||||
modelTypes: {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { ModelRowProps } from '../interface';
|
||||
import { ModelTypeBadges } from './model-type-badges';
|
||||
@@ -25,6 +26,8 @@ export function ModelRow({
|
||||
isAdded,
|
||||
verifyStatus,
|
||||
hideActions,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onVerify,
|
||||
onAdd,
|
||||
onRemove,
|
||||
@@ -39,6 +42,13 @@ export function ModelRow({
|
||||
>
|
||||
<div className="flex gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{onToggleSelect && (
|
||||
<Checkbox
|
||||
checked={isSelected ?? false}
|
||||
onCheckedChange={onToggleSelect}
|
||||
aria-label={`Select ${model.name}`}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-sm text-text-primary truncate">
|
||||
{model.name}
|
||||
</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@/hooks/use-llm-request';
|
||||
import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm';
|
||||
import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm';
|
||||
import llmService from '@/services/llm-service';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
@@ -413,6 +414,58 @@ interface UseModelVerifyArgs {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the verify call arguments for a single model. Shared by
|
||||
* per-model `handleVerify` and batch `handleBatchVerify` so both paths
|
||||
* use identical credential resolution logic.
|
||||
*/
|
||||
function buildVerifyArgs(
|
||||
model: IProviderModelItem,
|
||||
providerName: string,
|
||||
resolveCreds: () => ResolvedCreds,
|
||||
instance: IProviderInstance | undefined,
|
||||
getFormValues: (() => Record<string, any>) | undefined,
|
||||
verifyTransform: UseModelVerifyArgs['verifyTransform'],
|
||||
) {
|
||||
const modelInfo: IModelInfo[] = [
|
||||
{
|
||||
model_name: model.name,
|
||||
model_type: model.model_types ?? [],
|
||||
max_tokens: model.max_tokens ?? 0,
|
||||
},
|
||||
];
|
||||
|
||||
let apiKey: string | object;
|
||||
let baseUrl: string | undefined;
|
||||
let region: string | undefined;
|
||||
|
||||
if (verifyTransform) {
|
||||
const formValues = getFormValues?.() ?? {};
|
||||
const transformed = verifyTransform(formValues);
|
||||
apiKey = transformed.apiKey;
|
||||
baseUrl = transformed.baseUrl;
|
||||
region = transformed.region;
|
||||
} else {
|
||||
const creds = resolveCreds();
|
||||
apiKey = creds.apiKey;
|
||||
baseUrl = creds.baseUrl;
|
||||
}
|
||||
|
||||
// `api_key` is typed `string` on the service signature, but
|
||||
// providers with a `verifyTransform` may legitimately produce an
|
||||
// object (e.g. PaddleOCR's nested config). The backend accepts
|
||||
// both shapes, so cast to `any` to match the existing card-level
|
||||
// verify path in `useVerifyProvider`.
|
||||
return {
|
||||
provider_name: providerName,
|
||||
api_key: apiKey as any,
|
||||
base_url: baseUrl,
|
||||
model_info: modelInfo,
|
||||
...(region ? { region } : {}),
|
||||
...(instance?.id ? { instance_id: instance.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useModelVerify({
|
||||
providerName,
|
||||
resolveCreds,
|
||||
@@ -423,6 +476,7 @@ export function useModelVerify({
|
||||
}: UseModelVerifyArgs) {
|
||||
const { verifyProviderConnection } = useVerifyProviderConnection();
|
||||
const [verify, setVerify] = useState<Record<string, VerifyStatus>>({});
|
||||
const [batchVerifying, setBatchVerifying] = useState(false);
|
||||
|
||||
// Seed the per-model verify status from the backend's persisted `verify`
|
||||
// flag on each instance model.
|
||||
@@ -448,51 +502,16 @@ export function useModelVerify({
|
||||
const handleVerify = async (model: IProviderModelItem) => {
|
||||
setVerify((prev) => ({ ...prev, [model.name]: 'loading' }));
|
||||
try {
|
||||
// Per-model verify always sends only the model being verified -
|
||||
// even when `verifyTransform` returns a `modelInfo` array, it is
|
||||
// intentionally overridden here.
|
||||
const modelInfo: IModelInfo[] = [
|
||||
{
|
||||
model_name: model.name,
|
||||
model_type: model.model_types ?? [],
|
||||
max_tokens: model.max_tokens ?? 0,
|
||||
},
|
||||
];
|
||||
|
||||
let apiKey: string | object;
|
||||
let baseUrl: string | undefined;
|
||||
let region: string | undefined;
|
||||
|
||||
if (verifyTransform) {
|
||||
// Provider-specific field mapping (e.g. PaddleOCR's nested
|
||||
// `paddleocr_api_url` / `paddleocr_access_token` /
|
||||
// `paddleocr_algorithm` -> structured `api_key` object). Run the
|
||||
// host card's current form values through the transform so the
|
||||
// user can verify with values they are still editing.
|
||||
const formValues = getFormValues?.() ?? {};
|
||||
const transformed = verifyTransform(formValues);
|
||||
apiKey = transformed.apiKey;
|
||||
baseUrl = transformed.baseUrl;
|
||||
region = transformed.region;
|
||||
} else {
|
||||
const creds = resolveCreds();
|
||||
apiKey = creds.apiKey;
|
||||
baseUrl = creds.baseUrl;
|
||||
}
|
||||
|
||||
// `api_key` is typed `string` on the service signature, but
|
||||
// providers with a `verifyTransform` may legitimately produce an
|
||||
// object (e.g. PaddleOCR's nested config). The backend accepts
|
||||
// both shapes, so cast to `any` to match the existing card-level
|
||||
// verify path in `useVerifyProvider`.
|
||||
const ret = await verifyProviderConnection({
|
||||
provider_name: providerName,
|
||||
api_key: apiKey as any,
|
||||
base_url: baseUrl,
|
||||
model_info: modelInfo,
|
||||
...(region ? { region } : {}),
|
||||
...(instance?.id ? { instance_id: instance.id } : {}),
|
||||
});
|
||||
const ret = await verifyProviderConnection(
|
||||
buildVerifyArgs(
|
||||
model,
|
||||
providerName,
|
||||
resolveCreds,
|
||||
instance,
|
||||
getFormValues,
|
||||
verifyTransform,
|
||||
),
|
||||
);
|
||||
setVerify((prev) => ({
|
||||
...prev,
|
||||
[model.name]: ret.code === 0 ? 'success' : 'error',
|
||||
@@ -502,7 +521,66 @@ export function useModelVerify({
|
||||
}
|
||||
};
|
||||
|
||||
return { verify, handleVerify };
|
||||
// Batch verify: loop through the given models in chunks of 3 parallel
|
||||
// requests, suppressing the global error toast for each call. Per-model
|
||||
// verify status is updated on the same map used by `handleVerify`.
|
||||
const handleBatchVerify = async (models: IProviderModelItem[]) => {
|
||||
if (models.length === 0) return;
|
||||
setBatchVerifying(true);
|
||||
|
||||
const CHUNK_SIZE = 3;
|
||||
for (let i = 0; i < models.length; i += CHUNK_SIZE) {
|
||||
const chunk = models.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// Mark only the current chunk as loading; the remaining models
|
||||
// stay idle until their chunk is reached.
|
||||
const loadingMap: Record<string, VerifyStatus> = {};
|
||||
chunk.forEach((m) => {
|
||||
loadingMap[m.name] = 'loading';
|
||||
});
|
||||
setVerify((prev) => ({ ...prev, ...loadingMap }));
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(async (model) => {
|
||||
const args = buildVerifyArgs(
|
||||
model,
|
||||
providerName,
|
||||
resolveCreds,
|
||||
instance,
|
||||
getFormValues,
|
||||
verifyTransform,
|
||||
);
|
||||
// Pass `provider_name` at the top level so the URL function
|
||||
// can build `/providers/{provider_name}/connection`; the body
|
||||
// goes in `data`. `skipGlobalErrorNotification` suppresses the
|
||||
// global toast for each call during batch verify.
|
||||
const { data } = await llmService.verifyProviderConnection(
|
||||
{
|
||||
provider_name: providerName,
|
||||
data: args,
|
||||
skipGlobalErrorNotification: true,
|
||||
},
|
||||
true,
|
||||
);
|
||||
return { name: model.name, code: data?.code ?? -1 };
|
||||
}),
|
||||
);
|
||||
|
||||
const statusUpdate: Record<string, VerifyStatus> = {};
|
||||
results.forEach((result, idx) => {
|
||||
const modelName = chunk[idx].name;
|
||||
statusUpdate[modelName] =
|
||||
result.status === 'fulfilled' && result.value.code === 0
|
||||
? 'success'
|
||||
: 'error';
|
||||
});
|
||||
setVerify((prev) => ({ ...prev, ...statusUpdate }));
|
||||
}
|
||||
|
||||
setBatchVerifying(false);
|
||||
};
|
||||
|
||||
return { verify, handleVerify, batchVerifying, handleBatchVerify };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,12 +15,20 @@
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SearchInput } from '@/components/ui/input';
|
||||
import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks';
|
||||
import { useFetchInstanceModels } from '@/hooks/use-llm-request';
|
||||
import { IProviderModelItem } from '@/interfaces/request/llm';
|
||||
import { ListMinus, ListPlus, Loader2, Plus, Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ListMinus,
|
||||
ListPlus,
|
||||
Loader2,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AddCustomModelDialog } from '../add-custom-model-dialog';
|
||||
import { mapModelKey } from '../available-models';
|
||||
@@ -149,15 +157,63 @@ export function ModelsSection(props: ModelsSectionProps) {
|
||||
const { search, tag, setSearch, setTag, filteredModels, allTags } =
|
||||
useModelsFilter(models);
|
||||
|
||||
// 6. Per-model verify state.
|
||||
const { verify, handleVerify } = useModelVerify({
|
||||
providerName,
|
||||
resolveCreds,
|
||||
instanceModels,
|
||||
instance,
|
||||
getFormValues,
|
||||
verifyTransform,
|
||||
});
|
||||
// 6. Per-model verify state + batch verify.
|
||||
const { verify, handleVerify, batchVerifying, handleBatchVerify } =
|
||||
useModelVerify({
|
||||
providerName,
|
||||
resolveCreds,
|
||||
instanceModels,
|
||||
instance,
|
||||
getFormValues,
|
||||
verifyTransform,
|
||||
});
|
||||
|
||||
// 6a. Model selection for batch verify.
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const toggleModel = useCallback((name: string) => {
|
||||
setSelectedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllFiltered = useCallback(() => {
|
||||
setSelectedModels((prev) => {
|
||||
const allSelected = filteredModels.every((m) => prev.has(m.name));
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
filteredModels.forEach((m) => next.delete(m.name));
|
||||
} else {
|
||||
filteredModels.forEach((m) => next.add(m.name));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [filteredModels]);
|
||||
|
||||
const filteredSelectedCount = useMemo(
|
||||
() => filteredModels.filter((m) => selectedModels.has(m.name)).length,
|
||||
[filteredModels, selectedModels],
|
||||
);
|
||||
|
||||
const selectAllChecked: boolean | 'indeterminate' =
|
||||
filteredSelectedCount === 0
|
||||
? false
|
||||
: filteredSelectedCount === filteredModels.length
|
||||
? true
|
||||
: 'indeterminate';
|
||||
|
||||
const handleBatchVerifyClick = useCallback(() => {
|
||||
const selected = filteredModels.filter((m) => selectedModels.has(m.name));
|
||||
handleBatchVerify(selected);
|
||||
}, [filteredModels, selectedModels, handleBatchVerify]);
|
||||
|
||||
// 7. Add / remove / batch toggle / custom add.
|
||||
const {
|
||||
@@ -305,6 +361,33 @@ export function ModelsSection(props: ModelsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectAllChecked}
|
||||
onCheckedChange={toggleAllFiltered}
|
||||
disabled={batchVerifying || filteredModels.length === 0}
|
||||
aria-label={tSetting('selectAllFiltered')}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{tSetting('selectAllFiltered')}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBatchVerifyClick}
|
||||
disabled={selectedModels.size === 0 || batchVerifying}
|
||||
data-testid="models-batch-verify"
|
||||
className="ml-auto"
|
||||
>
|
||||
{batchVerifying ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="size-3" />
|
||||
)}
|
||||
{tSetting('batchVerifySelected', { count: selectedModels.size })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-bg-card rounded-lg max-h-80 overflow-auto scrollbar-auto border border-border-button">
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="flex items-center justify-center text-text-secondary text-sm py-6 gap-2">
|
||||
@@ -320,6 +403,8 @@ export function ModelsSection(props: ModelsSectionProps) {
|
||||
isAdded={addedSet.has(model.name)}
|
||||
verifyStatus={verify[model.name] ?? 'idle'}
|
||||
hideActions={hideActions}
|
||||
isSelected={selectedModels.has(model.name)}
|
||||
onToggleSelect={() => toggleModel(model.name)}
|
||||
onVerify={() => handleVerify(model)}
|
||||
onAdd={() => handleAddModel(model)}
|
||||
onRemove={() => handleRemoveModel(model)}
|
||||
|
||||
@@ -138,6 +138,10 @@ export interface ModelRowProps {
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
editLabel: string;
|
||||
/** Whether this row is currently selected for batch operations. */
|
||||
isSelected?: boolean;
|
||||
/** Toggle this row's selection state. */
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
export interface TagFilterButtonProps {
|
||||
|
||||
@@ -125,7 +125,8 @@ request.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.code === 100) {
|
||||
const skipErrorNotification = (response.config as any)?.skipGlobalErrorNotification;
|
||||
if (data?.code === 100 && !skipErrorNotification) {
|
||||
message.error(data?.message);
|
||||
} else if (data?.code === 401) {
|
||||
if (!isRedirecting) {
|
||||
@@ -138,7 +139,7 @@ request.interceptors.response.use(
|
||||
authorizationUtil.removeAll();
|
||||
redirectToLogin();
|
||||
}
|
||||
} else if (data?.code !== 0) {
|
||||
} else if (data?.code !== 0 && !skipErrorNotification) {
|
||||
notification.error({
|
||||
message: `${i18n.t('message.hint')} : ${data?.code}`,
|
||||
description: data?.message,
|
||||
@@ -167,7 +168,9 @@ request.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
errorHandler(error);
|
||||
if (!(error?.config as any)?.skipGlobalErrorNotification) {
|
||||
errorHandler(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user