From bdc94a93a3f189ae618579a581b2cc44a0722711 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Tue, 30 Jun 2026 22:48:38 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=B8=A0=E9=81=93=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B5=8B=E8=AF=95=E5=A2=9E=E5=8A=A0=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=8E=E5=93=8D=E5=BA=94=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增测试超时时间设置(默认 10 秒),超时自动 abort 请求并标记失败 - 新增响应时间筛选下拉(≤0.5s/1s/2s/5s/10s/自定义),测试列表可按延迟过滤 - 将"全选成功模型"改为"选择符合条件的模型",与响应时间筛选联动 - 测试请求支持 AbortController 信号透传,批量测试停止时同步取消当前请求 - 同时适配 default 和 classic 两个前端主题 --- .../table/channels/modals/ModelTestModal.jsx | 118 +++++++++++-- .../src/hooks/channels/useChannelsData.jsx | 75 ++++++-- web/classic/src/i18n/locales/en.json | 13 +- web/classic/src/i18n/locales/fr.json | 13 +- web/classic/src/i18n/locales/ja.json | 13 +- web/classic/src/i18n/locales/ru.json | 13 +- web/classic/src/i18n/locales/vi.json | 13 +- web/classic/src/i18n/locales/zh-CN.json | 13 +- web/classic/src/i18n/locales/zh-TW.json | 13 +- web/default/src/features/channels/api.ts | 5 +- .../dialogs/channel-test-dialog.tsx | 163 ++++++++++++++++-- .../features/channels/lib/channel-actions.ts | 5 +- web/default/src/i18n/locales/en.json | 8 + web/default/src/i18n/locales/fr.json | 8 + web/default/src/i18n/locales/ja.json | 8 + web/default/src/i18n/locales/ru.json | 8 + web/default/src/i18n/locales/vi.json | 8 + web/default/src/i18n/locales/zh.json | 8 + 18 files changed, 455 insertions(+), 50 deletions(-) diff --git a/web/classic/src/components/table/channels/modals/ModelTestModal.jsx b/web/classic/src/components/table/channels/modals/ModelTestModal.jsx index e7f57453229b..3f5a5b8960ac 100644 --- a/web/classic/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/classic/src/components/table/channels/modals/ModelTestModal.jsx @@ -53,6 +53,12 @@ const ModelTestModal = ({ setSelectedEndpointType, isStreamTest, setIsStreamTest, + testTimeoutMs, + setTestTimeoutMs, + responseTimeFilter, + setResponseTimeFilter, + customResponseTimeSec, + setCustomResponseTimeSec, allSelectingRef, isMobile, t, @@ -71,13 +77,27 @@ const ModelTestModal = ({ } }, [streamToggleDisabled, isStreamTest, setIsStreamTest]); - const filteredModels = hasChannel - ? currentTestChannel.models - .split(',') - .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), - ) - : []; + const filteredModels = (() => { + if (!hasChannel) return []; + let models = currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), + ); + if (responseTimeFilter !== 'all') { + const maxMs = responseTimeFilter === 'custom' + ? Number(customResponseTimeSec) * 1000 + : Number(responseTimeFilter); + if (maxMs > 0) { + models = models.filter((model) => { + const r = modelTestResults[`${currentTestChannel.id}-${model}`]; + if (!r || !r.success) return false; + return ((r.time || 0) * 1000) <= maxMs; + }); + } + } + return models; + })(); const endpointTypeOptions = [ { value: '', label: t('自动检测') }, @@ -100,6 +120,16 @@ const ModelTestModal = ({ { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, ]; + const RESPONSE_TIME_FILTER_OPTIONS = [ + { value: 'all', label: t('全部') }, + { value: '500', label: '<=0.5s (' + t('优秀') + ')' }, + { value: '1000', label: '<=1s (' + t('良好') + ')' }, + { value: '2000', label: '<=2s (' + t('一般') + ')' }, + { value: '5000', label: '<=5s (' + t('较差') + ')' }, + { value: '10000', label: '<=10s' }, + { value: 'custom', label: t('自定义') }, + ]; + const handleCopySelected = () => { if (selectedModelKeys.length === 0) { showError(t('请先选择模型!')); @@ -119,19 +149,35 @@ const ModelTestModal = ({ }); }; - const handleSelectSuccess = () => { - if (!currentTestChannel) return; - const successKeys = currentTestChannel.models + const fittingModels = (() => { + if (!hasChannel) return []; + let models = currentTestChannel.models .split(',') .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())) .filter((m) => { const result = modelTestResults[`${currentTestChannel.id}-${m}`]; return result && result.success; }); - if (successKeys.length === 0) { - showInfo(t('暂无成功模型')); + if (responseTimeFilter !== 'all') { + const maxMs = responseTimeFilter === 'custom' + ? Number(customResponseTimeSec) * 1000 + : Number(responseTimeFilter); + if (maxMs > 0) { + models = models.filter((model) => { + const r = modelTestResults[`${currentTestChannel.id}-${model}`]; + return ((r.time || 0) * 1000) <= maxMs; + }); + } + } + return models; + })(); + + const handleSelectSuccess = () => { + if (!currentTestChannel) return; + if (fittingModels.length === 0) { + showInfo(t('暂无符合条件的模型')); } - setSelectedModelKeys(successKeys); + setSelectedModelKeys(fittingModels); }; const columns = [ @@ -362,6 +408,52 @@ const ModelTestModal = ({ + {/* 超时设置与响应时间筛选 */} +
+
+ + {t('测试超时时间(秒)')}: + + { + const sec = Number(v); + if (sec >= 1) setTestTimeoutMs(sec * 1000); + }} + className='!w-24' + /> + + {t('超时未响应则自动判定测试失败。')} + +
+
+ + {t('响应时间筛选')}: + + setCustomResponseTimeSec(v)} + placeholder={t('最大秒数')} + className='!w-24' + /> + )} +
+
+ { const [modelTablePage, setModelTablePage] = useState(1); const [selectedEndpointType, setSelectedEndpointType] = useState(''); const [isStreamTest, setIsStreamTest] = useState(false); + const [testTimeoutMs, setTestTimeoutMs] = useState(10000); + const [responseTimeFilter, setResponseTimeFilter] = useState('all'); + const [customResponseTimeSec, setCustomResponseTimeSec] = useState(''); + const abortControllerRef = useRef(null); const [globalPassThroughEnabled, setGlobalPassThroughEnabled] = useState(false); @@ -875,6 +879,14 @@ export const useChannelsData = () => { // 添加到正在测试的模型集合 setTestingModels((prev) => new Set([...prev, model])); + // 创建 AbortController 用于超时控制 + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + const timeoutId = testTimeoutMs > 0 + ? setTimeout(() => abortController.abort(), testTimeoutMs) + : null; + try { let url = `/api/channel/test/${record.id}?model=${model}`; if (endpointType) { @@ -883,7 +895,9 @@ export const useChannelsData = () => { if (stream) { url += `&stream=true`; } - const res = await API.get(url); + const res = await API.get(url, { + signal: abortController.signal, + }); // 检查是否在请求期间被停止 if (shouldStopBatchTestingRef.current && isBatchTesting) { @@ -931,20 +945,42 @@ export const useChannelsData = () => { showError(message); } } catch (error) { - // 处理网络错误 - const testKey = `${record.id}-${model}`; - setModelTestResults((prev) => ({ - ...prev, - [testKey]: { - success: false, - message: error.message || t('网络错误'), - time: 0, - timestamp: Date.now(), - errorCode: null, - }, - })); - showError(error.message || t('测试失败')); + // 处理超时错误 + if (error.name === 'AbortError' || error.name === 'CanceledError') { + const isTimeout = abortController.signal.aborted; + setModelTestResults((prev) => ({ + ...prev, + [testKey]: { + success: false, + message: isTimeout + ? t('测试超时 ({{timeout}}s)').replace('{{timeout}}', (testTimeoutMs / 1000).toFixed(0)) + : t('测试已取消'), + time: 0, + timestamp: Date.now(), + errorCode: isTimeout ? 'test_timeout' : null, + }, + })); + } else { + // 处理网络错误 + const testKey = `${record.id}-${model}`; + setModelTestResults((prev) => ({ + ...prev, + [testKey]: { + success: false, + message: error.message || t('网络错误'), + time: 0, + timestamp: Date.now(), + errorCode: null, + }, + })); + showError(error.message || t('测试失败')); + } } finally { + // 清理超时定时器 + if (timeoutId !== null) clearTimeout(timeoutId); + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } // 从正在测试的模型集合中移除 setTestingModels((prev) => { const newSet = new Set(prev); @@ -1096,6 +1132,7 @@ export const useChannelsData = () => { showInfo(t('关闭弹窗,已停止批量测试')); } + abortControllerRef.current?.abort(); setShowModelTestModal(false); setModelSearchKeyword(''); setIsBatchTesting(false); @@ -1104,6 +1141,9 @@ export const useChannelsData = () => { setModelTablePage(1); setSelectedEndpointType(''); setIsStreamTest(false); + setTestTimeoutMs(10000); + setResponseTimeFilter('all'); + setCustomResponseTimeSec(''); // 可选择性保留测试结果,这里不清空以便用户查看 }; @@ -1196,6 +1236,13 @@ export const useChannelsData = () => { setSelectedEndpointType, isStreamTest, setIsStreamTest, + testTimeoutMs, + setTestTimeoutMs, + responseTimeFilter, + setResponseTimeFilter, + customResponseTimeSec, + setCustomResponseTimeSec, + abortControllerRef, allSelectingRef, // Multi-key management states diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 688e234787fc..d9c387da0b50 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -3826,6 +3826,17 @@ "并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment", "运营和收费行为产生的法律责任": "operation and charging behavior", ",": ", ", - "、": ", " + "、": ", ", + "测试超时时间(秒)": "Test timeout (seconds)", + "超时未响应则自动判定测试失败。": "Fail the test automatically if no response within this time.", + "响应时间筛选": "Response time filter", + "最大秒数": "Max seconds", + "测试超时 ({{timeout}}s)": "Test timed out ({{timeout}}s)", + "测试已取消": "Test cancelled", + "暂无符合条件的模型": "No matching models", + "优秀": "Excellent", + "良好": "Good", + "一般": "Fair", + "较差": "Poor" } } diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index d07322de1466..e9e7c58cf5e3 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -3680,6 +3680,17 @@ "并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment", "运营和收费行为产生的法律责任": "operation and charging behavior", ",": ", ", - "、": ", " + "、": ", ", + "测试超时时间(秒)": "Délai d'expiration (secondes)", + "超时未响应则自动判定测试失败。": "Échec automatique si aucune réponse dans ce délai.", + "响应时间筛选": "Filtre de temps de réponse", + "最大秒数": "Secondes max", + "测试超时 ({{timeout}}s)": "Test expiré ({{timeout}}s)", + "测试已取消": "Test annulé", + "暂无符合条件的模型": "Aucun modèle correspondant", + "优秀": "Excellent", + "良好": "Bon", + "一般": "Moyen", + "较差": "Médiocre" } } diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index 3c5ee586733e..3a3f3a28b063 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -3649,6 +3649,17 @@ "并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment", "运营和收费行为产生的法律责任": "operation and charging behavior", ",": ", ", - "、": ", " + "、": ", ", + "测试超时时间(秒)": "テストタイムアウト(秒)", + "超时未响应则自动判定测试失败。": "応答がない場合、自動的に失敗と判定されます。", + "响应时间筛选": "応答時間フィルター", + "最大秒数": "最大秒数", + "测试超时 ({{timeout}}s)": "テストタイムアウト ({{timeout}}s)", + "测试已取消": "テストがキャンセルされました", + "暂无符合条件的模型": "該当するモデルがありません", + "优秀": "優秀", + "良好": "良好", + "一般": "普通", + "较差": "やや悪い" } } diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index e050cf41b431..be0f7ce57fa1 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -3700,6 +3700,17 @@ "并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment", "运营和收费行为产生的法律责任": "operation and charging behavior", ",": ", ", - "、": ", " + "、": ", ", + "测试超时时间(秒)": "Тайм-аут теста (секунд)", + "超时未响应则自动判定测试失败。": "Автоматический сбой при отсутствии ответа.", + "响应时间筛选": "Фильтр времени отклика", + "最大秒数": "Макс. секунд", + "测试超时 ({{timeout}}s)": "Тайм-аут теста ({{timeout}}s)", + "测试已取消": "Тест отменён", + "暂无符合条件的模型": "Нет подходящих моделей", + "优秀": "Отлично", + "良好": "Хорошо", + "一般": "Средне", + "较差": "Плохо" } } diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index e50117d597d3..dffd70f18fc3 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -4214,6 +4214,17 @@ "并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment", "运营和收费行为产生的法律责任": "operation and charging behavior", ",": ", ", - "、": ", " + "、": ", ", + "测试超时时间(秒)": "Thời gian chờ (giây)", + "超时未响应则自动判定测试失败。": "Tự động thất bại nếu không có phản hồi.", + "响应时间筛选": "Bộ lọc thời gian phản hồi", + "最大秒数": "Số giây tối đa", + "测试超时 ({{timeout}}s)": "Hết thời gian chờ ({{timeout}}s)", + "测试已取消": "Đã hủy kiểm tra", + "暂无符合条件的模型": "Không có mô hình phù hợp", + "优秀": "Xuất sắc", + "良好": "Tốt", + "一般": "Trung bình", + "较差": "Kém" } } diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index 56d1e2bf009f..59d94e202682 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -3809,6 +3809,17 @@ "并确认自行承担部署": "并确认自行承担部署", "运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任", ",": ",", - "、": "、" + "、": "、", + "测试超时时间(秒)": "测试超时时间(秒)", + "超时未响应则自动判定测试失败。": "超时未响应则自动判定测试失败。", + "响应时间筛选": "响应时间筛选", + "最大秒数": "最大秒数", + "测试超时 ({{timeout}}s)": "测试超时 ({{timeout}}s)", + "测试已取消": "测试已取消", + "暂无符合条件的模型": "暂无符合条件的模型", + "优秀": "优秀", + "良好": "良好", + "一般": "一般", + "较差": "较差" } } diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json index 02d8c01e3254..39f3d2c9fad7 100644 --- a/web/classic/src/i18n/locales/zh-TW.json +++ b/web/classic/src/i18n/locales/zh-TW.json @@ -3673,6 +3673,17 @@ "并确认自行承担部署": "並確認自行承擔部署", "运营和收费行为产生的法律责任": "營運和收費行為產生的法律責任", ",": ",", - "、": "、" + "、": "、", + "测试超时时间(秒)": "測試逾時時間(秒)", + "超时未响应则自动判定测试失败。": "逾時未回應則自動判定測試失敗。", + "响应时间筛选": "回應時間篩選", + "最大秒数": "最大秒數", + "测试超时 ({{timeout}}s)": "測試逾時 ({{timeout}}s)", + "测试已取消": "測試已取消", + "暂无符合条件的模型": "暫無符合條件的模型", + "优秀": "優秀", + "良好": "良好", + "一般": "一般", + "较差": "較差" } } diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index 9e80801db683..f2bb390ebe6b 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -211,11 +211,12 @@ export async function batchSetChannelTag( */ export async function testChannel( id: number, - params?: { model?: string; endpoint_type?: string; stream?: boolean } + params?: { model?: string; endpoint_type?: string; stream?: boolean }, + signal?: AbortSignal ): Promise { const res = await api.get( `/api/channel/test/${id}`, - channelActionConfig({ params }) + channelActionConfig({ params, signal }) ) return res.data } diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index 886ffcd88288..248f5b99ea52 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -84,6 +84,7 @@ import { formatResponseTime, handleTestChannel, } from '../../lib' +import { RESPONSE_TIME_THRESHOLDS } from '../../constants' import type { Channel, GetChannelsResponse, @@ -205,6 +206,23 @@ const MODEL_PRICE_ERROR_CODE = 'model_price_error' const FAILURE_SUMMARY_MAX_LENGTH = 96 const BATCH_TEST_CONCURRENCY = 5 const BATCH_TEST_DELAY_MS = 100 +const DEFAULT_TEST_TIMEOUT_MS = 10000 +const TEST_TIMEOUT_ERROR_CODE = 'test_timeout' + +type ResponseTimeFilterOption = { + value: string + label: string +} + +const RESPONSE_TIME_FILTER_OPTIONS: ResponseTimeFilterOption[] = [ + { value: 'all', label: 'All' }, + { value: String(RESPONSE_TIME_THRESHOLDS.EXCELLENT), label: '<=0.5s (Excellent)' }, + { value: String(RESPONSE_TIME_THRESHOLDS.GOOD), label: '<=1s (Good)' }, + { value: String(RESPONSE_TIME_THRESHOLDS.FAIR), label: '<=2s (Fair)' }, + { value: String(RESPONSE_TIME_THRESHOLDS.POOR), label: '<=5s (Poor)' }, + { value: '10000', label: '<=10s' }, + { value: 'custom', label: 'Custom' }, +] type FailureStatusDisplay = { summary: string @@ -341,6 +359,10 @@ function ChannelTestDialogContent({ pageIndex: 0, pageSize: 30, }) + const [testTimeoutMs, setTestTimeoutMs] = useState(DEFAULT_TEST_TIMEOUT_MS) + const [responseTimeFilter, setResponseTimeFilter] = useState('all') + const [customResponseTimeSec, setCustomResponseTimeSec] = useState('') + const abortControllerRef = useRef(null) const endpointSelectItems = useMemo( () => endpointTypeOptions.map((option) => ({ @@ -352,6 +374,7 @@ function ChannelTestDialogContent({ const resetState = useCallback(() => { batchStopRequestedRef.current = true + abortControllerRef.current?.abort() setEndpointType('auto') setIsStreamTest(false) setSearchTerm('') @@ -366,6 +389,9 @@ function ChannelTestDialogContent({ setIsDeletingFailed(false) setFailureDetails(null) setPagination({ pageIndex: 0, pageSize: 30 }) + setTestTimeoutMs(DEFAULT_TEST_TIMEOUT_MS) + setResponseTimeFilter('all') + setCustomResponseTimeSec('') }, []) const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType) @@ -417,10 +443,27 @@ function ChannelTestDialogContent({ ) const filteredModels = useMemo(() => { - if (!searchTerm) return models - const keyword = searchTerm.toLowerCase() - return models.filter((model) => model.toLowerCase().includes(keyword)) - }, [models, searchTerm]) + let result = models + if (searchTerm) { + const keyword = searchTerm.toLowerCase() + result = result.filter((model) => model.toLowerCase().includes(keyword)) + } + if (responseTimeFilter !== 'all') { + const maxMs = + responseTimeFilter === 'custom' + ? Number(customResponseTimeSec) * 1000 + : Number(responseTimeFilter) + if (maxMs > 0) { + result = result.filter((model) => { + const r = testResults[model] + if (!r || r.status === 'idle' || r.status === 'testing') return true + if (r.status === 'error') return false + return (r.responseTime ?? Infinity) <= maxMs + }) + } + } + return result + }, [models, searchTerm, testResults, responseTimeFilter, customResponseTimeSec]) const tableData = useMemo( () => filteredModels.map((model) => ({ model })), @@ -503,10 +546,17 @@ function ChannelTestDialogContent({ ): Promise => { if (!currentRow) return + const abortController = new AbortController() + abortControllerRef.current = abortController + markModelTesting(model, true) updateTestResult(model, { status: 'testing' }) let finalResult: TestResult | undefined + const timeoutId = testTimeoutMs > 0 + ? window.setTimeout(() => abortController.abort(), testTimeoutMs) + : undefined + try { await handleTestChannel( currentRow.id, @@ -527,16 +577,31 @@ function ChannelTestDialogContent({ errorCode, } updateTestResult(model, finalResult) - } + }, + abortController.signal ) } catch (error: unknown) { + const isTimeout = + error instanceof DOMException && error.name === 'AbortError' && + abortController.signal.aborted finalResult = { status: 'error', completedAt: Date.now(), - error: error instanceof Error ? error.message : t('Test failed'), + error: isTimeout + ? t('Test timed out ({{timeout}}s)', { + timeout: (testTimeoutMs / 1000).toFixed(0), + }) + : error instanceof Error + ? error.message + : t('Test failed'), + errorCode: isTimeout ? TEST_TIMEOUT_ERROR_CODE : undefined, } updateTestResult(model, finalResult) } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null + } markModelTesting(model, false) if (refreshList) { refreshChannelLists( @@ -556,6 +621,7 @@ function ChannelTestDialogContent({ markModelTesting, refreshChannelLists, t, + testTimeoutMs, updateTestResult, ] ) @@ -564,6 +630,7 @@ function ChannelTestDialogContent({ if (!isBatchTesting || isBatchStopRequested) return batchStopRequestedRef.current = true + abortControllerRef.current?.abort() setIsBatchStopRequested(true) }, [isBatchStopRequested, isBatchTesting]) @@ -700,15 +767,30 @@ function ChannelTestDialogContent({ [refreshChannelLists, t, testSingleModel, updateTestResult] ) - const handleSelectSuccessfulModels = useCallback(() => { + const fittingModels = useMemo(() => { + const success = filteredModels.filter( + (model) => testResults[model]?.status === 'success' + ) + if (responseTimeFilter === 'all') return success + const maxMs = + responseTimeFilter === 'custom' + ? Number(customResponseTimeSec) * 1000 + : Number(responseTimeFilter) + if (maxMs <= 0) return success + return success.filter( + (model) => (testResults[model]?.responseTime ?? Infinity) <= maxMs + ) + }, [filteredModels, testResults, responseTimeFilter, customResponseTimeSec]) + + const handleSelectFittingModels = useCallback(() => { setRowSelection(() => { const next: RowSelectionState = {} - for (const model of successModels) { + for (const model of fittingModels) { next[model] = true } return next }) - }, [successModels]) + }, [fittingModels]) const handleDeleteFailedModels = useCallback(async () => { const failed = models.filter( @@ -1030,11 +1112,11 @@ function ChannelTestDialogContent({ )} @@ -1053,6 +1135,63 @@ function ChannelTestDialogContent({ )} +
+
+ + { + const sec = Number(e.target.value) + if (sec >= 1) setTestTimeoutMs(sec * 1000) + }} + className='w-full' + /> +

+ {t('Fail the test automatically if no response within this time.')} +

+
+
+ +
+ + {responseTimeFilter === 'custom' && ( + setCustomResponseTimeSec(e.target.value)} + placeholder={t('Max seconds')} + className='w-28' + /> + )} +
+

+ {t('Filter models by response time after testing.')} +

+
+
+
void + ) => void, + signal?: AbortSignal ): Promise { const payload = options && (options.testModel || options.endpointType || options.stream) @@ -296,7 +297,7 @@ export async function handleTestChannel( : undefined try { - const response = await testChannel(id, payload) + const response = await testChannel(id, payload, signal) const responseTime = getChannelTestResponseTime(response) const duration = formatChannelTestDuration(responseTime) const target = getChannelTestLabel(options) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 569dd49992c6..85d09823b417 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4979,4 +4979,12 @@ "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" } + , + "Test timeout (seconds)": "Test timeout (seconds)", + "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", + "Response time filter": "Response time filter", + "Filter models by response time after testing.": "Filter models by response time after testing.", + "Select fitting models ({{count}})": "Select fitting models ({{count}})", + "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", + "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 8ff9c68a2950..d41f60c8f09f 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -4979,4 +4979,12 @@ "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" } + , + "Test timeout (seconds)": "Test timeout (seconds)", + "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", + "Response time filter": "Response time filter", + "Filter models by response time after testing.": "Filter models by response time after testing.", + "Select fitting models ({{count}})": "Select fitting models ({{count}})", + "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", + "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 1bbcf897fdac..0f918f6eecc8 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -4979,4 +4979,12 @@ "Zhipu V4": "Zhipu V 4", "Zoom": "ズーム" } + , + "Test timeout (seconds)": "Test timeout (seconds)", + "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", + "Response time filter": "Response time filter", + "Filter models by response time after testing.": "Filter models by response time after testing.", + "Select fitting models ({{count}})": "Select fitting models ({{count}})", + "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", + "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index a9044cb6fa09..664f151e2491 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -4979,4 +4979,12 @@ "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" } + , + "Test timeout (seconds)": "Test timeout (seconds)", + "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", + "Response time filter": "Response time filter", + "Filter models by response time after testing.": "Filter models by response time after testing.", + "Select fitting models ({{count}})": "Select fitting models ({{count}})", + "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", + "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 0fb823789d4f..c63cd8590c59 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -4979,4 +4979,12 @@ "Zhipu V4": "Zhipu V4", "Zoom": "Zoom" } + , + "Test timeout (seconds)": "Test timeout (seconds)", + "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", + "Response time filter": "Response time filter", + "Filter models by response time after testing.": "Filter models by response time after testing.", + "Select fitting models ({{count}})": "Select fitting models ({{count}})", + "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", + "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index e78ac5b441b6..7efd1994ad1d 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4979,4 +4979,12 @@ "Zhipu V4": "智谱 V4", "Zoom": "缩放" } + , + "Test timeout (seconds)": "测试超时 (秒)", + "Fail the test automatically if no response within this time.": "超过此时间未响应则自动判定测试失败。", + "Response time filter": "响应时间筛选", + "Filter models by response time after testing.": "测试后按响应时间筛选模型。", + "Select fitting models ({{count}})": "选择符合条件的模型 ({{count}})", + "Test timed out ({{timeout}}s)": "测试超时 ({{timeout}}s)", + "Max seconds": "最大秒数" } From 95ea6daf23b2041b6b0f51d0f1011b4a0ef170f8 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Thu, 2 Jul 2026 01:42:08 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=B5=8B=E8=AF=95i18n=20key=E6=94=BE=E7=BD=AE=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E9=9D=9E=E8=8B=B1=E6=96=87=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将7个新增key从JSON根级别移入translation命名空间内 - 原提交将key放在了translation兄弟节点导致t()无法匹配 - 为fr/ja/ru/vi四个语种补充完整翻译 - 移除多余的逗号空行使格式与项目风格一致 --- web/default/src/i18n/locales/en.json | 17 ++++++++--------- web/default/src/i18n/locales/fr.json | 17 ++++++++--------- web/default/src/i18n/locales/ja.json | 17 ++++++++--------- web/default/src/i18n/locales/ru.json | 17 ++++++++--------- web/default/src/i18n/locales/vi.json | 17 ++++++++--------- web/default/src/i18n/locales/zh.json | 17 ++++++++--------- 6 files changed, 48 insertions(+), 54 deletions(-) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 85d09823b417..3038b6c53afd 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4977,14 +4977,13 @@ "Zero retention": "Zero retention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", + "Filter models by response time after testing.": "Filter models by response time after testing.", + "Max seconds": "Max seconds", + "Response time filter": "Response time filter", + "Select fitting models ({{count}})": "Select fitting models ({{count}})", + "Test timeout (seconds)": "Test timeout (seconds)", + "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)" } - , - "Test timeout (seconds)": "Test timeout (seconds)", - "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", - "Response time filter": "Response time filter", - "Filter models by response time after testing.": "Filter models by response time after testing.", - "Select fitting models ({{count}})": "Select fitting models ({{count}})", - "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", - "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index d41f60c8f09f..fd8870f3bebe 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -4977,14 +4977,13 @@ "Zero retention": "Aucune rétention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Fail the test automatically if no response within this time.": "Échouer le test automatiquement si aucune réponse dans ce délai.", + "Filter models by response time after testing.": "Filtrer les modèles par temps de réponse après le test.", + "Max seconds": "Secondes max", + "Response time filter": "Filtre de temps de réponse", + "Select fitting models ({{count}})": "Sélectionner les modèles correspondants ({{count}})", + "Test timeout (seconds)": "Délai d'attente du test (secondes)", + "Test timed out ({{timeout}}s)": "Test expiré ({{timeout}}s)" } - , - "Test timeout (seconds)": "Test timeout (seconds)", - "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", - "Response time filter": "Response time filter", - "Filter models by response time after testing.": "Filter models by response time after testing.", - "Select fitting models ({{count}})": "Select fitting models ({{count}})", - "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", - "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 0f918f6eecc8..4013ecde3176 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -4977,14 +4977,13 @@ "Zero retention": "データ保持なし", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", - "Zoom": "ズーム" + "Zoom": "ズーム", + "Fail the test automatically if no response within this time.": "この時間内に応答がない場合、自動的にテストを失敗とします。", + "Filter models by response time after testing.": "テスト後に応答時間でモデルをフィルタリングします。", + "Max seconds": "最大秒数", + "Response time filter": "応答時間フィルター", + "Select fitting models ({{count}})": "条件に合うモデルを選択 ({{count}})", + "Test timeout (seconds)": "テストタイムアウト (秒)", + "Test timed out ({{timeout}}s)": "テストがタイムアウトしました ({{timeout}}秒)" } - , - "Test timeout (seconds)": "Test timeout (seconds)", - "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", - "Response time filter": "Response time filter", - "Filter models by response time after testing.": "Filter models by response time after testing.", - "Select fitting models ({{count}})": "Select fitting models ({{count}})", - "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", - "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 664f151e2491..fc981da5ff0e 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -4977,14 +4977,13 @@ "Zero retention": "Без хранения данных", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Fail the test automatically if no response within this time.": "Автоматический провал теста при отсутствии ответа в течение этого времени.", + "Filter models by response time after testing.": "Фильтровать модели по времени отклика после тестирования.", + "Max seconds": "Макс. секунд", + "Response time filter": "Фильтр времени отклика", + "Select fitting models ({{count}})": "Выбрать подходящие модели ({{count}})", + "Test timeout (seconds)": "Тайм-аут теста (секунды)", + "Test timed out ({{timeout}}s)": "Тайм-аут теста ({{timeout}}с)" } - , - "Test timeout (seconds)": "Test timeout (seconds)", - "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", - "Response time filter": "Response time filter", - "Filter models by response time after testing.": "Filter models by response time after testing.", - "Select fitting models ({{count}})": "Select fitting models ({{count}})", - "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", - "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index c63cd8590c59..6f797a92bd47 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -4977,14 +4977,13 @@ "Zero retention": "Không lưu dữ liệu", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Fail the test automatically if no response within this time.": "Tự động đánh dấu thất bại nếu không có phản hồi trong thời gian này.", + "Filter models by response time after testing.": "Lọc mô hình theo thời gian phản hồi sau khi kiểm tra.", + "Max seconds": "Số giây tối đa", + "Response time filter": "Bộ lọc thời gian phản hồi", + "Select fitting models ({{count}})": "Chọn mô hình phù hợp ({{count}})", + "Test timeout (seconds)": "Thời gian chờ kiểm tra (giây)", + "Test timed out ({{timeout}}s)": "Kiểm tra hết thời gian chờ ({{timeout}}s)" } - , - "Test timeout (seconds)": "Test timeout (seconds)", - "Fail the test automatically if no response within this time.": "Fail the test automatically if no response within this time.", - "Response time filter": "Response time filter", - "Filter models by response time after testing.": "Filter models by response time after testing.", - "Select fitting models ({{count}})": "Select fitting models ({{count}})", - "Test timed out ({{timeout}}s)": "Test timed out ({{timeout}}s)", - "Max seconds": "Max seconds" } diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 7efd1994ad1d..0518165715b5 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4977,14 +4977,13 @@ "Zero retention": "零数据保留", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", - "Zoom": "缩放" + "Zoom": "缩放", + "Fail the test automatically if no response within this time.": "超过此时间未响应则自动判定测试失败。", + "Filter models by response time after testing.": "测试后按响应时间筛选模型。", + "Max seconds": "最大秒数", + "Response time filter": "响应时间筛选", + "Select fitting models ({{count}})": "选择符合条件的模型 ({{count}})", + "Test timeout (seconds)": "测试超时 (秒)", + "Test timed out ({{timeout}}s)": "测试超时 ({{timeout}}s)" } - , - "Test timeout (seconds)": "测试超时 (秒)", - "Fail the test automatically if no response within this time.": "超过此时间未响应则自动判定测试失败。", - "Response time filter": "响应时间筛选", - "Filter models by response time after testing.": "测试后按响应时间筛选模型。", - "Select fitting models ({{count}})": "选择符合条件的模型 ({{count}})", - "Test timed out ({{timeout}}s)": "测试超时 ({{timeout}}s)", - "Max seconds": "最大秒数" } From 92330ff2984d8e9cbe0f55686cfbfe9f008b7b7a Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Thu, 2 Jul 2026 02:43:17 +0800 Subject: [PATCH 3/8] fix: add missing i18n keys for response time filter labels in channel test dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel-test-dialog response time filter dropdown options (<=0.5s, <=1s, <=2s, <=5s, <=10s) used hardcoded English labels as i18n keys but these keys did not exist in any locale file, so the labels always displayed in English regardless of the selected language. Add the 5 missing keys to en.json and provide translations for zh, ja, fr, ru, vi matching the classic frontend's Chinese labels (优秀/良好/一般/较差). Co-Authored-By: Claude --- web/default/src/i18n/locales/en.json | 5 +++++ web/default/src/i18n/locales/fr.json | 5 +++++ web/default/src/i18n/locales/ja.json | 5 +++++ web/default/src/i18n/locales/ru.json | 5 +++++ web/default/src/i18n/locales/vi.json | 5 +++++ web/default/src/i18n/locales/zh.json | 5 +++++ 6 files changed, 30 insertions(+) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 3038b6c53afd..b63035ed1e38 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -3,6 +3,11 @@ "360": "360", "1000": "1000", "10000": "10000", + "<=0.5s (Excellent)": "<=0.5s (Excellent)", + "<=1s (Good)": "<=1s (Good)", + "<=2s (Fair)": "<=2s (Fair)", + "<=5s (Poor)": "<=5s (Poor)", + "<=10s": "<=10s", "_copy": "_copy", ",": ", ", ", and": ", and", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index fd8870f3bebe..5c71eb5d4e38 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -3,6 +3,11 @@ "360": "360", "1000": "1000", "10000": "10000", + "<=0.5s (Excellent)": "<=0.5s (Excellent)", + "<=1s (Good)": "<=1s (Bon)", + "<=2s (Fair)": "<=2s (Moyen)", + "<=5s (Poor)": "<=5s (Mauvais)", + "<=10s": "<=10s", "_copy": "_copie", ",": ", ", ", and": ", et", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 4013ecde3176..49b5de824ab8 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -3,6 +3,11 @@ "360": "360", "1000": "1000", "10000": "10000", + "<=0.5s (Excellent)": "<=0.5s (優秀)", + "<=1s (Good)": "<=1s (良好)", + "<=2s (Fair)": "<=2s (普通)", + "<=5s (Poor)": "<=5s (悪い)", + "<=10s": "<=10s", "_copy": "_copy", ",": "、", ", and": "、および", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index fc981da5ff0e..2e4e88f1f1bd 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -3,6 +3,11 @@ "360": "360", "1000": "1000", "10000": "10000", + "<=0.5s (Excellent)": "<=0.5s (Отлично)", + "<=1s (Good)": "<=1s (Хорошо)", + "<=2s (Fair)": "<=2s (Удовл.)", + "<=5s (Poor)": "<=5s (Плохо)", + "<=10s": "<=10s", "_copy": "_копировать", ",": ", ", ", and": ", и", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 6f797a92bd47..485db2e1cd27 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -3,6 +3,11 @@ "360": "360", "1000": "1000", "10000": "10000", + "<=0.5s (Excellent)": "<=0.5s (Tuyệt vời)", + "<=1s (Good)": "<=1s (Tốt)", + "<=2s (Fair)": "<=2s (Trung bình)", + "<=5s (Poor)": "<=5s (Kém)", + "<=10s": "<=10s", "_copy": "_bản sao", ",": ", ", ", and": ", và", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 0518165715b5..32024019c676 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -3,6 +3,11 @@ "360": "360", "1000": "1000", "10000": "10000", + "<=0.5s (Excellent)": "<=0.5s (优秀)", + "<=1s (Good)": "<=1s (良好)", + "<=2s (Fair)": "<=2s (一般)", + "<=5s (Poor)": "<=5s (较差)", + "<=10s": "<=10s", "_copy": "_复制", ",": ",", ", and": ",和", From 2a2fe0b3c246832d290d9b6e758caba2d042fd6d Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sat, 4 Jul 2026 02:07:07 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20testChannel=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0disableDuplicate=E9=81=BF=E5=85=8DAbortSignal?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.get的dedup机制按URL+params缓存Promise,忽略signal参数 - 同一渠道同一模型并发测试时共用同一个请求,一方abort会导致另一方也失败 - 参照getCodexUsage等接口做法,添加disableDuplicate: true绕过dedup --- web/default/src/features/channels/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index f2bb390ebe6b..901a4d7f0bdd 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -216,7 +216,7 @@ export async function testChannel( ): Promise { const res = await api.get( `/api/channel/test/${id}`, - channelActionConfig({ params, signal }) + channelActionConfig({ params, signal, disableDuplicate: true }) ) return res.data } From fd59c91d856172d7eecde2622bf2cb4d2e0d6715 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sat, 4 Jul 2026 02:07:12 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=BC=B9=E7=AA=97=E8=B6=85=E6=97=B6=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E7=BC=96=E8=BE=91=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=8F=AF?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 超时输入框改用timeoutInputValue字符串状态,支持清空和编辑中间值,失焦时自动归位到有效值 - Response time filter标签添加htmlFor与SelectTrigger的id关联,修复无障碍问题 - 提取getResponseTimeThresholdMs公共函数,消除filteredModels和fittingModels中重复的maxMs计算逻辑 --- .../dialogs/channel-test-dialog.tsx | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index 248f5b99ea52..dacae5582ffc 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -214,6 +214,14 @@ type ResponseTimeFilterOption = { label: string } +function getResponseTimeThresholdMs( + filter: string, + customSec: string +): number { + if (filter === 'custom') return Number(customSec) * 1000 + return Number(filter) +} + const RESPONSE_TIME_FILTER_OPTIONS: ResponseTimeFilterOption[] = [ { value: 'all', label: 'All' }, { value: String(RESPONSE_TIME_THRESHOLDS.EXCELLENT), label: '<=0.5s (Excellent)' }, @@ -360,6 +368,7 @@ function ChannelTestDialogContent({ pageSize: 30, }) const [testTimeoutMs, setTestTimeoutMs] = useState(DEFAULT_TEST_TIMEOUT_MS) + const [timeoutInputValue, setTimeoutInputValue] = useState(String(DEFAULT_TEST_TIMEOUT_MS / 1000)) const [responseTimeFilter, setResponseTimeFilter] = useState('all') const [customResponseTimeSec, setCustomResponseTimeSec] = useState('') const abortControllerRef = useRef(null) @@ -390,6 +399,7 @@ function ChannelTestDialogContent({ setFailureDetails(null) setPagination({ pageIndex: 0, pageSize: 30 }) setTestTimeoutMs(DEFAULT_TEST_TIMEOUT_MS) + setTimeoutInputValue(String(DEFAULT_TEST_TIMEOUT_MS / 1000)) setResponseTimeFilter('all') setCustomResponseTimeSec('') }, []) @@ -449,10 +459,7 @@ function ChannelTestDialogContent({ result = result.filter((model) => model.toLowerCase().includes(keyword)) } if (responseTimeFilter !== 'all') { - const maxMs = - responseTimeFilter === 'custom' - ? Number(customResponseTimeSec) * 1000 - : Number(responseTimeFilter) + const maxMs = getResponseTimeThresholdMs(responseTimeFilter, customResponseTimeSec) if (maxMs > 0) { result = result.filter((model) => { const r = testResults[model] @@ -772,10 +779,7 @@ function ChannelTestDialogContent({ (model) => testResults[model]?.status === 'success' ) if (responseTimeFilter === 'all') return success - const maxMs = - responseTimeFilter === 'custom' - ? Number(customResponseTimeSec) * 1000 - : Number(responseTimeFilter) + const maxMs = getResponseTimeThresholdMs(responseTimeFilter, customResponseTimeSec) if (maxMs <= 0) return success return success.filter( (model) => (testResults[model]?.responseTime ?? Infinity) <= maxMs @@ -1143,10 +1147,24 @@ function ChannelTestDialogContent({ type='number' min={1} step={1} - value={testTimeoutMs / 1000} + value={timeoutInputValue} onChange={(e) => { + const raw = e.target.value + setTimeoutInputValue(raw) + const sec = Number(raw) + if (!Number.isNaN(sec) && sec >= 1) { + setTestTimeoutMs(sec * 1000) + } + }} + onBlur={(e) => { const sec = Number(e.target.value) - if (sec >= 1) setTestTimeoutMs(sec * 1000) + if (Number.isNaN(sec) || sec < 1) { + const resetVal = testTimeoutMs / 1000 + setTimeoutInputValue(String(resetVal)) + } else { + setTestTimeoutMs(sec * 1000) + setTimeoutInputValue(String(sec)) + } }} className='w-full' /> @@ -1155,13 +1173,13 @@ function ChannelTestDialogContent({

