Feat: batch verify models in provider instance card (#17552)

This commit is contained in:
chanx
2026-07-30 13:58:16 +08:00
committed by GitHub
parent 6e0e495929
commit 03a93eabf7
7 changed files with 246 additions and 60 deletions

View File

@@ -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',

View File

@@ -1956,6 +1956,9 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
addCustomModelTitle: '添加自定义模型',
batchAddModels: '批量添加当前模型',
batchRemoveModels: '批量移除当前模型',
batchVerifyModels: '批量验证',
selectAllFiltered: '全选',
batchVerifySelected: '验证 {{count}} 个模型',
editCustomModelTitle: '编辑模型',
modelMaxTokens: '最大 Token 数',
modelTypes: {

View File

@@ -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>

View File

@@ -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 };
}
// ---------------------------------------------------------------------------

View File

@@ -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)}

View File

@@ -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 {

View File

@@ -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);
},
);