From 45fd61e285b36a4f866c899b401d043a564029af Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Sun, 12 Apr 2026 16:46:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=90=AF=E7=94=A8=E7=8A=B6=E6=80=81=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=92=8C=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=20-=20=E5=9C=A8?= =?UTF-8?q?=20ChannelModelMapping=20=E4=B8=AD=E6=B7=BB=E5=8A=A0=20enabled?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=20-=20=E6=9B=B4=E6=96=B0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=AF=E7=94=A8=E7=8A=B6=E6=80=81=20-=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=EF=BC=8C=E5=85=81=E8=AE=B8=E6=89=B9=E9=87=8F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=92=8C=E5=90=AF=E7=94=A8=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/channel-models.ts | 10 +- frontend/src/pages/Channels.tsx | 282 +++++++++++++++++++++++++---- frontend/src/types/index.ts | 1 + src/admin/channel_api.ts | 5 +- src/channel-config.ts | 8 +- src/utils.ts | 3 +- type.d.ts | 1 + 7 files changed, 263 insertions(+), 47 deletions(-) diff --git a/frontend/src/lib/channel-models.ts b/frontend/src/lib/channel-models.ts index d45ae89..00b674c 100644 --- a/frontend/src/lib/channel-models.ts +++ b/frontend/src/lib/channel-models.ts @@ -9,11 +9,13 @@ const normalizeModels = (models?: ChannelModelMapping[]): ChannelModelMapping[] .map((model) => ({ id: typeof model?.id === 'string' ? model.id.trim() : '', name: typeof model?.name === 'string' ? model.name.trim() : '', + enabled: model?.enabled !== false, })) .filter((model) => model.id.length > 0) .map((model) => ({ id: model.id, name: model.name || model.id, + enabled: model.enabled, })) } @@ -33,6 +35,10 @@ export const isChannelEnabled = (config: ChannelConfig): boolean => { return config.enabled !== false } +export const isChannelModelEnabled = (model: ChannelModelMapping): boolean => { + return model.enabled !== false +} + export const parseTokenConfig = (token: Token): TokenConfig => { if (typeof token.value !== 'string') { return token.value @@ -99,7 +105,9 @@ export const getUniqueModelNamesFromChannels = (channels: Channel[]): string[] = if (!isChannelEnabled(config)) { return } - getChannelModels(config).forEach((model) => modelNames.add(model.name)) + getChannelModels(config) + .filter((model) => isChannelModelEnabled(model)) + .forEach((model) => modelNames.add(model.name)) }) return Array.from(modelNames).sort() diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 7721244..019b3e4 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; import { apiClient } from "@/api/client"; @@ -11,6 +11,7 @@ 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useToast } from "@/components/ui/use-toast"; import { cn } from "@/lib/utils"; import { @@ -32,7 +33,8 @@ import { PageContainer } from "@/components/ui/page-container"; type EditMode = "form" | "json"; type ModelEditorMode = "visual" | "json"; -type ModelRow = { id: string; name: string }; +type ModelRow = { id: string; name: string; enabled: boolean }; +type FetchedModelCandidate = { id: string; label: string }; const channelTypes = [ { value: "openai", label: "OpenAI" }, @@ -54,6 +56,7 @@ const channelWeightOptions = Array.from({ length: 6 }, (_, weight) => ({ const createEmptyModelRow = (): ModelRow => ({ id: "", name: "", + enabled: true, }); const createDefaultChannelFormData = (): ChannelConfig => ({ @@ -105,11 +108,13 @@ const normalizeModels = (models?: ChannelModelMapping[]): ChannelModelMapping[] .map((model) => ({ id: typeof model?.id === "string" ? model.id.trim() : "", name: typeof model?.name === "string" ? model.name.trim() : "", + enabled: model?.enabled !== false, })) .filter((model) => model.id.length > 0) .map((model) => ({ id: model.id, name: model.name || model.id, + enabled: model.enabled, })); }; @@ -124,7 +129,7 @@ const getChannelModels = (config: ChannelConfig): ChannelModelMapping[] => { const legacyModels: ChannelModelMapping[] = []; const seenNames = new Set(); - const pushModel = (id: string, name?: string) => { + const pushModel = (id: string, name?: string, enabled = true) => { const normalizedId = id.trim(); const normalizedName = (name || id).trim(); @@ -136,6 +141,7 @@ const getChannelModels = (config: ChannelConfig): ChannelModelMapping[] => { legacyModels.push({ id: normalizedId, name: normalizedName, + enabled, }); }; @@ -203,7 +209,13 @@ const ensureTrailingEmptyModelRow = (rows: ModelRow[]): ModelRow[] => { }; const buildRowsFromModels = (models: ChannelModelMapping[]): ModelRow[] => { - return ensureTrailingEmptyModelRow(models.map((model) => ({ id: model.id, name: model.name }))); + return ensureTrailingEmptyModelRow( + models.map((model) => ({ + id: model.id, + name: model.name, + enabled: model.enabled !== false, + })), + ); }; const serializeModels = (models: ChannelModelMapping[]): string => { @@ -238,7 +250,7 @@ const parseModelsFromRows = (rows: ModelRow[]): { models: ChannelModelMapping[]; const models = activeRows.map((row) => { const id = row.id.trim(); const name = row.name.trim() || id; - return { id, name }; + return { id, name, enabled: row.enabled !== false }; }); const validationError = validateModels(models); @@ -280,6 +292,7 @@ const parseModelsFromJson = (value: string): { models: ChannelModelMapping[]; er models.push({ id, name: rawName || id, + enabled: (item as ChannelModelMapping).enabled !== false, }); } @@ -291,6 +304,40 @@ const parseModelsFromJson = (value: string): { models: ChannelModelMapping[]; er return { models }; }; +const normalizeFetchedModelCandidates = (models: ChannelModelMapping[]): FetchedModelCandidate[] => { + const candidates: FetchedModelCandidate[] = []; + const seenIds = new Set(); + + models.forEach((model) => { + const id = typeof model.id === "string" ? model.id.trim() : ""; + const label = typeof model.name === "string" ? model.name.trim() : ""; + + if (!id || seenIds.has(id)) { + return; + } + + seenIds.add(id); + candidates.push({ + id, + label: label || id, + }); + }); + + return candidates; +}; + +const buildInitialFetchedModelSelection = ( + candidates: FetchedModelCandidate[], + currentModels: ChannelModelMapping[], +): string[] => { + const currentModelIds = new Set(currentModels.map((model) => model.id)); + const matchedIds = candidates + .filter((candidate) => currentModelIds.has(candidate.id)) + .map((candidate) => candidate.id); + + return matchedIds.length > 0 ? matchedIds : candidates.map((candidate) => candidate.id); +}; + const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ""); const joinPath = (...segments: string[]): string => { @@ -450,6 +497,10 @@ export function Channels({ createMode = false, editRoute = false }: { createMode const [modelJsonValue, setModelJsonValue] = useState("[]"); const [searchQuery, setSearchQuery] = useState(""); const [openMenu, setOpenMenu] = useState(null); + const [isFetchedModelsDialogOpen, setIsFetchedModelsDialogOpen] = useState(false); + const [fetchedModelCandidates, setFetchedModelCandidates] = useState([]); + const [selectedFetchedModelIds, setSelectedFetchedModelIds] = useState([]); + const [fetchedModelsSearchQuery, setFetchedModelsSearchQuery] = useState(""); const { addToast } = useToast(); const queryClient = useQueryClient(); @@ -465,6 +516,11 @@ export function Channels({ createMode = false, editRoute = false }: { createMode return modelEditorMode === "visual" ? parseModelsFromRows(modelRows) : parseModelsFromJson(modelJsonValue); }; + const getCurrentConfiguredModels = (): ChannelModelMapping[] => { + const result = resolveCurrentModels(); + return result.error ? normalizeModels(formData.models) : result.models; + }; + const loadFormConfig = (config: ChannelConfig) => { const normalizedConfig = normalizeChannelFormConfig(config); setFormData(normalizedConfig); @@ -573,12 +629,17 @@ export function Channels({ createMode = false, editRoute = false }: { createMode 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"); + const candidates = normalizeFetchedModelCandidates((response.data as ChannelModelMapping[]) || []); + if (candidates.length === 0) { + addToast("未获取到可选模型", "error"); + return; + } + + const currentModels = getCurrentConfiguredModels(); + setFetchedModelCandidates(candidates); + setSelectedFetchedModelIds(buildInitialFetchedModelSelection(candidates, currentModels)); + setFetchedModelsSearchQuery(""); + setIsFetchedModelsDialogOpen(true); }, onError: (error: Error) => { addToast("获取模型失败:" + error.message, "error"); @@ -593,6 +654,10 @@ export function Channels({ createMode = false, editRoute = false }: { createMode setModelRows([createEmptyModelRow()]); setModelJsonValue("[]"); setModelEditorMode("visual"); + setIsFetchedModelsDialogOpen(false); + setFetchedModelCandidates([]); + setSelectedFetchedModelIds([]); + setFetchedModelsSearchQuery(""); setEditingKey(null); setEditMode("form"); }, []); @@ -671,6 +736,51 @@ export function Channels({ createMode = false, editRoute = false }: { createMode fetchModelsMutation.mutate(config); }; + const filteredFetchedModelCandidates = useMemo(() => { + const query = fetchedModelsSearchQuery.trim().toLowerCase(); + if (!query) { + return fetchedModelCandidates; + } + + return fetchedModelCandidates.filter((candidate) => + `${candidate.id} ${candidate.label}`.toLowerCase().includes(query), + ); + }, [fetchedModelCandidates, fetchedModelsSearchQuery]); + + const toggleFetchedModelSelection = (modelId: string, checked: boolean) => { + setSelectedFetchedModelIds((current) => { + if (checked) { + return current.includes(modelId) ? current : [...current, modelId]; + } + + return current.filter((item) => item !== modelId); + }); + }; + + const handleApplyFetchedModels = () => { + const selectedIdSet = new Set(selectedFetchedModelIds); + const currentModelsById = new Map(getCurrentConfiguredModels().map((model) => [model.id, model])); + const selectedModels = fetchedModelCandidates + .filter((candidate) => selectedIdSet.has(candidate.id)) + .map( + (candidate) => + currentModelsById.get(candidate.id) || { + id: candidate.id, + name: candidate.id, + enabled: true, + }, + ); + + if (selectedModels.length === 0) { + addToast("请至少选择一个模型", "error"); + return; + } + + applyModels(selectedModels); + setIsFetchedModelsDialogOpen(false); + addToast(`已写入 ${selectedModels.length} 个模型`, "success"); + }; + const handleSave = () => { if (!channelKey) { addToast("请填写渠道标识", "error"); @@ -895,6 +1005,9 @@ export function Channels({ createMode = false, editRoute = false }: { createMode const rawConfig = parseChannelValue(channel); const config = normalizeChannelFormConfig(rawConfig); const modelCount = (config.models || []).length; + const enabledModelCount = (config.models || []).filter((model) => model.enabled !== false).length; + const modelSummary = + enabledModelCount === modelCount ? `${modelCount} 个模型` : `${enabledModelCount}/${modelCount} 启用`; const isMenuOpen = openMenu === channel.key; const isEnabled = config.enabled !== false; const isToggling = @@ -945,7 +1058,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode {getTypeLabel(config.type)} - {modelCount} 个模型 + {modelSummary} {config.weight ?? 0} @@ -992,7 +1105,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode */}
- {modelCount} 个模型 + {modelSummary}
@@ -1001,14 +1114,6 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
- - {isEnabled ? "已启用" : "已停用"} -
- +
+ { + const nextRows = [...modelRows]; + nextRows[index] = { ...row, enabled: checked }; + updateModelRowsState(nextRows); + }} + disabled={!canDelete} + aria-label={`模型 ${row.name || row.id || index} 启用开关`} + /> + +
); })} @@ -1323,9 +1440,11 @@ export function Channels({ createMode = false, editRoute = false }: { createMode onChange={(e) => setModelJsonValue(e.target.value)} rows={12} className="font-mono text-sm" - placeholder='[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini"}]' + placeholder='[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini","enabled":true}]' /> -

JSON 结构为数组,每项包含 `id` 和 `name`。

+

+ JSON 结构为数组,每项包含 `id`、`name`、`enabled`。 +

)} @@ -1340,12 +1459,95 @@ export function Channels({ createMode = false, editRoute = false }: { createMode 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/","enabled":true,"weight":0,"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,"weight":0,"api_keys":["sk-1","sk-2"],"auto_retry":true,"auto_rotate":true,"models":[{"id":"gpt-4.1-mini","name":"gpt-4.1-mini","enabled":true}]}' /> )} + + + + 选择要写入的模型 + + 先从上游模型列表中批量勾选,再写入当前渠道配置。已存在且 ID 相同的模型会保留原有别名和启用状态。 + + + +
+
+
+ + setFetchedModelsSearchQuery(event.target.value)} + placeholder="搜索模型 ID 或展示名称" + className="pl-9" + /> +
+
+ + 已选 {selectedFetchedModelIds.length} / {fetchedModelCandidates.length} + + + +
+
+ +
+ {filteredFetchedModelCandidates.length > 0 ? ( +
+ {filteredFetchedModelCandidates.map((candidate) => { + const checked = selectedFetchedModelIds.includes(candidate.id); + + return ( + + ); + })} +
+ ) : ( +
没有匹配的模型
+ )} +
+ +
+ + +
+
+
+
+