Skip to content

Commit 9577f92

Browse files
committed
fix: ComparableChannelPayload
1 parent 905ff89 commit 9577f92

2 files changed

Lines changed: 176 additions & 15 deletions

File tree

web/src/feature/channel/components/ChannelForm.tsx

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
FormMessage,
1515
} from '@/components/ui/form'
1616
import { channelCreateSchema } from '@/validation/channel'
17-
import { useChannelTypeMetas, useCreateChannel, useUpdateChannel, useUpdateChannelStatus, useTestChannelPreviewAll, useChannelDefaultModels } from '../hooks'
17+
import { useChannelTypeMetas, useCreateChannel, useUpdateChannel, useUpdateChannelStatus, useTestChannel, useTestChannelPreviewAll, useChannelDefaultModels } from '../hooks'
1818
import { useModels } from '@/feature/model/hooks'
1919
import { useTranslation } from 'react-i18next'
2020
import { ChannelCreateForm } from '@/validation/channel'
@@ -33,6 +33,62 @@ import { DefaultModelsDialog } from './DefaultModelsDialog'
3333
import { ChannelConfigEditor } from './ChannelConfigEditor'
3434
import { useRuntimeMetrics } from '@/feature/monitor/runtime-hooks'
3535
import { getChannelModelMetric } from '@/utils/runtime-metrics'
36+
import { DEFAULT_PRIORITY } from '@/types/channel'
37+
38+
type ComparableChannelPayload = {
39+
type: number
40+
name: string
41+
key: string
42+
base_url: string
43+
proxy_url: string
44+
models: string[]
45+
model_mapping: Record<string, string>
46+
sets: string[]
47+
priority: number
48+
enabled_no_permission_ban: boolean
49+
warn_error_rate?: number
50+
max_error_rate?: number
51+
configs?: Record<string, unknown>
52+
}
53+
54+
const stableSerialize = (value: unknown): string => {
55+
if (Array.isArray(value)) {
56+
return `[${value.map(stableSerialize).join(',')}]`
57+
}
58+
59+
if (value && typeof value === 'object') {
60+
const entries = Object.entries(value as Record<string, unknown>)
61+
.sort(([left], [right]) => left.localeCompare(right))
62+
.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableSerialize(nestedValue)}`)
63+
return `{${entries.join(',')}}`
64+
}
65+
66+
return JSON.stringify(value) ?? 'undefined'
67+
}
68+
69+
const normalizeChannelPayload = (
70+
payload: Partial<ComparableChannelPayload> & {
71+
type: number
72+
name: string
73+
key: string
74+
}
75+
): ComparableChannelPayload => ({
76+
type: payload.type,
77+
name: payload.name,
78+
key: payload.key,
79+
base_url: payload.base_url ?? '',
80+
proxy_url: payload.proxy_url ?? '',
81+
models: payload.models ?? [],
82+
model_mapping: payload.model_mapping ?? {},
83+
sets: payload.sets ?? [],
84+
priority: payload.priority ?? DEFAULT_PRIORITY,
85+
enabled_no_permission_ban: payload.enabled_no_permission_ban ?? false,
86+
warn_error_rate: payload.warn_error_rate ?? undefined,
87+
max_error_rate: payload.max_error_rate && payload.max_error_rate > 0
88+
? payload.max_error_rate
89+
: undefined,
90+
configs: payload.configs ?? undefined,
91+
})
3692

3793
interface ChannelFormProps {
3894
mode?: 'create' | 'update' | 'copy'
@@ -114,15 +170,24 @@ export function ChannelForm({
114170
const { updateStatus, isLoading: isStatusUpdating } = useUpdateChannelStatus()
115171

116172
// Test channel hook
173+
const {
174+
testChannel: testSavedChannel,
175+
cancelTest: cancelSavedChannelTest,
176+
isTesting: isSavedChannelTesting,
177+
results: savedChannelTestResults,
178+
clearResults: clearSavedChannelTestResults
179+
} = useTestChannel()
180+
117181
const {
118182
testChannelPreviewAll,
119-
cancelTest,
120-
isTesting,
121-
results: testResults,
122-
clearResults: clearTestResults
183+
cancelTest: cancelPreviewChannelTest,
184+
isTesting: isPreviewChannelTesting,
185+
results: previewChannelTestResults,
186+
clearResults: clearPreviewChannelTestResults
123187
} = useTestChannelPreviewAll()
124188

125189
const [testDialogOpen, setTestDialogOpen] = useState(false)
190+
const [activeTestMode, setActiveTestMode] = useState<'saved' | 'preview' | null>(null)
126191

127192
useEffect(() => {
128193
setCurrentStatus(channel?.status ?? 1)
@@ -132,6 +197,8 @@ export function ChannelForm({
132197
const isLoading = isCreateLikeMode ? isCreating : isUpdating
133198
const error = isCreateLikeMode ? createError : updateError
134199
const clearError = isCreateLikeMode ? clearCreateError : clearUpdateError
200+
const isTesting = isSavedChannelTesting || isPreviewChannelTesting
201+
const testResults = activeTestMode === 'saved' ? savedChannelTestResults : previewChannelTestResults
135202

136203
// 表单设置
137204
const form = useForm<ChannelCreateForm>({
@@ -281,6 +348,49 @@ export function ChannelForm({
281348
}
282349
}
283350

351+
const isChannelFormUnchanged = (
352+
formData: ChannelCreateForm,
353+
parsedConfigs?: Record<string, unknown>
354+
) => {
355+
if (mode !== 'update' || !channel) {
356+
return false
357+
}
358+
359+
const currentPayload = normalizeChannelPayload({
360+
type: formData.type,
361+
name: formData.name,
362+
key: formData.key,
363+
base_url: formData.base_url || '',
364+
proxy_url: formData.proxy_url || '',
365+
models: effectiveUseDefault ? [] : (formData.models || []),
366+
model_mapping: effectiveUseDefault ? {} : (formData.model_mapping || {}),
367+
sets: formData.sets || [],
368+
priority: formData.priority,
369+
enabled_no_permission_ban: formData.enabled_no_permission_ban ?? false,
370+
warn_error_rate: formData.warn_error_rate,
371+
max_error_rate: formData.max_error_rate,
372+
configs: parsedConfigs,
373+
})
374+
375+
const originalPayload = normalizeChannelPayload({
376+
type: channel.type,
377+
name: channel.name,
378+
key: channel.key,
379+
base_url: channel.base_url || '',
380+
proxy_url: channel.proxy_url || '',
381+
models: channel.models || [],
382+
model_mapping: channel.model_mapping || {},
383+
sets: channel.sets || [],
384+
priority: channel.priority,
385+
enabled_no_permission_ban: channel.enabled_no_permission_ban ?? false,
386+
warn_error_rate: channel.warn_error_rate,
387+
max_error_rate: channel.max_error_rate,
388+
configs: channel.configs || undefined,
389+
})
390+
391+
return stableSerialize(currentPayload) === stableSerialize(originalPayload)
392+
}
393+
284394
// 处理测试按钮点击
285395
const handleTestClick = () => {
286396
const formData = form.getValues()
@@ -329,9 +439,18 @@ export function ChannelForm({
329439
}
330440
}
331441

332-
clearTestResults()
333442
setTestDialogOpen(true)
443+
const useSavedChannelTest = mode === 'update' && !!channelId && isChannelFormUnchanged(formData, parsedConfigs)
334444

445+
if (useSavedChannelTest && channelId) {
446+
setActiveTestMode('saved')
447+
clearSavedChannelTestResults()
448+
testSavedChannel(channelId)
449+
return
450+
}
451+
452+
setActiveTestMode('preview')
453+
clearPreviewChannelTestResults()
335454
testChannelPreviewAll({
336455
type: formData.type,
337456
key: formData.key,
@@ -346,7 +465,11 @@ export function ChannelForm({
346465

347466
// 处理取消测试
348467
const handleCancelTest = () => {
349-
cancelTest()
468+
if (activeTestMode === 'saved') {
469+
cancelSavedChannelTest()
470+
} else {
471+
cancelPreviewChannelTest()
472+
}
350473
setTestDialogOpen(false)
351474
}
352475

web/src/feature/channel/components/ChannelTable.tsx

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,9 @@ export function ChannelTable() {
368368
header: () => <div className="font-medium py-3.5 whitespace-nowrap">{t("channel.name")}</div>,
369369
cell: ({ row }) => (
370370
<div
371-
className={cn("font-medium", clickableCell)}
371+
className={cn("max-w-[240px] truncate font-medium", clickableCell)}
372372
onClick={() => openUpdateDialog(row.original)}
373+
title={row.original.name}
373374
>
374375
{row.original.name}
375376
</div>
@@ -560,19 +561,56 @@ export function ChannelTable() {
560561
return <div className="text-xs text-muted-foreground">-</div>
561562
}
562563

563-
return (
564-
<div className="flex flex-wrap gap-1">
565-
{excludedModels.map((model) => (
564+
if (excludedModels.length === 1) {
565+
return (
566+
<div className="flex flex-wrap gap-1">
566567
<Badge
567-
key={model}
568+
key={excludedModels[0]}
568569
variant="destructive"
569570
className="max-w-[220px] truncate text-xs"
570571
title={t('channel.highErrorRateExcluded')}
571572
>
572-
{model}
573+
{excludedModels[0]}
573574
</Badge>
574-
))}
575-
</div>
575+
</div>
576+
)
577+
}
578+
579+
return (
580+
<Popover>
581+
<PopoverTrigger asChild>
582+
<button
583+
type="button"
584+
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-red-300/70 bg-red-50 px-2.5 py-1 text-sm text-red-700 transition-colors hover:border-red-400 hover:bg-red-100 hover:text-red-800 dark:border-red-800/70 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-900/40"
585+
title={t('channel.highErrorRateExcluded')}
586+
>
587+
<span className="whitespace-nowrap">
588+
{excludedModels.length} {t("channel.modelsCount")}
589+
</span>
590+
<ChevronDown className="h-3.5 w-3.5" />
591+
</button>
592+
</PopoverTrigger>
593+
<PopoverContent className="w-auto max-w-sm p-3" align="start">
594+
<div className="space-y-2">
595+
<h4 className="font-medium text-sm">
596+
{t("channel.temporarilyExcludedModels")} ({excludedModels.length})
597+
</h4>
598+
<div className="flex max-h-64 flex-col gap-1 overflow-y-auto">
599+
{excludedModels.map((model) => (
600+
<button
601+
key={model}
602+
type="button"
603+
className="w-full cursor-pointer rounded-md border border-red-300/70 bg-red-50 px-2 py-1 text-left text-xs text-red-700 transition-colors hover:border-red-400 hover:bg-red-100 hover:text-red-800 dark:border-red-800/70 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-900/40"
604+
title={t('channel.highErrorRateExcluded')}
605+
onClick={() => openUpdateDialog(row.original)}
606+
>
607+
<span className="block truncate">{model}</span>
608+
</button>
609+
))}
610+
</div>
611+
</div>
612+
</PopoverContent>
613+
</Popover>
576614
)
577615
},
578616
},

0 commit comments

Comments
 (0)