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) {
+
+ + + {tSetting('selectAllFiltered')} + + +
+
{filteredModels.length === 0 ? (
@@ -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)} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts b/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts index d7327e7941..3177237c7d 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts @@ -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 { diff --git a/web/src/utils/next-request.ts b/web/src/utils/next-request.ts index 2b474e8366..97971531a1 100644 --- a/web/src/utils/next-request.ts +++ b/web/src/utils/next-request.ts @@ -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); }, );