From 03a93eabf78352a1ed2d2280259fba9eb2b7540a Mon Sep 17 00:00:00 2001
From: chanx <1243304602@qq.com>
Date: Thu, 30 Jul 2026 13:58:16 +0800
Subject: [PATCH] Feat: batch verify models in provider instance card (#17552)
---
web/src/locales/en.ts | 3 +
web/src/locales/zh.ts | 3 +
.../models-section/components/model-row.tsx | 10 ++
.../instance-card/models-section/hooks.ts | 170 +++++++++++++-----
.../instance-card/models-section/index.tsx | 107 +++++++++--
.../instance-card/models-section/interface.ts | 4 +
web/src/utils/next-request.ts | 9 +-
7 files changed, 246 insertions(+), 60 deletions(-)
diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts
index 5d4b6d418f..278a3cc100 100644
--- a/web/src/locales/en.ts
+++ b/web/src/locales/en.ts
@@ -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',
diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts
index 97923e30f5..942dc1665e 100644
--- a/web/src/locales/zh.ts
+++ b/web/src/locales/zh.ts
@@ -1956,6 +1956,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
addCustomModelTitle: '添加自定义模型',
batchAddModels: '批量添加当前模型',
batchRemoveModels: '批量移除当前模型',
+ batchVerifyModels: '批量验证',
+ selectAllFiltered: '全选',
+ batchVerifySelected: '验证 {{count}} 个模型',
editCustomModelTitle: '编辑模型',
modelMaxTokens: '最大 Token 数',
modelTypes: {
diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx
index 65617b887a..560bd11176 100644
--- a/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx
+++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx
@@ -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({
>
+ {onToggleSelect && (
+
+ )}
{model.name}
diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts
index 891f67a33d..82606e0e20 100644
--- a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts
+++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts
@@ -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) | 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>({});
+ 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 = {};
+ 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 = {};
+ 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 };
}
// ---------------------------------------------------------------------------
diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx
index 202ba696bf..b34d95f556 100644
--- a/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx
+++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx
@@ -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>(
+ 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) {
+