From 84cc346ebd00a1752651b7a533ac4df2323617cf Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Thu, 9 Apr 2026 11:56:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=98=A0=E5=B0=84=E5=92=8C=E8=8E=B7=E5=8F=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9A=84=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ChannelConfig 中添加 models 字段以支持模型映射 - 新增 ChannelFetchModelsEndpoint 用于获取上游模型列表 - 更新 normalizeChannelConfig 函数以处理新模型结构 - 修改相关的数据库操作以适应新的配置结构 - 引入 buildPrefixedTargetUrl 函数以简化 URL 构建 - 更新 utils 函数以支持模型映射查找 --- frontend/src/api/client.ts | 6 + frontend/src/pages/Channels.tsx | 941 ++++++++++++-------- frontend/src/types/index.ts | 10 +- src/admin/channel_api.ts | 190 +++- src/admin/index.ts | 3 +- src/channel-config.ts | 74 +- src/constants.ts | 2 +- src/db/index.ts | 5 +- src/providers/claude-to-openai-proxy.ts | 3 +- src/providers/models.ts | 6 +- src/providers/openai-proxy.ts | 5 +- src/providers/openai-responses-proxy.ts | 5 +- src/providers/shared/azure-target-url.ts | 9 +- src/providers/shared/channel-resolver.ts | 36 +- src/providers/shared/prefixed-target-url.ts | 36 + src/utils.ts | 36 +- type.d.ts | 8 +- 17 files changed, 951 insertions(+), 424 deletions(-) create mode 100644 src/providers/shared/prefixed-target-url.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7aee679..3421b28 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -161,6 +161,12 @@ export const apiClient = { body: JSON.stringify(config), }), + fetchChannelModels: (config: unknown) => + request('/api/admin/channel/models/fetch', { + method: 'POST', + body: JSON.stringify(config), + }), + deleteChannel: (key: string) => request(`/api/admin/channel/${encodeURIComponent(key)}`, { method: 'DELETE' }), diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 18eab00..14d85ce 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { apiClient } from '@/api/client' -import { Channel, ChannelConfig } from '@/types' +import { Channel, ChannelConfig, ChannelModelMapping } from '@/types' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -23,13 +23,14 @@ import { Check, MoreHorizontal, Search, - ArrowRight, Globe, Cpu, } from 'lucide-react' import { PageContainer } from '@/components/ui/page-container' type EditMode = 'form' | 'json' +type ModelEditorMode = 'visual' | 'json' +type ModelRow = { id: string; name: string } const channelTypes = [ { value: 'azure-openai', label: 'Azure OpenAI' }, @@ -42,6 +43,11 @@ const channelTypes = [ { value: 'azure-openai-responses', label: 'Azure OpenAI Responses' }, ] +const createEmptyModelRow = (): ModelRow => ({ + id: '', + name: '', +}) + const createDefaultChannelFormData = (): ChannelConfig => ({ name: '', type: 'azure-openai', @@ -49,8 +55,7 @@ const createDefaultChannelFormData = (): ChannelConfig => ({ api_keys: [], auto_retry: true, auto_rotate: true, - supported_models: [], - deployment_mapper: {}, + models: [], }) const parseApiKeys = (value: string): string[] => { @@ -73,10 +78,72 @@ const formatApiKeys = (apiKeys?: string[]): string => { return (apiKeys || []).join('\n') } +const normalizeModels = (models?: ChannelModelMapping[]): ChannelModelMapping[] => { + if (!Array.isArray(models)) { + return [] + } + + return models + .map((model) => ({ + id: typeof model?.id === 'string' ? model.id.trim() : '', + name: typeof model?.name === 'string' ? model.name.trim() : '', + })) + .filter((model) => model.id.length > 0) + .map((model) => ({ + id: model.id, + name: model.name || model.id, + })) +} + +const getChannelModels = (config: ChannelConfig): ChannelModelMapping[] => { + const normalizedModels = normalizeModels(config.models) + if (normalizedModels.length > 0) { + return normalizedModels + } + + const deploymentMapper = config.deployment_mapper || {} + const supportedModels = Array.isArray(config.supported_models) ? config.supported_models : [] + const legacyModels: ChannelModelMapping[] = [] + const seenNames = new Set() + + const pushModel = (id: string, name?: string) => { + const normalizedId = id.trim() + const normalizedName = (name || id).trim() + + if (!normalizedId || !normalizedName || seenNames.has(normalizedName)) { + return + } + + seenNames.add(normalizedName) + legacyModels.push({ + id: normalizedId, + name: normalizedName, + }) + } + + supportedModels.forEach((modelName) => { + const normalizedName = typeof modelName === 'string' ? modelName.trim() : '' + if (!normalizedName) { + return + } + pushModel(deploymentMapper[normalizedName] || normalizedName, normalizedName) + }) + + Object.entries(deploymentMapper).forEach(([modelName, modelId]) => { + if (typeof modelId !== 'string') { + return + } + pushModel(modelId, modelName) + }) + + return legacyModels +} + const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => { const legacyApiKey = typeof config.api_key === 'string' ? config.api_key.trim() : '' const rawApiKeys = Array.isArray(config.api_keys) ? config.api_keys : [] const mergedKeys = parseApiKeys([legacyApiKey, ...rawApiKeys].join('\n')) + const models = getChannelModels(config) return { ...config, @@ -84,27 +151,149 @@ const normalizeChannelFormConfig = (config: ChannelConfig): ChannelConfig => { api_keys: mergedKeys, auto_retry: config.auto_retry ?? true, auto_rotate: config.auto_rotate ?? true, - supported_models: Array.isArray(config.supported_models) ? config.supported_models : [], - deployment_mapper: config.deployment_mapper || {}, + models, + supported_models: undefined, + deployment_mapper: undefined, } } +const isEmptyModelRow = (row: ModelRow): boolean => { + return !row.id.trim() && !row.name.trim() +} + +const ensureTrailingEmptyModelRow = (rows: ModelRow[]): ModelRow[] => { + const meaningfulRows = rows.filter((row) => !isEmptyModelRow(row)) + return [...meaningfulRows, createEmptyModelRow()] +} + +const buildRowsFromModels = (models: ChannelModelMapping[]): ModelRow[] => { + return ensureTrailingEmptyModelRow(models.map((model) => ({ id: model.id, name: model.name }))) +} + +const serializeModels = (models: ChannelModelMapping[]): string => { + return JSON.stringify(models, null, 2) +} + +const validateModels = (models: ChannelModelMapping[]): string | null => { + if (models.length === 0) { + return '请至少配置一个模型' + } + + const names = new Set() + for (const model of models) { + if (names.has(model.name)) { + return `模型名称重复:${model.name}` + } + names.add(model.name) + } + + return null +} + +const parseModelsFromRows = (rows: ModelRow[]): { models: ChannelModelMapping[]; error?: string } => { + const activeRows = rows.filter((row) => !isEmptyModelRow(row)) + + for (const row of activeRows) { + if (!row.id.trim()) { + return { models: [], error: '模型 ID 不能为空' } + } + } + + const models = activeRows.map((row) => { + const id = row.id.trim() + const name = row.name.trim() || id + return { id, name } + }) + + const validationError = validateModels(models) + if (validationError) { + return { models: [], error: validationError } + } + + return { models } +} + +const parseModelsFromJson = (value: string): { models: ChannelModelMapping[]; error?: string } => { + let parsed: unknown + + try { + parsed = JSON.parse(value) + } catch { + return { models: [], error: '模型 JSON 格式错误' } + } + + if (!Array.isArray(parsed)) { + return { models: [], error: '模型 JSON 必须是数组' } + } + + const models: ChannelModelMapping[] = [] + + for (const item of parsed) { + if (!item || typeof item !== 'object') { + return { models: [], error: '模型 JSON 项必须是对象' } + } + + const id = typeof (item as ChannelModelMapping).id === 'string' ? (item as ChannelModelMapping).id.trim() : '' + const rawName = typeof (item as ChannelModelMapping).name === 'string' ? (item as ChannelModelMapping).name.trim() : '' + + if (!id) { + return { models: [], error: '模型 JSON 中的 id 不能为空' } + } + + models.push({ + id, + name: rawName || id, + }) + } + + const validationError = validateModels(models) + if (validationError) { + return { models: [], error: validationError } + } + + return { models } +} + export function Channels() { const [view, setView] = useState<'list' | 'form'>('list') const [editMode, setEditMode] = useState('form') + const [modelEditorMode, setModelEditorMode] = useState('visual') const [editingKey, setEditingKey] = useState(null) const [formData, setFormData] = useState(createDefaultChannelFormData()) const [channelKey, setChannelKey] = useState('') const [jsonValue, setJsonValue] = useState('') const [apiKeysInput, setApiKeysInput] = useState('') - const [supportedModelRows, setSupportedModelRows] = useState>([]) - const [mapperRows, setMapperRows] = useState>([]) + const [modelRows, setModelRows] = useState([createEmptyModelRow()]) + const [modelJsonValue, setModelJsonValue] = useState('[]') const [searchQuery, setSearchQuery] = useState('') const [openMenu, setOpenMenu] = useState(null) const { addToast } = useToast() const queryClient = useQueryClient() + const applyModels = (models: ChannelModelMapping[]) => { + const normalizedModels = normalizeModels(models) + setFormData((prev) => ({ ...prev, models: normalizedModels })) + setModelRows(buildRowsFromModels(normalizedModels)) + setModelJsonValue(serializeModels(normalizedModels)) + } + + const resolveCurrentModels = (): { models: ChannelModelMapping[]; error?: string } => { + return modelEditorMode === 'visual' + ? parseModelsFromRows(modelRows) + : parseModelsFromJson(modelJsonValue) + } + + const loadFormConfig = (config: ChannelConfig) => { + const normalizedConfig = normalizeChannelFormConfig(config) + setFormData(normalizedConfig) + setApiKeysInput(formatApiKeys(normalizedConfig.api_keys)) + setModelRows(buildRowsFromModels(normalizedConfig.models || [])) + setModelJsonValue(serializeModels(normalizedConfig.models || [])) + setModelEditorMode('visual') + return normalizedConfig + } + const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: ['channels'], queryFn: async () => { @@ -120,7 +309,7 @@ export function Channels() { }, []) const saveMutation = useMutation({ - mutationFn: async ({ key, config }: { key: string; config: any }) => { + mutationFn: async ({ key, config }: { key: string; config: ChannelConfig }) => { return apiClient.saveChannel(key, config) }, onSuccess: () => { @@ -129,7 +318,7 @@ export function Channels() { resetForm() setView('list') }, - onError: (error: any) => { + onError: (error: Error) => { addToast('保存失败:' + error.message, 'error') }, }) @@ -142,18 +331,36 @@ export function Channels() { queryClient.invalidateQueries({ queryKey: ['channels'] }) addToast('渠道已删除', 'success') }, - onError: (error: any) => { + onError: (error: Error) => { addToast('删除失败:' + error.message, 'error') }, }) + const fetchModelsMutation = useMutation({ + mutationFn: async (config: ChannelConfig) => { + return apiClient.fetchChannelModels(config) + }, + onSuccess: (response) => { + const fetchedModels = ((response.data as ChannelModelMapping[]) || []).map((model) => ({ + id: model.id, + name: model.id, + })) + applyModels(fetchedModels) + addToast(`已获取 ${fetchedModels.length} 个模型`, 'success') + }, + onError: (error: Error) => { + addToast('获取模型失败:' + error.message, 'error') + }, + }) + const resetForm = () => { setFormData(createDefaultChannelFormData()) setChannelKey('') setJsonValue('') setApiKeysInput('') - setSupportedModelRows([]) - setMapperRows([]) + setModelRows([createEmptyModelRow()]) + setModelJsonValue('[]') + setModelEditorMode('visual') setEditingKey(null) setEditMode('form') } @@ -167,75 +374,71 @@ export function Channels() { setEditingKey(channel.key) setChannelKey(channel.key) const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value - const config = normalizeChannelFormConfig(rawConfig) - setFormData(config) - setJsonValue(JSON.stringify(config, null, 2)) - setApiKeysInput(formatApiKeys(config.api_keys)) - setSupportedModelRows((config.supported_models || []).map((model) => ({ model }))) - const rows = Object.entries(config.deployment_mapper || {}).map(([request, deployment]) => ({ - request, - deployment: deployment as string, - })) - setMapperRows(rows) + const normalizedConfig = loadFormConfig(rawConfig) + setJsonValue(JSON.stringify(normalizedConfig, null, 2)) setView('form') } const handleDelete = (key: string) => { - if (confirm(`确定要删除此渠道吗?`)) { + if (confirm('确定要删除此渠道吗?')) { deleteMutation.mutate(key) } } + const handleFetchModels = () => { + const apiKeys = parseApiKeys(apiKeysInput) + + if (!formData.type || !formData.endpoint || apiKeys.length === 0) { + addToast('请先填写渠道类型、API 端点和 API 密钥', 'error') + return + } + + const config = normalizeChannelFormConfig({ + ...formData, + api_keys: apiKeys, + }) + + fetchModelsMutation.mutate(config) + } + const handleSave = () => { if (!channelKey) { addToast('请填写渠道标识', 'error') return } - const buildFormConfig = () => { - const deployment_mapper: Record = {} - mapperRows.forEach((row) => { - if (row.request && row.deployment) { - deployment_mapper[row.request] = row.deployment - } - }) + const apiKeys = parseApiKeys(apiKeysInput) + const modelResult = resolveCurrentModels() - const supported_models = supportedModelRows - .map((row) => row.model.trim()) - .filter((model) => model.length > 0) - - return normalizeChannelFormConfig({ - ...formData, - api_key: undefined, - api_keys: parseApiKeys(apiKeysInput), - supported_models, - deployment_mapper, - }) + if (modelResult.error) { + addToast(modelResult.error, 'error') + return } - let config: any + if (!formData.name || !formData.endpoint || apiKeys.length === 0) { + addToast('请填写所有必填字段', 'error') + return + } + + let config: ChannelConfig if (editMode === 'form') { - const apiKeys = parseApiKeys(apiKeysInput) - const supportedModels = supportedModelRows - .map((row) => row.model.trim()) - .filter((model) => model.length > 0) - - if (!formData.name || !formData.endpoint || apiKeys.length === 0) { - addToast('请填写所有必填字段', 'error') - return - } - - if (supportedModels.length === 0) { - addToast('请至少填写一个支持模型', 'error') - return - } - - config = buildFormConfig() + config = normalizeChannelFormConfig({ + ...formData, + api_key: undefined, + api_keys: apiKeys, + models: modelResult.models, + }) } else { try { config = normalizeChannelFormConfig(JSON.parse(jsonValue)) } catch { - addToast('JSON格式错误', 'error') + addToast('JSON 格式错误', 'error') + return + } + + const validationError = validateModels(config.models || []) + if (validationError) { + addToast(validationError, 'error') return } } @@ -245,84 +448,120 @@ export function Channels() { const toggleEditMode = () => { if (editMode === 'form') { - const deployment_mapper: Record = {} - mapperRows.forEach((row) => { - if (row.request && row.deployment) { - deployment_mapper[row.request] = row.deployment - } - }) - const supported_models = supportedModelRows - .map((row) => row.model.trim()) - .filter((model) => model.length > 0) + const modelResult = resolveCurrentModels() + if (modelResult.error) { + addToast(modelResult.error, 'error') + return + } + const config = normalizeChannelFormConfig({ ...formData, api_key: undefined, api_keys: parseApiKeys(apiKeysInput), - supported_models, - deployment_mapper, + models: modelResult.models, }) setJsonValue(JSON.stringify(config, null, 2)) setEditMode('json') - } else { - try { - const config = normalizeChannelFormConfig(JSON.parse(jsonValue)) - setFormData(config) - setApiKeysInput(formatApiKeys(config.api_keys)) - setSupportedModelRows((config.supported_models || []).map((model: string) => ({ model }))) - const rows = Object.entries(config.deployment_mapper || {}).map(([request, deployment]) => ({ - request, - deployment: deployment as string, - })) - setMapperRows(rows) - setEditMode('form') - } catch { - addToast('JSON格式错误', 'error') + return + } + + try { + const config = normalizeChannelFormConfig(JSON.parse(jsonValue)) + const validationError = validateModels(config.models || []) + if (validationError) { + addToast(validationError, 'error') + return } + + loadFormConfig(config) + setEditMode('form') + } catch { + addToast('JSON 格式错误', 'error') } } - const addMapperRow = () => { - setMapperRows([...mapperRows, { request: '', deployment: '' }]) + const toggleModelEditorMode = () => { + if (modelEditorMode === 'visual') { + const result = parseModelsFromRows(modelRows) + if (result.error) { + addToast(result.error, 'error') + return + } + + setModelJsonValue(serializeModels(result.models)) + setModelEditorMode('json') + return + } + + const result = parseModelsFromJson(modelJsonValue) + if (result.error) { + addToast(result.error, 'error') + return + } + + applyModels(result.models) + setModelEditorMode('visual') } - const addSupportedModelRow = () => { - setSupportedModelRows([...supportedModelRows, { model: '' }]) + const setModelEditorModeWithSync = (nextMode: ModelEditorMode) => { + if (nextMode === modelEditorMode) { + return + } + + toggleModelEditorMode() } - const removeSupportedModelRow = (index: number) => { - setSupportedModelRows(supportedModelRows.filter((_, i) => i !== index)) + const updateModelRowsState = (nextRows: ModelRow[]) => { + const normalizedRows = ensureTrailingEmptyModelRow(nextRows) + setModelRows(normalizedRows) + const result = parseModelsFromRows(normalizedRows) + if (!result.error) { + setFormData((prev) => ({ ...prev, models: result.models })) + setModelJsonValue(serializeModels(result.models)) + } } - const updateSupportedModelRow = (index: number, value: string) => { - const newRows = [...supportedModelRows] - newRows[index].model = value - setSupportedModelRows(newRows) + const updateModelRow = (index: number, field: keyof ModelRow, value: string) => { + const nextRows = [...modelRows] + const previousRow = nextRows[index] || createEmptyModelRow() + const nextRow = { + ...previousRow, + [field]: value, + } + + if (field === 'id' && (!previousRow.name.trim() || previousRow.name.trim() === previousRow.id.trim())) { + nextRow.name = value + } + + nextRows[index] = nextRow + updateModelRowsState(nextRows) } - const removeMapperRow = (index: number) => { - setMapperRows(mapperRows.filter((_, i) => i !== index)) - } + const removeModelRow = (index: number) => { + const meaningfulRows = modelRows.filter((row) => !isEmptyModelRow(row)) + if (meaningfulRows.length === 0) { + updateModelRowsState([createEmptyModelRow()]) + return + } - const updateMapperRow = (index: number, field: 'request' | 'deployment', value: string) => { - const newRows = [...mapperRows] - newRows[index][field] = value - setMapperRows(newRows) + const nextRows = modelRows.filter((_, rowIndex) => rowIndex !== index) + updateModelRowsState(nextRows) } const getTypeLabel = (type: string) => { - return channelTypes.find((t) => t.value === type)?.label || type + return channelTypes.find((item) => item.value === type)?.label || type } const filteredData = data?.filter((channel) => { if (!searchQuery) return true - const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value + const rawConfig = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value + const config = normalizeChannelFormConfig(rawConfig) return ( config.name?.toLowerCase().includes(searchQuery.toLowerCase()) || channel.key.toLowerCase().includes(searchQuery.toLowerCase()) ) }) - // List View if (view === 'list') { return (