- +
{ if (v !== null) setResponseTimeFilter(v); }} + onChange={(v) => { + if (v !== null) { + setResponseTimeFilter(v); + setModelTablePage(1); + } + }} optionList={RESPONSE_TIME_FILTER_OPTIONS} className='!w-44' placeholder={t('全部')} diff --git a/web/classic/src/hooks/channels/useChannelsData.jsx b/web/classic/src/hooks/channels/useChannelsData.jsx index 0d20e26e8107..70747b90a7a4 100644 --- a/web/classic/src/hooks/channels/useChannelsData.jsx +++ b/web/classic/src/hooks/channels/useChannelsData.jsx @@ -997,12 +997,30 @@ export const useChannelsData = () => { return; } - const models = currentTestChannel.models + let models = currentTestChannel.models .split(',') .filter((model) => model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), ); + // 响应时间筛选 + if (responseTimeFilter !== 'all') { + const getThreshold = (filter, customSec) => { + if (filter === 'custom') return Number(customSec) * 1000; + return Number(filter); + }; + const maxMs = getThreshold(responseTimeFilter, customResponseTimeSec); + if (maxMs > 0) { + models = models.filter((model) => { + const r = modelTestResults[`${currentTestChannel.id}-${model}`]; + if (!r) return true; + if (testingModels.has(model)) return true; + if (!r.success) return false; + return ((r.time || 0) * 1000) <= maxMs; + }); + } + } + if (models.length === 0) { showError(t('没有找到匹配的模型')); return;