mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
perf(web): refine channel test dialog interactions
Merge pull request #5886 from QuantumNous/perf/channel-test-dialog-layout
This commit is contained in:
+155
-108
@@ -17,21 +17,29 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import type {
|
||||||
type ColumnDef,
|
ColumnDef,
|
||||||
type RowSelectionState,
|
RowSelectionState,
|
||||||
type Table as TanStackTable,
|
Table as TanStackTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Gauge,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { type ChangeEvent, useCallback, useMemo, useRef, useState } from 'react'
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -54,7 +62,6 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -224,7 +231,7 @@ function sleep(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInlineError(errorText: string) {
|
function normalizeInlineError(errorText: string) {
|
||||||
return errorText.replace(/\s+/g, ' ').trim()
|
return errorText.replaceAll(/\s+/g, ' ').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstErrorLine(errorText: string) {
|
function getFirstErrorLine(errorText: string) {
|
||||||
@@ -281,11 +288,13 @@ function getTestTableColumnClass(columnId: string) {
|
|||||||
case 'select':
|
case 'select':
|
||||||
return 'w-10 min-w-10'
|
return 'w-10 min-w-10'
|
||||||
case 'model':
|
case 'model':
|
||||||
return 'w-auto whitespace-nowrap'
|
return 'w-auto min-w-48 whitespace-nowrap'
|
||||||
case 'status':
|
case 'status':
|
||||||
return 'w-70 min-w-70 max-w-70 whitespace-normal'
|
return 'w-28 min-w-28 whitespace-nowrap'
|
||||||
|
case 'result':
|
||||||
|
return 'w-80 min-w-80 max-w-80 whitespace-normal'
|
||||||
case 'actions':
|
case 'actions':
|
||||||
return 'bg-popover w-24 min-w-24 whitespace-nowrap sm:w-28 sm:min-w-28'
|
return 'bg-popover w-px whitespace-nowrap'
|
||||||
default:
|
default:
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -320,6 +329,9 @@ function ChannelTestDialogContent({
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const currentChannelId = currentRow.id
|
const currentChannelId = currentRow.id
|
||||||
const batchStopRequestedRef = useRef(false)
|
const batchStopRequestedRef = useRef(false)
|
||||||
|
const batchProgressToastIdRef = useRef<ReturnType<
|
||||||
|
typeof toast.loading
|
||||||
|
> | null>(null)
|
||||||
const [endpointType, setEndpointType] = useState('auto')
|
const [endpointType, setEndpointType] = useState('auto')
|
||||||
const [isStreamTest, setIsStreamTest] = useState(false)
|
const [isStreamTest, setIsStreamTest] = useState(false)
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
@@ -352,6 +364,39 @@ function ChannelTestDialogContent({
|
|||||||
[t]
|
[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(() => {
|
const resetState = useCallback(() => {
|
||||||
batchStopRequestedRef.current = true
|
batchStopRequestedRef.current = true
|
||||||
setEndpointType('auto')
|
setEndpointType('auto')
|
||||||
@@ -571,9 +616,9 @@ function ChannelTestDialogContent({
|
|||||||
|
|
||||||
const handleBatchTest = useCallback(
|
const handleBatchTest = useCallback(
|
||||||
async (modelsToTest: string[]) => {
|
async (modelsToTest: string[]) => {
|
||||||
const uniqueModels = Array.from(
|
const uniqueModels = [
|
||||||
new Set(modelsToTest.map((model) => model.trim()).filter(Boolean))
|
...new Set(modelsToTest.map((model) => model.trim()).filter(Boolean)),
|
||||||
)
|
]
|
||||||
if (!uniqueModels.length) return
|
if (!uniqueModels.length) return
|
||||||
|
|
||||||
batchStopRequestedRef.current = false
|
batchStopRequestedRef.current = false
|
||||||
@@ -661,6 +706,7 @@ function ChannelTestDialogContent({
|
|||||||
const stopped =
|
const stopped =
|
||||||
batchStopRequestedRef.current && completedCount < uniqueModels.length
|
batchStopRequestedRef.current && completedCount < uniqueModels.length
|
||||||
|
|
||||||
|
dismissBatchProgressToast()
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
toast.info(
|
toast.info(
|
||||||
t(
|
t(
|
||||||
@@ -699,7 +745,13 @@ function ChannelTestDialogContent({
|
|||||||
refreshChannelLists(resultPatch)
|
refreshChannelLists(resultPatch)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshChannelLists, t, testSingleModel, updateTestResult]
|
[
|
||||||
|
dismissBatchProgressToast,
|
||||||
|
refreshChannelLists,
|
||||||
|
t,
|
||||||
|
testSingleModel,
|
||||||
|
updateTestResult,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSelectSuccessfulModels = useCallback(() => {
|
const handleSelectSuccessfulModels = useCallback(() => {
|
||||||
@@ -838,11 +890,22 @@ function ChannelTestDialogContent({
|
|||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: t('Status'),
|
header: t('Status'),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const model = row.original.model
|
||||||
|
const result = testResults[model]
|
||||||
|
return <TestStatusCell result={result} />
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
size: 112,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'result',
|
||||||
|
header: t('Result'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const model = row.original.model
|
const model = row.original.model
|
||||||
const result = testResults[model]
|
const result = testResults[model]
|
||||||
return (
|
return (
|
||||||
<TestStatusCell
|
<TestResultCell
|
||||||
result={result}
|
result={result}
|
||||||
model={model}
|
model={model}
|
||||||
onOpenDetails={setFailureDetails}
|
onOpenDetails={setFailureDetails}
|
||||||
@@ -850,7 +913,7 @@ function ChannelTestDialogContent({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 220,
|
size: 320,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -860,17 +923,26 @@ function ChannelTestDialogContent({
|
|||||||
const isTestingModel = testingModels.has(model)
|
const isTestingModel = testingModels.has(model)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='ghost'
|
||||||
size='sm'
|
size='icon-sm'
|
||||||
onClick={() => testSingleModel(model)}
|
onClick={() => testSingleModel(model)}
|
||||||
disabled={isTestingModel || isBatchTesting}
|
disabled={isTestingModel || isBatchTesting}
|
||||||
|
aria-label={t('Test Connection')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isTestingModel && (
|
{isTestingModel ? (
|
||||||
<Loader2 className='animate-spin' data-icon='inline-start' />
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Gauge className='size-4' />
|
||||||
)}
|
)}
|
||||||
{t('Test')}
|
</TooltipTrigger>
|
||||||
</Button>
|
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -905,22 +977,19 @@ function ChannelTestDialogContent({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleDialogOpenChange}
|
onOpenChange={handleDialogOpenChange}
|
||||||
title={t('Test Channel Connection')}
|
title={
|
||||||
description={
|
<span className='inline-flex max-w-full min-w-0 items-center gap-1.5'>
|
||||||
<>
|
<span className='shrink-0'>{t('Test Channel Connection')}:</span>
|
||||||
{t('Test connectivity for:')}
|
<span className='min-w-0 truncate'>{currentRow.name}</span>
|
||||||
<strong>{currentRow.name}</strong>
|
</span>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
|
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-4xl'
|
||||||
contentHeight='auto'
|
contentHeight='auto'
|
||||||
bodyClassName='space-y-4'
|
bodyClassName='space-y-4'
|
||||||
footer={
|
footer={
|
||||||
<>
|
|
||||||
<Button variant='outline' onClick={handleClose}>
|
<Button variant='outline' onClick={handleClose}>
|
||||||
{t('Close')}
|
{t('Close')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
|
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
|
||||||
@@ -983,23 +1052,17 @@ function ChannelTestDialogContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
|
<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 className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
<div>
|
<div className='min-w-0 space-y-2'>
|
||||||
<p className='text-sm font-medium'>{t('Channel models')}</p>
|
<p className='text-sm font-medium'>{t('Channel models')}</p>
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
{t('Select models to run batch tests.')}
|
{t('Select models to run batch tests.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
|
||||||
<Input
|
|
||||||
placeholder={t('Filter models...')}
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={handleSearchTermChange}
|
|
||||||
className='sm:w-64'
|
|
||||||
/>
|
|
||||||
{isBatchTesting ? (
|
{isBatchTesting ? (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
onClick={handleStopBatchTest}
|
onClick={handleStopBatchTest}
|
||||||
disabled={isBatchStopRequested}
|
disabled={isBatchStopRequested}
|
||||||
>
|
>
|
||||||
@@ -1008,26 +1071,14 @@ function ChannelTestDialogContent({
|
|||||||
: t('Stop testing')}
|
: t('Stop testing')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
size='sm'
|
||||||
onClick={() => handleBatchTest(filteredModels)}
|
onClick={() => handleBatchTest(filteredModels)}
|
||||||
disabled={isAnyTesting || filteredModels.length === 0}
|
disabled={isAnyTesting || filteredModels.length === 0}
|
||||||
>
|
>
|
||||||
{testAllButtonLabel}
|
{testAllButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{batchProgress && (
|
|
||||||
<BatchProgressSummary
|
|
||||||
progress={batchProgress}
|
|
||||||
isStopping={isBatchStopRequested}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAnyTesting &&
|
|
||||||
(successModels.length > 0 || failedModels.length > 0) && (
|
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
|
||||||
{successModels.length > 0 && (
|
{successModels.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
@@ -1052,8 +1103,19 @@ function ChannelTestDialogContent({
|
|||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
||||||
|
<Input
|
||||||
|
placeholder={t('Filter models...')}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleSearchTermChange}
|
||||||
|
className='sm:w-64'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<DataTableView
|
<DataTableView
|
||||||
@@ -1076,8 +1138,9 @@ function ChannelTestDialogContent({
|
|||||||
<colgroup>
|
<colgroup>
|
||||||
<col className='w-10 min-w-10' />
|
<col className='w-10 min-w-10' />
|
||||||
<col className='w-auto' />
|
<col className='w-auto' />
|
||||||
<col className='w-70' />
|
<col className='w-28' />
|
||||||
<col className='w-auto' />
|
<col className='w-80' />
|
||||||
|
<col className='w-px' />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
}
|
}
|
||||||
getColumnClassName={(columnId) =>
|
getColumnClassName={(columnId) =>
|
||||||
@@ -1123,46 +1186,36 @@ function ChannelTestDialogContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BatchProgressSummary({
|
function TestStatusCell({ result }: { result?: TestResult }) {
|
||||||
progress,
|
|
||||||
isStopping,
|
|
||||||
}: {
|
|
||||||
progress: BatchProgress
|
|
||||||
isStopping: boolean
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const progressValue =
|
|
||||||
progress.total > 0
|
|
||||||
? Math.min(100, Math.round((progress.completed / progress.total) * 100))
|
|
||||||
: 0
|
|
||||||
|
|
||||||
|
if (!result || result.status === 'idle') {
|
||||||
return (
|
return (
|
||||||
<div className='bg-muted/30 flex flex-col gap-2 rounded-md border p-3'>
|
<StatusBadge label={t('Not tested')} variant='neutral' copyable={false} />
|
||||||
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
|
|
||||||
<p className='text-sm font-medium'>
|
|
||||||
{isStopping
|
|
||||||
? t('Stopping batch test...')
|
|
||||||
: t('Batch testing models...')}
|
|
||||||
</p>
|
|
||||||
<p className='text-muted-foreground text-xs tabular-nums'>
|
|
||||||
{t('{{completed}}/{{total}} completed', {
|
|
||||||
completed: progress.completed,
|
|
||||||
total: progress.total,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Progress value={progressValue} />
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
{t('{{success}} succeeded, {{failed}} failed', {
|
|
||||||
success: progress.success,
|
|
||||||
failed: progress.failed,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TestStatusCell({
|
if (result.status === 'testing') {
|
||||||
|
return (
|
||||||
|
<StatusBadge variant='info' copyable={false}>
|
||||||
|
<Loader2 className='size-3.5 shrink-0 animate-spin' />
|
||||||
|
<span className='min-w-0 truncate leading-normal'>
|
||||||
|
{t('Testing...')}
|
||||||
|
</span>
|
||||||
|
</StatusBadge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
return (
|
||||||
|
<StatusBadge label={t('Success')} variant='success' copyable={false} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StatusBadge label={t('Failed')} variant='danger' copyable={false} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultCell({
|
||||||
result,
|
result,
|
||||||
model,
|
model,
|
||||||
onOpenDetails,
|
onOpenDetails,
|
||||||
@@ -1174,9 +1227,7 @@ function TestStatusCell({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (!result || result.status === 'idle') {
|
if (!result || result.status === 'idle') {
|
||||||
return (
|
return <span className='text-muted-foreground text-sm'>-</span>
|
||||||
<StatusBadge label={t('Not tested')} variant='neutral' copyable={false} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'testing') {
|
if (result.status === 'testing') {
|
||||||
@@ -1189,20 +1240,17 @@ function TestStatusCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
return (
|
return typeof result.responseTime === 'number' ? (
|
||||||
<div className='flex min-w-0 flex-col gap-1 text-xs'>
|
<span className='text-muted-foreground text-sm'>
|
||||||
<StatusBadge label={t('Success')} variant='success' copyable={false} />
|
|
||||||
{typeof result.responseTime === 'number' && (
|
|
||||||
<span className='text-muted-foreground truncate'>
|
|
||||||
{formatResponseTime(result.responseTime, t)}
|
{formatResponseTime(result.responseTime, t)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<span className='text-muted-foreground text-sm'>-</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FailureStatusContent
|
<FailureResultContent
|
||||||
result={result}
|
result={result}
|
||||||
model={model}
|
model={model}
|
||||||
onOpenDetails={onOpenDetails}
|
onOpenDetails={onOpenDetails}
|
||||||
@@ -1210,7 +1258,7 @@ function TestStatusCell({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FailureStatusContent({
|
function FailureResultContent({
|
||||||
result,
|
result,
|
||||||
model,
|
model,
|
||||||
onOpenDetails,
|
onOpenDetails,
|
||||||
@@ -1233,12 +1281,11 @@ function FailureStatusContent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-0 flex-col gap-1.5 text-xs whitespace-normal'>
|
<div className='flex min-w-0 items-center gap-2 text-xs whitespace-normal'>
|
||||||
<StatusBadge label={t('Failed')} variant='danger' copyable={false} />
|
<p className='text-muted-foreground line-clamp-2 min-w-0 flex-1 leading-snug wrap-break-word'>
|
||||||
<p className='text-muted-foreground line-clamp-2 min-w-0 leading-snug wrap-break-word'>
|
|
||||||
{summary}
|
{summary}
|
||||||
</p>
|
</p>
|
||||||
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
|
<div className='flex shrink-0 flex-wrap items-center justify-end gap-1.5'>
|
||||||
{isModelPriceError && (
|
{isModelPriceError && (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
|||||||
Reference in New Issue
Block a user