/* 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 { useCallback, useEffect, useMemo, useState } from 'react' import { type ColumnDef, type RowSelectionState, type Table as TanStackTable, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable, } from '@tanstack/react-table' import { Check, Copy, Info, Loader2, Settings } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useIsMobile } from '@/hooks/use-mobile' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' import { DataTablePagination } from '@/components/data-table/pagination' import { StatusBadge } from '@/components/status-badge' import { formatResponseTime, handleTestChannel } from '../../lib' import { useChannels } from '../channels-provider' type ChannelTestDialogProps = { open: boolean onOpenChange: (open: boolean) => void } type ModelRow = { model: string } type TestStatus = 'idle' | 'testing' | 'success' | 'error' type TestResult = { status: TestStatus responseTime?: number error?: string errorCode?: string } 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 type FailureStatusDisplay = { summary: string details?: string } type FailureDetailsState = { model: string summary: string details: string } function normalizeInlineError(errorText: string) { return errorText.replace(/\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 whitespace-nowrap' case 'status': return 'w-70 min-w-70 max-w-70 whitespace-normal' case 'actions': return 'bg-popover sticky right-0 z-20 w-24 min-w-24 border-l shadow-[-8px_0_8px_-8px_rgb(0_0_0_/_0.2)] whitespace-nowrap sm:w-28 sm:min-w-28' default: return undefined } } export function ChannelTestDialog({ open, onOpenChange, }: ChannelTestDialogProps) { const { t } = useTranslation() const { currentRow } = useChannels() 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 [failureDetails, setFailureDetails] = useState(null) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10, }) const resetState = useCallback(() => { setEndpointType('auto') setIsStreamTest(false) setSearchTerm('') setTestResults({}) setRowSelection({}) setTestingModels(() => new Set()) setIsBatchTesting(false) setFailureDetails(null) setPagination({ pageIndex: 0, pageSize: 10 }) }, []) useEffect(() => { if (open && currentRow) { resetState() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, currentRow?.id, resetState]) const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType) useEffect(() => { if (streamDisabled) { setIsStreamTest(false) } }, [streamDisabled]) const modelsValue = currentRow?.models ?? '' const defaultTestModel = currentRow?.test_model?.trim() const models = useMemo(() => { if (!modelsValue) return [] return modelsValue .split(',') .map((model) => model.trim()) .filter(Boolean) }, [modelsValue]) const filteredModels = useMemo(() => { if (!searchTerm) return models const keyword = searchTerm.toLowerCase() return models.filter((model) => model.toLowerCase().includes(keyword)) }, [models, searchTerm]) useEffect(() => { setPagination((prev) => ({ ...prev, pageIndex: 0 })) }, [searchTerm, modelsValue]) 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 testSingleModel = useCallback( async (model: string) => { if (!currentRow) return markModelTesting(model, true) updateTestResult(model, { status: 'testing' }) try { await handleTestChannel( currentRow.id, { testModel: model, endpointType: endpointType === 'auto' ? undefined : endpointType, stream: isStreamTest || undefined, }, (success, responseTime, error, errorCode) => { updateTestResult(model, { status: success ? 'success' : 'error', responseTime, error, errorCode, }) } ) } catch (error: unknown) { updateTestResult(model, { status: 'error', error: error instanceof Error ? error.message : t('Test failed'), }) } finally { markModelTesting(model, false) } }, [ currentRow, endpointType, isStreamTest, markModelTesting, t, updateTestResult, ] ) const handleBatchTest = useCallback( async (modelsToTest: string[]) => { if (!modelsToTest.length) return setIsBatchTesting(true) try { await Promise.allSettled( modelsToTest.map((modelName) => testSingleModel(modelName)) ) } finally { setIsBatchTesting(false) setRowSelection({}) } }, [testSingleModel] ) const handleClose = () => { resetState() onOpenChange(false) } const isAnyTesting = testingModels.size > 0 || isBatchTesting const columns = useMemo[]>( () => [ { id: 'select', header: ({ table }) => ( table.toggleAllPageRowsSelected(!!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: 220, }, { id: 'actions', header: t('Actions'), cell: ({ row }) => { const model = row.original.model const isTestingModel = testingModels.has(model) return ( testSingleModel(model)} disabled={isTestingModel || isBatchTesting} > {isTestingModel && ( )} {t('Test')} ) }, enableSorting: false, size: 120, }, ], [ defaultTestModel, isBatchTesting, t, testResults, testingModels, testSingleModel, ] ) const table = useReactTable({ data: tableData, columns, state: { rowSelection, pagination, }, enableRowSelection: true, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, }) if (!currentRow) { return null } return ( <> {t('Test Channel Connection')} {t('Test connectivity for:')} {currentRow.name} {t('Endpoint Type')} { const itemValue = option.value return { value: itemValue, label: t(option.label) } }), ]} value={endpointType} onValueChange={(v) => v !== null && setEndpointType(v)} > {endpointTypeOptions.map((option) => { const itemValue = option.value return ( {t(option.label)} ) })} {t( 'Override the endpoint used for testing. Leave empty to auto detect.' )} {t('Stream Mode')} {isStreamTest ? t('Enabled') : t('Disabled')} {t('Enable streaming mode for the test request.')} {t('Channel models')} {t('Select models to run batch tests.')} setSearchTerm(e.target.value)} className='sm:w-64' /> {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ))} ))} {table.getRowModel().rows.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ))} )) ) : ( {models.length ? 'No models matched your search.' : 'This channel has no configured models.'} )} {t('Close')} { if (!sheetOpen) { setFailureDetails(null) } }} /> > ) } function TestStatusCell({ 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 FailureStatusContent({ 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, disabled, onTestSelected, }: { table: TanStackTable disabled?: boolean onTestSelected: (models: string[]) => void }) { const { t } = useTranslation() const selectedRows = table.getFilteredSelectedRowModel().rows const selectedModels = selectedRows.map((row) => row.original.model) const buttonLabel = selectedModels.length > 0 ? t('Test {{count}} selected', { count: selectedModels.length }) : t('Test selected models') return ( onTestSelected(selectedModels)} disabled={disabled || selectedModels.length === 0} /> } > {disabled ? ( <> {t('Testing...')} > ) : ( buttonLabel )} {t('Run tests for the selected models')} ) }
{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('Run tests for the selected models')}