From 1243bf629d50d265e08ff2f06731587af0d17d2f Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Sat, 11 Apr 2026 23:23:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=B8=A0=E9=81=93=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E3=80=81=E5=90=AF=E7=94=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ui/switch.tsx | 45 ++++++++ frontend/src/lib/channel-models.ts | 7 ++ frontend/src/pages/Channels.tsx | 140 ++++++++++++++++++++++- frontend/src/pages/UsageLogs.tsx | 27 ++++- frontend/src/types/index.ts | 1 + src/admin/channel_api.ts | 2 + src/channel-config.ts | 6 +- src/constants.ts | 2 +- src/providers/models.ts | 3 + src/providers/shared/channel-resolver.ts | 9 ++ type.d.ts | 1 + 11 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/ui/switch.tsx diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..75b6984 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface SwitchProps + extends Omit, 'type' | 'onChange'> { + onCheckedChange?: (checked: boolean) => void +} + +const Switch = React.forwardRef( + ({ className, onCheckedChange, disabled, onChange, ...props }, ref) => { + return ( + + ) + } +) + +Switch.displayName = 'Switch' + +export { Switch } diff --git a/frontend/src/lib/channel-models.ts b/frontend/src/lib/channel-models.ts index 51099fb..d45ae89 100644 --- a/frontend/src/lib/channel-models.ts +++ b/frontend/src/lib/channel-models.ts @@ -29,6 +29,10 @@ export const parseChannelConfig = (channel: Channel): ChannelConfig => { } } +export const isChannelEnabled = (config: ChannelConfig): boolean => { + return config.enabled !== false +} + export const parseTokenConfig = (token: Token): TokenConfig => { if (typeof token.value !== 'string') { return token.value @@ -92,6 +96,9 @@ export const getUniqueModelNamesFromChannels = (channels: Channel[]): string[] = channels.forEach((channel) => { const config = parseChannelConfig(channel) + if (!isChannelEnabled(config)) { + return + } getChannelModels(config).forEach((model) => modelNames.add(model.name)) }) diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index c336802..9c1a58f 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label' import { Select } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' import { cn } from '@/lib/utils' import { @@ -54,6 +55,7 @@ const createDefaultChannelFormData = (): ChannelConfig => ({ name: '', type: 'openai', endpoint: '', + enabled: true, api_keys: [], auto_retry: true, auto_rotate: true, @@ -150,6 +152,7 @@ const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => { return { ...config, api_key: undefined, + enabled: config.enabled ?? true, api_keys: mergedKeys, auto_retry: config.auto_retry ?? true, auto_rotate: config.auto_rotate ?? true, @@ -159,6 +162,22 @@ const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => { } } +const parseChannelValue = (channel: Channel): ChannelConfig => { + if (typeof channel.value !== 'string') { + return channel.value + } + + try { + return JSON.parse(channel.value) as ChannelConfig + } catch { + return createDefaultChannelFormData() + } +} + +const buildChannelValue = (source: Channel['value'], config: ChannelConfig): Channel['value'] => { + return typeof source === 'string' ? JSON.stringify(config) : config +} + const isEmptyModelRow = (row: ModelRow): boolean => { return !row.id.trim() && !row.name.trim() } @@ -458,7 +477,7 @@ export function Channels({ const openChannelForEdit = useCallback((channel: Channel) => { setEditingKey(channel.key) setChannelKey(channel.key) - const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value + const rawConfig = parseChannelValue(channel) const normalizedConfig = loadFormConfig(rawConfig) setJsonValue(JSON.stringify(normalizedConfig, null, 2)) setView('form') @@ -505,6 +524,49 @@ export function Channels({ }, }) + const toggleEnabledMutation = useMutation({ + mutationFn: async ({ channel, enabled }: { channel: Channel; enabled: boolean }) => { + const config = normalizeChannelFormConfig(parseChannelValue(channel)) + return apiClient.saveChannel(channel.key, { ...config, enabled }) + }, + onMutate: async ({ channel, enabled }) => { + await queryClient.cancelQueries({ queryKey: ['channels'] }) + const previousChannels = queryClient.getQueryData(['channels']) + + queryClient.setQueryData(['channels'], (current) => { + if (!current) { + return current + } + + return current.map((item) => { + if (item.key !== channel.key) { + return item + } + + const config = normalizeChannelFormConfig(parseChannelValue(item)) + return { + ...item, + value: buildChannelValue(item.value, { ...config, enabled }), + } + }) + }) + + return { previousChannels } + }, + onSuccess: (_, { enabled }) => { + addToast(enabled ? '渠道已启用' : '渠道已停用', 'success') + }, + onError: (error: Error, _variables, context) => { + if (context?.previousChannels) { + queryClient.setQueryData(['channels'], context.previousChannels) + } + addToast('更新渠道状态失败:' + error.message, 'error') + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['channels'] }) + }, + }) + const fetchModelsMutation = useMutation({ mutationFn: async (config: ChannelConfig) => { return apiClient.fetchChannelModels(config) @@ -588,6 +650,10 @@ export function Channels({ } } + const handleToggleEnabled = (channel: Channel, enabled: boolean) => { + toggleEnabledMutation.mutate({ channel, enabled }) + } + const handleFetchModels = () => { const apiKeys = parseApiKeys(apiKeysInput) @@ -757,7 +823,7 @@ export function Channels({ const filteredData = data?.filter((channel) => { if (!searchQuery) return true - const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value + const rawConfig = parseChannelValue(channel) const config = normalizeChannelFormConfig(rawConfig) return ( config.name?.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -825,10 +891,12 @@ export function Channels({
{filteredData?.map((channel) => { - const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value + const rawConfig = parseChannelValue(channel) const config = normalizeChannelFormConfig(rawConfig) const modelCount = (config.models || []).length const isMenuOpen = openMenu === channel.key + const isEnabled = config.enabled !== false + const isToggling = toggleEnabledMutation.isPending && toggleEnabledMutation.variables?.channel.key === channel.key return (
@@ -876,6 +944,25 @@ export function Channels({ {modelCount} 个模型
+
+
+
运行状态
+
+ {isEnabled ? '已启用' : '已停用'} +
+
+ handleToggleEnabled(channel, checked)} + aria-label={`${config.name || channel.key} 渠道启用开关`} + /> +
@@ -883,7 +970,7 @@ export function Channels({
{config.name}
{channel.key}
-
+
{getTypeLabel(config.type)} @@ -894,6 +981,22 @@ export function Channels({
{modelCount} 模型
+
+ + {isEnabled ? '已启用' : '已停用'} + + handleToggleEnabled(channel, checked)} + aria-label={`${config.name || channel.key} 渠道启用开关`} + /> +
+
+
+
+ +

+ 停用后不会参与请求选路,也不会出现在可用模型列表中。 +

+
+
+ + {formData.enabled !== false ? '已启用' : '已停用'} + + setFormData({ ...formData, enabled: checked })} + aria-label="渠道状态开关" + /> +
+
+
@@ -1150,7 +1280,7 @@ export function Channels({ onChange={(e) => setJsonValue(e.target.value)} rows={18} className="font-mono text-sm" - placeholder='{"name":"Azure OpenAI","type":"azure-openai","endpoint":"https://example.openai.azure.com/","api_keys":["sk-1","sk-2"],"auto_retry":true,"auto_rotate":true,"models":[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini"}]}' + placeholder='{"name":"Azure OpenAI","type":"azure-openai","endpoint":"https://example.openai.azure.com/","enabled":true,"api_keys":["sk-1","sk-2"],"auto_retry":true,"auto_rotate":true,"models":[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini"}]}' /> diff --git a/frontend/src/pages/UsageLogs.tsx b/frontend/src/pages/UsageLogs.tsx index 2973b80..d1fa59c 100644 --- a/frontend/src/pages/UsageLogs.tsx +++ b/frontend/src/pages/UsageLogs.tsx @@ -75,6 +75,19 @@ const createFilterPreset = (hoursBack: number): UsageLogFilterState => { }; }; +const hydrateUsageLogFilters = ( + filters: UsageLogFilterState | undefined, + latestWindow: UsageLogFilterState, +): UsageLogFilterState => { + return { + start: latestWindow.start, + end: latestWindow.end, + dimension: filters?.dimension ?? latestWindow.dimension, + keyword: filters?.keyword ?? latestWindow.keyword, + result: filters?.result ?? latestWindow.result, + }; +}; + const formatDateTime = (value: string): string => { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -157,13 +170,21 @@ const DetailField = ({ label, value, mono = false }: { label: string; value: str export function UsageLogs() { const defaultFilters = useMemo(() => createFilterPreset(24), []); const cachedLogsSnapshot = useMemo(() => readScopedCache(USAGE_LOGS_PAGE_CACHE_KEY), []); + const initialDraftFilters = useMemo( + () => hydrateUsageLogFilters(cachedLogsSnapshot?.data.draftFilters, defaultFilters), + [cachedLogsSnapshot, defaultFilters], + ); + const initialAppliedFilters = useMemo( + () => hydrateUsageLogFilters(cachedLogsSnapshot?.data.appliedFilters, defaultFilters), + [cachedLogsSnapshot, defaultFilters], + ); const [draftFilters, setDraftFilters] = useState( - () => cachedLogsSnapshot?.data.draftFilters ?? defaultFilters, + () => initialDraftFilters, ); const [appliedFilters, setAppliedFilters] = useState( - () => cachedLogsSnapshot?.data.appliedFilters ?? defaultFilters, + () => initialAppliedFilters, ); - const [currentPage, setCurrentPage] = useState(() => Math.max(cachedLogsSnapshot?.data.currentPage ?? 1, 1)); + const [currentPage, setCurrentPage] = useState(1); const [selectedItem, setSelectedItem] = useState(null); const initialLogsData = cachedLogsSnapshot?.data && diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b3b9ed7..b55d106 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -13,6 +13,7 @@ export interface ChannelConfig { name: string type: 'openai' | 'gemini' | 'azure-openai' | 'openai-audio' | 'azure-openai-audio' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses' endpoint: string + enabled?: boolean api_key?: string api_keys?: string[] auto_retry?: boolean diff --git a/src/admin/channel_api.ts b/src/admin/channel_api.ts index 094998e..76046ee 100644 --- a/src/admin/channel_api.ts +++ b/src/admin/channel_api.ts @@ -181,6 +181,7 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute { name: z.string().describe('Channel name'), type: z.string().describe('Channel type'), endpoint: z.string().describe('API endpoint'), + enabled: z.boolean().optional().describe('Whether this channel participates in request routing'), api_key: z.string().optional().describe('Deprecated single API key'), api_keys: z.array(z.string()).optional().describe('API keys, one request will pick one randomly'), auto_retry: z.boolean().optional().describe('Automatically retry the same API key'), @@ -252,6 +253,7 @@ export class ChannelFetchModelsEndpoint extends OpenAPIRoute { name: z.string().optional(), type: z.string().optional(), endpoint: z.string().optional(), + enabled: z.boolean().optional(), api_key: z.string().optional(), api_keys: z.array(z.string()).optional(), auto_retry: z.boolean().optional(), diff --git a/src/channel-config.ts b/src/channel-config.ts index 65759ce..b70091e 100644 --- a/src/channel-config.ts +++ b/src/channel-config.ts @@ -1,10 +1,12 @@ export const DEFAULT_CHANNEL_AUTO_RETRY = true; export const DEFAULT_CHANNEL_AUTO_ROTATE = true; +export const DEFAULT_CHANNEL_ENABLED = true; export const DEFAULT_CLAUDE_API_VERSION = "2023-06-01"; export const MAX_RETRIES_PER_KEY = 3; export const MAX_ROTATION_ATTEMPTS = 3; -export type NormalizedChannelConfig = Omit & { +export type NormalizedChannelConfig = Omit & { + enabled: boolean; api_keys: string[]; auto_retry: boolean; auto_rotate: boolean; @@ -105,6 +107,7 @@ export const normalizeChannelConfig = ( name: config.name || "", type: config.type, endpoint: config.endpoint || "", + enabled: config.enabled ?? DEFAULT_CHANNEL_ENABLED, api_key: typeof config.api_key === "string" ? config.api_key.trim() : undefined, api_keys: normalizeApiKeys(config), auto_retry: config.auto_retry ?? DEFAULT_CHANNEL_AUTO_RETRY, @@ -125,6 +128,7 @@ export const sanitizeChannelConfig = ( name: normalized.name, type: normalized.type, endpoint: normalized.endpoint, + enabled: normalized.enabled, api_keys: normalized.api_keys, auto_retry: normalized.auto_retry, auto_rotate: normalized.auto_rotate, diff --git a/src/constants.ts b/src/constants.ts index def1360..43fca86 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,6 @@ export const CONSTANTS = { VERSION: "v0.0.1", DB_VERSION_KEY: "db_version", - DB_VERSION: "v0.0.3", + DB_VERSION: "v0.0.4", MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG", } diff --git a/src/providers/models.ts b/src/providers/models.ts index f2c8d0f..2fa88aa 100644 --- a/src/providers/models.ts +++ b/src/providers/models.ts @@ -59,6 +59,9 @@ export class ModelsEndpoint extends OpenAPIRoute { for (const row of channelsResult.results) { const config = normalizeChannelConfig(JSON.parse(row.value) as ChannelConfig); + if (!config.enabled) { + continue; + } for (const model of getChannelModels(config)) { modelsSet.add(model.name); } diff --git a/src/providers/shared/channel-resolver.ts b/src/providers/shared/channel-resolver.ts index 8f72b1b..1384c21 100644 --- a/src/providers/shared/channel-resolver.ts +++ b/src/providers/shared/channel-resolver.ts @@ -60,6 +60,7 @@ export const resolveChannel = async ( const allowedTypes = policy.allowedTypes; const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: ChannelModelMapping }> = []; + let hasDisabledMatchingChannel = false; for (const row of channelsResult.results) { const config = (() => { @@ -83,6 +84,11 @@ export const resolveChannel = async ( continue; } + if (!config.enabled) { + hasDisabledMatchingChannel = true; + continue; + } + availableChannels.push({ key: row.key, config: config, @@ -91,6 +97,9 @@ export const resolveChannel = async ( } if (availableChannels.length === 0) { + if (hasDisabledMatchingChannel) { + return c.text(`No enabled channels available for model: ${requestedModel}. Please enable a channel first.`, 503); + } return c.text(`Model not supported: ${requestedModel}. Please configure models.`, 400); } diff --git a/type.d.ts b/type.d.ts index 07ac46c..b7ff60f 100644 --- a/type.d.ts +++ b/type.d.ts @@ -70,6 +70,7 @@ type ChannelConfig = { name: string; type: ChannelType; endpoint: string; + enabled?: boolean; api_key?: string; api_keys?: string[]; auto_retry?: boolean;