Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files. The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
642 lines
19 KiB
TypeScript
Vendored
642 lines
19 KiB
TypeScript
Vendored
/*
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 { Loader2, Settings } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
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 { 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',
|
|
])
|
|
|
|
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<Record<string, TestResult>>({})
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
const [testingModels, setTestingModels] = useState<Set<string>>(
|
|
() => new Set()
|
|
)
|
|
const [isBatchTesting, setIsBatchTesting] = useState(false)
|
|
const [pagination, setPagination] = useState({
|
|
pageIndex: 0,
|
|
pageSize: 10,
|
|
})
|
|
|
|
const resetState = useCallback(() => {
|
|
setEndpointType('auto')
|
|
setIsStreamTest(false)
|
|
setSearchTerm('')
|
|
setTestResults({})
|
|
setRowSelection({})
|
|
setTestingModels(() => new Set())
|
|
setIsBatchTesting(false)
|
|
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<ModelRow[]>(
|
|
() => 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) => {
|
|
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 : 'Test failed',
|
|
})
|
|
} finally {
|
|
markModelTesting(model, false)
|
|
}
|
|
},
|
|
[currentRow, endpointType, isStreamTest, markModelTesting, 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<ColumnDef<ModelRow>[]>(
|
|
() => [
|
|
{
|
|
id: 'select',
|
|
header: ({ table }) => (
|
|
<Checkbox
|
|
checked={table.getIsAllPageRowsSelected()}
|
|
indeterminate={table.getIsSomePageRowsSelected()}
|
|
onCheckedChange={(value) =>
|
|
table.toggleAllPageRowsSelected(!!value)
|
|
}
|
|
aria-label='Select all models'
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
aria-label={`Select model ${row.original.model}`}
|
|
/>
|
|
),
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
size: 40,
|
|
},
|
|
{
|
|
accessorKey: 'model',
|
|
header: 'Model',
|
|
cell: ({ row }) => {
|
|
const model = row.original.model
|
|
const isDefault = defaultTestModel === model
|
|
|
|
return (
|
|
<div className='flex items-center gap-2'>
|
|
<span className='font-medium'>{model}</span>
|
|
{isDefault && (
|
|
<StatusBadge
|
|
label='Default'
|
|
variant='info'
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
id: 'status',
|
|
header: 'Status',
|
|
cell: ({ row }) => {
|
|
const model = row.original.model
|
|
const result = testResults[model]
|
|
|
|
if (!result || result.status === 'idle') {
|
|
return (
|
|
<StatusBadge
|
|
label={t('Not tested')}
|
|
variant='neutral'
|
|
copyable={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (result.status === 'testing') {
|
|
return (
|
|
<div className='text-muted-foreground flex items-center gap-2 text-sm'>
|
|
<Loader2 className='h-4 w-4 animate-spin' />
|
|
Testing...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (result.status === 'success') {
|
|
return (
|
|
<div className='flex flex-col gap-1 text-xs'>
|
|
<StatusBadge
|
|
label='Success'
|
|
variant='success'
|
|
copyable={false}
|
|
/>
|
|
{typeof result.responseTime === 'number' && (
|
|
<span className='text-muted-foreground'>
|
|
{formatResponseTime(result.responseTime, t)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className='flex flex-col gap-1 text-xs'>
|
|
<StatusBadge label='Failed' variant='danger' copyable={false} />
|
|
{result.error && (
|
|
<span className='text-muted-foreground break-all'>
|
|
{result.error}
|
|
</span>
|
|
)}
|
|
{result.errorCode === 'model_price_error' && (
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
className='w-fit'
|
|
onClick={() =>
|
|
window.open('/console/setting?tab=ratio', '_blank')
|
|
}
|
|
>
|
|
<Settings className='mr-1 h-3 w-3' />
|
|
{t('Go to Settings')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
enableSorting: false,
|
|
size: 220,
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: 'Actions',
|
|
cell: ({ row }) => {
|
|
const model = row.original.model
|
|
const isTestingModel = testingModels.has(model)
|
|
|
|
return (
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => testSingleModel(model)}
|
|
disabled={isTestingModel || isBatchTesting}
|
|
>
|
|
{isTestingModel && (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
)}
|
|
Test
|
|
</Button>
|
|
)
|
|
},
|
|
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 (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
|
|
<Select
|
|
items={[
|
|
...endpointTypeOptions.map((option) => {
|
|
const itemValue = option.value
|
|
return { value: itemValue, label: t(option.label) }
|
|
}),
|
|
]}
|
|
value={endpointType}
|
|
onValueChange={(v) => v !== null && setEndpointType(v)}
|
|
>
|
|
<SelectTrigger id='endpoint-type'>
|
|
<SelectValue placeholder={t('Auto detect (default)')} />
|
|
</SelectTrigger>
|
|
<SelectContent alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
{endpointTypeOptions.map((option) => {
|
|
const itemValue = option.value
|
|
return (
|
|
<SelectItem key={itemValue} value={itemValue}>
|
|
{t(option.label)}
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t(
|
|
'Override the endpoint used for testing. Leave empty to auto detect.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
|
|
<div className='flex items-center gap-2'>
|
|
<Switch
|
|
id='stream-toggle'
|
|
checked={isStreamTest}
|
|
onCheckedChange={setIsStreamTest}
|
|
disabled={streamDisabled}
|
|
/>
|
|
<span className='text-sm'>
|
|
{isStreamTest ? t('Enabled') : t('Disabled')}
|
|
</span>
|
|
</div>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t('Enable streaming mode for the test request.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
|
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div>
|
|
<p className='text-sm font-medium'>{t('Channel models')}</p>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t('Select models to run batch tests.')}
|
|
</p>
|
|
</div>
|
|
<Input
|
|
placeholder={t('Filter models...')}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className='sm:w-64'
|
|
/>
|
|
</div>
|
|
|
|
<div className='space-y-3'>
|
|
<div className='overflow-hidden rounded-md border' role='region'>
|
|
<div className='max-h-[360px] overflow-y-auto'>
|
|
<Table>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<TableHead key={header.id}>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext()
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{table.getRowModel().rows.length ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={
|
|
row.getIsSelected() ? 'selected' : undefined
|
|
}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id}>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext()
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={table.getVisibleLeafColumns().length}
|
|
className='text-muted-foreground h-16 text-center text-sm'
|
|
>
|
|
{models.length
|
|
? 'No models matched your search.'
|
|
: 'This channel has no configured models.'}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTablePagination table={table} />
|
|
</div>
|
|
|
|
<TestModelsBulkActions
|
|
table={table}
|
|
disabled={isAnyTesting}
|
|
onTestSelected={handleBatchTest}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant='outline' onClick={handleClose}>
|
|
{t('Close')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function TestModelsBulkActions({
|
|
table,
|
|
disabled,
|
|
onTestSelected,
|
|
}: {
|
|
table: TanStackTable<ModelRow>
|
|
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
|
|
? `Test ${selectedModels.length} selected`
|
|
: 'Test selected models'
|
|
|
|
return (
|
|
<BulkActionsToolbar table={table} entityName='model'>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
size='sm'
|
|
onClick={() => onTestSelected(selectedModels)}
|
|
disabled={disabled || selectedModels.length === 0}
|
|
/>
|
|
}
|
|
>
|
|
{disabled ? (
|
|
<>
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
{t('Testing...')}
|
|
</>
|
|
) : (
|
|
buttonLabel
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t('Run tests for the selected models')}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</BulkActionsToolbar>
|
|
)
|
|
}
|