/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { Combobox } from '@/components/ui/combobox' import { useQueryClient } from '@tanstack/react-query' import type { ColumnDef, RowSelectionState, Table as TanStackTable, } from '@tanstack/react-table' import { Check, CheckCircle2, Copy, Gauge, Info, Loader2, Settings, Trash2, } from 'lucide-react' import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ConfirmDialog } from '@/components/confirm-dialog' import { DataTableBulkActions as BulkActionsToolbar, DataTablePagination, DataTableView, useDataTable, } from '@/components/data-table' import { Dialog } from '@/components/dialog' import { sideDrawerContentClassName, sideDrawerFooterClassName, sideDrawerFormClassName, sideDrawerHeaderClassName, } from '@/components/drawer-layout' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useIsMobile } from '@/hooks/use-mobile' import { updateChannel } from '../../api' import { channelsQueryKeys, formatResponseTime, handleTestChannel, } from '../../lib' import type { Channel, GetChannelsResponse, SearchChannelsResponse, } from '../../types' import { useChannels } from '../channels-provider' type ChannelTestDialogProps = { open: boolean onOpenChange: (open: boolean) => void } type ChannelTestDialogContentProps = ChannelTestDialogProps & { currentRow: Channel } type ModelRow = { model: string } type TestStatus = 'idle' | 'testing' | 'success' | 'error' type TestResult = { status: TestStatus responseTime?: number completedAt?: number error?: string errorCode?: string } type BatchProgress = { total: number completed: number success: number failed: number } type ChannelTestCachePatch = { responseTime: number testTime: number } type LatestChannelTestCachePatch = { patch: ChannelTestCachePatch completedAt: number } type ChannelListCache = GetChannelsResponse | SearchChannelsResponse function createChannelTestCachePatch( responseTime?: number, completedAt = Date.now() ): ChannelTestCachePatch | undefined { if (typeof responseTime !== 'number' || !Number.isFinite(responseTime)) { return undefined } return { responseTime, testTime: Math.floor(completedAt / 1000), } } function getLatestChannelTestCachePatch( results: TestResult[] ): ChannelTestCachePatch | undefined { const latest = results.reduce( (latestPatch, result) => { const completedAt = result.completedAt ?? 0 const patch = createChannelTestCachePatch( result.responseTime, completedAt ) if (!patch) return latestPatch if (!latestPatch || completedAt >= latestPatch.completedAt) { return { patch, completedAt } } return latestPatch }, undefined ) return latest?.patch } const endpointTypeOptions: Array<{ value: string; label: string }> = [ { value: 'auto', label: 'Auto detect (default)' }, { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, { value: 'openai-response', label: 'OpenAI Responses (/v1/responses)' }, { value: 'openai-response-compact', label: 'OpenAI Response Compaction (/v1/responses/compact)', }, { value: 'anthropic', label: 'Anthropic (/v1/messages)' }, { value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)', }, { value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' }, { value: 'image-generation', label: 'Image Generation (/v1/images/generations)', }, { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, ] const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([ 'embeddings', 'image-generation', 'jina-rerank', 'openai-response-compact', ]) 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 type FailureStatusDisplay = { summary: string details?: string } type FailureDetailsState = { model: string summary: string details: string } function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)) } function normalizeInlineError(errorText: string) { return errorText.replaceAll(/\s+/g, ' ').trim() } function getFirstErrorLine(errorText: string) { return errorText .split(/\r?\n/) .map((line) => line.trim()) .find(Boolean) } function truncateFailureSummary(summary: string) { if (summary.length <= FAILURE_SUMMARY_MAX_LENGTH) { return summary } return `${summary.slice(0, FAILURE_SUMMARY_MAX_LENGTH).trimEnd()}...` } function getFailureStatusDisplay({ errorText, fallbackSummary, isModelPriceError, modelPriceSummary, }: { errorText?: string fallbackSummary: string isModelPriceError: boolean modelPriceSummary: string }): FailureStatusDisplay { const rawError = errorText?.trim() if (!rawError) { return { summary: fallbackSummary } } if (isModelPriceError) { return { summary: modelPriceSummary, details: rawError === modelPriceSummary ? undefined : rawError, } } const firstLine = getFirstErrorLine(rawError) ?? rawError const summary = truncateFailureSummary(normalizeInlineError(firstLine)) const normalizedRawError = normalizeInlineError(rawError) return { summary, details: summary === normalizedRawError ? undefined : rawError, } } function getTestTableColumnClass(columnId: string) { switch (columnId) { case 'select': return 'w-10 min-w-10' case 'model': return 'w-auto min-w-48 whitespace-nowrap' case 'status': return 'w-28 min-w-28 whitespace-nowrap' case 'result': return 'w-80 min-w-80 max-w-80 whitespace-normal' case 'actions': return 'bg-popover w-px whitespace-nowrap' default: return undefined } } export function ChannelTestDialog({ open, onOpenChange, }: ChannelTestDialogProps) { const { currentRow } = useChannels() if (!currentRow) { return null } return ( ) } function ChannelTestDialogContent({ open, onOpenChange, currentRow, }: ChannelTestDialogContentProps) { const { t } = useTranslation() const queryClient = useQueryClient() const currentChannelId = currentRow.id const batchStopRequestedRef = useRef(false) const batchProgressToastIdRef = useRef | null>(null) const [endpointType, setEndpointType] = useState('auto') const [isStreamTest, setIsStreamTest] = useState(false) const [searchTerm, setSearchTerm] = useState('') const [testResults, setTestResults] = useState>({}) const [rowSelection, setRowSelection] = useState({}) const [testingModels, setTestingModels] = useState>( () => new Set() ) const [isBatchTesting, setIsBatchTesting] = useState(false) const [isBatchStopRequested, setIsBatchStopRequested] = useState(false) const [batchProgress, setBatchProgress] = useState(null) const [removedModels, setRemovedModels] = useState>( () => new Set() ) const [isDeleteFailedDialogOpen, setIsDeleteFailedDialogOpen] = useState(false) const [isDeletingFailed, setIsDeletingFailed] = useState(false) const [failureDetails, setFailureDetails] = useState(null) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 30, }) const endpointSelectItems = useMemo( () => endpointTypeOptions.map((option) => ({ value: option.value, label: t(option.label), })), [t] ) const dismissBatchProgressToast = useCallback(() => { if (batchProgressToastIdRef.current === null) return toast.dismiss(batchProgressToastIdRef.current) batchProgressToastIdRef.current = null }, []) useEffect(() => { if (!batchProgress) { dismissBatchProgressToast() return } const title = isBatchStopRequested ? t('Stopping batch test...') : t('Batch testing models...') const completedText = t('{{completed}}/{{total}} completed', { completed: batchProgress.completed, total: batchProgress.total, }) const resultText = t('{{success}} succeeded, {{failed}} failed', { success: batchProgress.success, failed: batchProgress.failed, }) batchProgressToastIdRef.current = toast.loading(title, { id: batchProgressToastIdRef.current ?? undefined, description: `${completedText} ยท ${resultText}`, }) }, [batchProgress, dismissBatchProgressToast, isBatchStopRequested, t]) useEffect(() => dismissBatchProgressToast, [dismissBatchProgressToast]) const resetState = useCallback(() => { batchStopRequestedRef.current = true setEndpointType('auto') setIsStreamTest(false) setSearchTerm('') setTestResults({}) setRowSelection({}) setTestingModels(() => new Set()) setIsBatchTesting(false) setIsBatchStopRequested(false) setBatchProgress(null) setRemovedModels(() => new Set()) setIsDeleteFailedDialogOpen(false) setIsDeletingFailed(false) setFailureDetails(null) setPagination({ pageIndex: 0, pageSize: 30 }) }, []) const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType) const effectiveStreamTest = !streamDisabled && isStreamTest const handleEndpointTypeChange = useCallback((value: string | null) => { if (value === null) return setEndpointType(value) if (STREAM_INCOMPATIBLE_ENDPOINTS.has(value)) { setIsStreamTest(false) } }, []) const handleSearchTermChange = useCallback( (event: ChangeEvent) => { setSearchTerm(event.target.value) setPagination((prev) => prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 } ) }, [] ) const modelsValue = currentRow.models const defaultTestModel = currentRow.test_model?.trim() const baseModels = useMemo(() => { if (!modelsValue) return [] return modelsValue .split(',') .map((model) => model.trim()) .filter(Boolean) }, [modelsValue]) const models = useMemo( () => baseModels.filter((model) => !removedModels.has(model)), [baseModels, removedModels] ) const successModels = useMemo( () => models.filter((model) => testResults[model]?.status === 'success'), [models, testResults] ) const failedModels = useMemo( () => models.filter((model) => testResults[model]?.status === 'error'), [models, testResults] ) const filteredModels = useMemo(() => { if (!searchTerm) return models const keyword = searchTerm.toLowerCase() return models.filter((model) => model.toLowerCase().includes(keyword)) }, [models, searchTerm]) const tableData = useMemo( () => filteredModels.map((model) => ({ model })), [filteredModels] ) const markModelTesting = useCallback((key: string, isTesting: boolean) => { setTestingModels((prev) => { const next = new Set(prev) if (isTesting) { next.add(key) } else { next.delete(key) } return next }) }, []) const updateTestResult = useCallback((key: string, result: TestResult) => { setFailureDetails((current) => (current?.model === key ? null : current)) setTestResults((prev) => ({ ...prev, [key]: result, })) }, []) const updateChannelTestCache = useCallback( (patch?: ChannelTestCachePatch) => { if (!patch) return queryClient.setQueriesData( { queryKey: channelsQueryKeys.lists() }, (oldData) => { const data = oldData?.data if (!oldData || !data?.items.length) return oldData let changed = false const nextItems = data.items.map((channel) => { if (channel.id !== currentChannelId) return channel changed = true return { ...channel, response_time: patch.responseTime, test_time: patch.testTime, } }) if (!changed) return oldData return { ...oldData, data: { ...data, items: nextItems, }, } } ) }, [currentChannelId, queryClient] ) const refreshChannelLists = useCallback( (patch?: ChannelTestCachePatch) => { updateChannelTestCache(patch) void queryClient .invalidateQueries({ queryKey: channelsQueryKeys.lists() }) .then(() => updateChannelTestCache(patch)) .catch(() => undefined) }, [queryClient, updateChannelTestCache] ) const testSingleModel = useCallback( async ( model: string, silent = false, refreshList = true ): Promise => { if (!currentRow) return markModelTesting(model, true) updateTestResult(model, { status: 'testing' }) let finalResult: TestResult | undefined try { await handleTestChannel( currentRow.id, { channelName: currentRow.name, testModel: model, endpointType: endpointType === 'auto' ? undefined : endpointType, stream: effectiveStreamTest || undefined, silent, }, (success, responseTime, error, errorCode) => { const completedAt = Date.now() finalResult = { status: success ? 'success' : 'error', responseTime, completedAt, error, errorCode, } updateTestResult(model, finalResult) } ) } catch (error: unknown) { finalResult = { status: 'error', completedAt: Date.now(), error: error instanceof Error ? error.message : t('Test failed'), } updateTestResult(model, finalResult) } finally { markModelTesting(model, false) if (refreshList) { refreshChannelLists( createChannelTestCachePatch( finalResult?.responseTime, finalResult?.completedAt ) ) } } return finalResult }, [ currentRow, endpointType, effectiveStreamTest, markModelTesting, refreshChannelLists, t, updateTestResult, ] ) const handleStopBatchTest = useCallback(() => { if (!isBatchTesting || isBatchStopRequested) return batchStopRequestedRef.current = true setIsBatchStopRequested(true) }, [isBatchStopRequested, isBatchTesting]) const handleBatchTest = useCallback( async (modelsToTest: string[]) => { const uniqueModels = [ ...new Set(modelsToTest.map((model) => model.trim()).filter(Boolean)), ] if (!uniqueModels.length) return batchStopRequestedRef.current = false setIsBatchTesting(true) setIsBatchStopRequested(false) setBatchProgress({ total: uniqueModels.length, completed: 0, success: 0, failed: 0, }) let resultPatch: ChannelTestCachePatch | undefined const results: TestResult[] = [] let completedCount = 0 let successCount = 0 let failedCount = 0 try { const createFallbackResult = (error?: unknown): TestResult => ({ status: 'error', completedAt: Date.now(), error: error instanceof Error ? error.message : t('Test failed'), }) const recordBatchResult = (result: TestResult) => { results.push(result) completedCount += 1 if (result.status === 'success') { successCount += 1 } failedCount = completedCount - successCount setBatchProgress({ total: uniqueModels.length, completed: completedCount, success: successCount, failed: failedCount, }) } for ( let startIndex = 0; startIndex < uniqueModels.length; startIndex += BATCH_TEST_CONCURRENCY ) { if (batchStopRequestedRef.current) { break } const batch = uniqueModels.slice( startIndex, startIndex + BATCH_TEST_CONCURRENCY ) const batchPromises = batch.map(async (modelName) => { try { const result = await testSingleModel(modelName, true, false) const finalResult = result ?? createFallbackResult() if (!result) { updateTestResult(modelName, finalResult) } recordBatchResult(finalResult) return finalResult } catch (error: unknown) { const fallbackResult = createFallbackResult(error) updateTestResult(modelName, fallbackResult) recordBatchResult(fallbackResult) return fallbackResult } }) await Promise.allSettled(batchPromises) if ( batchStopRequestedRef.current || startIndex + BATCH_TEST_CONCURRENCY >= uniqueModels.length ) { break } await sleep(BATCH_TEST_DELAY_MS) } resultPatch = getLatestChannelTestCachePatch(results) const stopped = batchStopRequestedRef.current && completedCount < uniqueModels.length dismissBatchProgressToast() if (stopped) { toast.info( t( 'Batch test stopped: {{completed}}/{{total}} completed, {{success}} succeeded, {{failed}} failed', { completed: completedCount, total: uniqueModels.length, success: successCount, failed: failedCount, } ) ) } else if (failedCount > 0) { toast.error( t( 'Batch test completed: {{success}} succeeded, {{failed}} failed', { success: successCount, failed: failedCount, } ) ) } else { toast.success( t('Batch test completed: {{count}} succeeded', { count: successCount, }) ) } } finally { batchStopRequestedRef.current = false setIsBatchTesting(false) setIsBatchStopRequested(false) setBatchProgress(null) setRowSelection({}) refreshChannelLists(resultPatch) } }, [ dismissBatchProgressToast, refreshChannelLists, t, testSingleModel, updateTestResult, ] ) const handleSelectSuccessfulModels = useCallback(() => { setRowSelection(() => { const next: RowSelectionState = {} for (const model of successModels) { next[model] = true } return next }) }, [successModels]) const handleDeleteFailedModels = useCallback(async () => { const failed = models.filter( (model) => testResults[model]?.status === 'error' ) if (!failed.length) { setIsDeleteFailedDialogOpen(false) return } const failedSet = new Set(failed) const remaining = models.filter((model) => !failedSet.has(model)) setIsDeletingFailed(true) try { const response = await updateChannel(currentRow.id, { models: remaining.join(','), }) if (response.success) { setRemovedModels((prev) => { const next = new Set(prev) for (const model of failed) next.add(model) return next }) setTestResults((prev) => { const next = { ...prev } for (const model of failed) delete next[model] return next }) setRowSelection((prev) => { const next = { ...prev } for (const model of failed) delete next[model] return next }) toast.success( t('Deleted {{count}} failed models', { count: failed.length }) ) refreshChannelLists() setIsDeleteFailedDialogOpen(false) } else { toast.error(response.message || t('Failed to delete failed models')) } } catch (error: unknown) { toast.error( error instanceof Error ? error.message : t('Failed to delete failed models') ) } finally { setIsDeletingFailed(false) } }, [currentRow.id, models, refreshChannelLists, t, testResults]) const handleClose = useCallback(() => { resetState() onOpenChange(false) }, [onOpenChange, resetState]) const handleDialogOpenChange = useCallback( (nextOpen: boolean) => { if (!nextOpen) { handleClose() } }, [handleClose] ) const isAnyTesting = testingModels.size > 0 || isBatchTesting const isFilteringModels = searchTerm.trim().length > 0 const testAllButtonLabel = isFilteringModels ? t('Test {{count}} matching models', { count: filteredModels.length }) : t('Test all {{count}} models', { count: filteredModels.length }) const columns = useMemo[]>( () => [ { id: 'select', header: ({ table }) => ( table.toggleAllRowsSelected(!!value)} aria-label={t('Select all models')} /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label={t('Select model {{model}}', { model: row.original.model, })} /> ), enableSorting: false, enableHiding: false, size: 40, }, { accessorKey: 'model', header: t('Model'), cell: ({ row }) => { const model = row.original.model const isDefault = defaultTestModel === model return ( {model} {isDefault && ( )} ) }, }, { id: 'status', header: t('Status'), cell: ({ row }) => { const model = row.original.model const result = testResults[model] return }, enableSorting: false, size: 112, }, { id: 'result', header: t('Result'), cell: ({ row }) => { const model = row.original.model const result = testResults[model] return ( ) }, enableSorting: false, size: 320, }, { id: 'actions', header: t('Actions'), cell: ({ row }) => { const model = row.original.model const isTestingModel = testingModels.has(model) return ( testSingleModel(model)} disabled={isTestingModel || isBatchTesting} aria-label={t('Test Connection')} /> } > {isTestingModel ? ( ) : ( )} {t('Test Connection')} ) }, enableSorting: false, }, ], [ defaultTestModel, isBatchTesting, t, testResults, testingModels, testSingleModel, ] ) const { table } = useDataTable({ data: tableData, columns, rowSelection, pagination, enableRowSelection: true, getRowId: (row) => row.model, onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, withFilteredRowModel: false, withSortedRowModel: false, withFacetedRowModel: false, }) return ( <> {t('Test Channel Connection')}: {currentRow.name} } contentClassName='max-h-[90vh] overflow-hidden sm:max-w-4xl' contentHeight='auto' bodyClassName='space-y-4' footer={ {t('Close')} } > {t('Endpoint Type')} {t( 'Override the endpoint used for testing. Leave empty to auto detect.' )} {t('Stream Mode')} {effectiveStreamTest ? t('Enabled') : t('Disabled')} {t('Enable streaming mode for the test request.')} {t('Channel models')} {t('Select models to run batch tests.')} {isBatchTesting ? ( {isBatchStopRequested ? t('Stopping...') : t('Stop testing')} ) : ( <> handleBatchTest(filteredModels)} disabled={isAnyTesting || filteredModels.length === 0} > {testAllButtonLabel} {successModels.length > 0 && ( {t('Select successful models ({{count}})', { count: successModels.length, })} )} {failedModels.length > 0 && ( setIsDeleteFailedDialogOpen(true)} > {t('Delete failed models ({{count}})', { count: failedModels.length, })} )} > )} } getColumnClassName={(columnId) => getTestTableColumnClass(columnId) } emptyContent={ models.length ? t('No models matched your search.') : t('This channel has no configured models.') } emptyCellClassName='text-muted-foreground h-16 text-center text-sm' /> { if (!sheetOpen) { setFailureDetails(null) } }} /> > ) } function TestStatusCell({ result }: { result?: TestResult }) { const { t } = useTranslation() if (!result || result.status === 'idle') { return ( ) } if (result.status === 'testing') { return ( {t('Testing...')} ) } if (result.status === 'success') { return ( ) } return } function TestResultCell({ result, model, onOpenDetails, }: { result?: TestResult model: string onOpenDetails: (details: FailureDetailsState) => void }) { const { t } = useTranslation() if (!result || result.status === 'idle') { return - } if (result.status === 'testing') { return ( {t('Testing...')} ) } if (result.status === 'success') { return typeof result.responseTime === 'number' ? ( {formatResponseTime(result.responseTime, t)} ) : ( - ) } return ( ) } function FailureResultContent({ result, model, onOpenDetails, }: { result: TestResult model: string onOpenDetails: (details: FailureDetailsState) => void }) { const { t } = useTranslation() const errorText = result.error?.trim() const isModelPriceError = result.errorCode === MODEL_PRICE_ERROR_CODE const modelPriceSummary = t( 'Model price is not configured. Please complete model pricing in settings.' ) const { summary, details } = getFailureStatusDisplay({ errorText, fallbackSummary: t('Test failed'), isModelPriceError, modelPriceSummary, }) return ( {summary} {isModelPriceError && ( window.open('/system-settings/billing/model-pricing', '_blank') } > {t('Go to Settings')} )} {details && ( onOpenDetails({ model, summary, details })} > {t('Details')} )} ) } function FailureDetailsSheet({ details, onOpenChange, }: { details: FailureDetailsState | null onOpenChange: (open: boolean) => void }) { const { t } = useTranslation() const isMobile = useIsMobile() const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false }) return ( {details && ( <> {t('Details')} {details.model} {t('Model')} {details.model} {t('Failed')} {details.summary} {t('Details')} {details.details} copyToClipboard(details.details)} > {copiedText === details.details ? ( ) : ( )} {t('Copy')} > )} ) } function TestModelsBulkActions({ table }: { table: TanStackTable }) { const { t } = useTranslation() const { copyToClipboard } = useCopyToClipboard() const selectedRows = table.getFilteredSelectedRowModel().rows const selectedModels = selectedRows.map((row) => row.original.model) const handleCopySelected = useCallback(() => { if (selectedModels.length === 0) return void copyToClipboard(selectedModels.join(',')) }, [copyToClipboard, selectedModels]) return ( } > {t('Copy selected models')} {t('Copy selected models separated by commas (e.g. a,b)')} ) }
{t( 'Override the endpoint used for testing. Leave empty to auto detect.' )}
{t('Enable streaming mode for the test request.')}
{t('Channel models')}
{t('Select models to run batch tests.')}
{summary}
{details.model}
{details.summary}
{details.details}
{t('Copy selected models separated by commas (e.g. a,b)')}