feat(channels): 渠道停用、启用功能
This commit is contained in:
1 parent
0183be8fa0
commit
1243bf629d
11 files changed
+233
-10
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SwitchProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, onCheckedChange, disabled, onChange, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex items-center',
|
||||
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
ref={ref}
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
onChange?.(event)
|
||||
onCheckedChange?.(event.target.checked)
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-6 w-11 shrink-0 rounded-full border border-transparent bg-muted transition-colors duration-200',
|
||||
'after:absolute after:left-0.5 after:top-0.5 after:h-4.5 after:w-4.5 after:rounded-full after:bg-background after:shadow-md after:transition-transform after:duration-200',
|
||||
'peer-checked:bg-emerald-500 peer-checked:after:translate-x-5',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-primary/30 peer-focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Switch.displayName = 'Switch'
|
||||
|
||||
export { Switch }
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
@@ -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<Channel[]>(['channels'])
|
||||
|
||||
queryClient.setQueryData<Channel[]>(['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({
|
||||
<Card>
|
||||
<div className="divide-y">
|
||||
{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 (
|
||||
<div key={channel.key} className="p-4 hover:bg-muted/30 transition-colors">
|
||||
@@ -876,6 +944,25 @@ export function Channels({
|
||||
</span>
|
||||
<span className="text-muted-foreground">{modelCount} 个模型</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">运行状态</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 text-xs font-medium',
|
||||
isEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400',
|
||||
)}
|
||||
>
|
||||
{isEnabled ? '已启用' : '已停用'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={isToggling}
|
||||
onCheckedChange={(checked) => handleToggleEnabled(channel, checked)}
|
||||
aria-label={`${config.name || channel.key} 渠道启用开关`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex md:items-center md:gap-4">
|
||||
@@ -883,7 +970,7 @@ export function Channels({
|
||||
<div className="font-medium">{config.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{channel.key}</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<span className="px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs font-medium whitespace-nowrap">
|
||||
{getTypeLabel(config.type)}
|
||||
</span>
|
||||
@@ -894,6 +981,22 @@ export function Channels({
|
||||
<div className="w-20 text-sm text-center flex-shrink-0">
|
||||
<span className="text-muted-foreground">{modelCount} 模型</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium whitespace-nowrap',
|
||||
isEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400',
|
||||
)}
|
||||
>
|
||||
{isEnabled ? '已启用' : '已停用'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={isToggling}
|
||||
onCheckedChange={(checked) => handleToggleEnabled(channel, checked)}
|
||||
aria-label={`${config.name || channel.key} 渠道启用开关`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(channel)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
@@ -988,6 +1091,33 @@ export function Channels({
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-3 md:col-span-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label className="text-sm">渠道状态</Label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
停用后不会参与请求选路,也不会出现在可用模型列表中。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
formData.enabled !== false
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-amber-600 dark:text-amber-400',
|
||||
)}
|
||||
>
|
||||
{formData.enabled !== false ? '已启用' : '已停用'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={formData.enabled !== false}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||
aria-label="渠道状态开关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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"}]}'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<UsageLogPageCacheSnapshot>(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<UsageLogFilterState>(
|
||||
() => cachedLogsSnapshot?.data.draftFilters ?? defaultFilters,
|
||||
() => initialDraftFilters,
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<UsageLogFilterState>(
|
||||
() => 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<AnalyticsEventItem | null>(null);
|
||||
const initialLogsData =
|
||||
cachedLogsSnapshot?.data &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<ChannelConfig, "api_keys" | "auto_retry" | "auto_rotate" | "models" | "supported_models" | "deployment_mapper"> & {
|
||||
export type NormalizedChannelConfig = Omit<ChannelConfig, "enabled" | "api_keys" | "auto_retry" | "auto_rotate" | "models" | "supported_models" | "deployment_mapper"> & {
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user