From 2e5092285ad87d1d06993dd5d7624778ca8d0617 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 19 Sep 2026 07:18:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=20-=20=E6=8C=89=E7=B3=BB=E7=BB=9F=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E8=81=9A=E5=90=88=E6=A8=A1=E5=9E=8B=E4=B8=80=E7=BA=A7=E8=8F=9C?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: lmj15657306288-ui Co-authored-by: ZW-Hao --- backend/internal/handler/openapi.yaml | 4 +-- docs/content/docs/backend/http-api.mdx | 2 +- web/src/components/model-picker.tsx | 2 +- web/src/lib/model-picker-groups.ts | 37 +++++++++++------------- web/test/model-picker-groups.test.ts | 39 +++++++++++++++----------- 5 files changed, 43 insertions(+), 41 deletions(-) diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 4907872a9..c88886650 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -32,10 +32,10 @@ paths: summary: 获取创作端模型目录 description: | source=system 时读取 channels,source=frontend 时读取 models;两个集合始终为数组。 - 系统渠道模型按 capability + modelKey 分组展示,每条渠道选择及其价格保持独立。 + 系统渠道模型按渠道聚合展示,渠道内模型保持独立选择及价格;模型请求仍使用 channelId + modelKey 定位。 channelLabel 是后台渠道模型创建和更新时配置的公开渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用渠道公开名称。 description 是后台渠道模型创建和更新时填写的公开模型描述,去除首尾空白后最多 500 字;空字符串表示未填写。 - displayName 和 icon 用于产品模型一级菜单,channelLabel 不参与路由、权限或计费。 + displayName 和 icon 用于渠道内模型菜单,channelLabel 不参与路由、权限或计费。 responses: "200": description: 当前启用的模型目录 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 7647f7285..d58ca0b55 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -19,7 +19,7 @@ description: 信封、错误码与 OpenAPI 渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。 -创作端按 `capability + modelKey` 精确分组,二级菜单保留各渠道模型的独立选择;选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示名、渠道展示名和 Logo 不参与路由、权限及计费。`source=frontend` 的逻辑模型目录继续按原逻辑模型身份工作,不将逻辑模型的供应线路公开成可选渠道。 +创作端系统渠道按渠道聚合一级菜单,二级菜单保留该渠道内各模型的独立选择;选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示名、渠道展示名和 Logo 不参与路由、权限及计费。`source=frontend` 的逻辑模型目录继续按原逻辑模型身份工作,不将逻辑模型的供应线路公开成可选渠道。 同一产品在不同渠道中的 `modelKey` 应保持一致,名称与 Logo 也应统一;不同产品版本使用不同标识。已有模型标识不会自动改写。一级顺序按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道顺序;若历史配置的展示名或 Logo 不一致,以稳定渠道身份排序选取元数据,不随当前选择和渠道排序跳变。 diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 891a93104..a18463916 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -245,7 +245,7 @@ export function ModelPicker({ theme={theme} creationVariant showConfiguredModelName={showConfiguredModelName} - label={group.kind === "product" ? modelGroup.label : undefined} + label={group.kind === "product" || group.kind === "system-channel" ? modelGroup.label : undefined} requirements={requirements} showPrice={showOptionPrices && creditsEnabled} disabledReason={disabledReason} diff --git a/web/src/lib/model-picker-groups.ts b/web/src/lib/model-picker-groups.ts index dd72b533f..c4b9f9ce1 100644 --- a/web/src/lib/model-picker-groups.ts +++ b/web/src/lib/model-picker-groups.ts @@ -6,7 +6,7 @@ export type ModelPickerGroup = { label: string; icon: string; scope: string; - kind: "product" | "channel"; + kind: "product" | "channel" | "system-channel"; models: DisplayModelGroup[]; }; @@ -23,21 +23,26 @@ export function modelChannelLabel(config: AiConfig, value: string) { return cost?.channelLabel?.trim() || channel.publicAlias?.trim() || channel.name || "未命名渠道"; } -// 只构建展示树。每个产品下的叶子仍是独立的渠道选择,不能交给模型族自动路由。 +// 系统模型按渠道聚合展示,但每个模型仍保留独立的 channelId::modelKey 选择值。 export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] { const groups = new Map(); for (const channel of config.channels) { const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id); - for (const value of models) { - if (!isDirectSystemModel(config, value)) continue; - const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value)); - const key = JSON.stringify(["product", cost?.capability, modelOptionName(value)]); - let group = groups.get(key); - if (!group) { - group = { key, label: "", icon: "", scope: "平台服务", kind: "product", models: [] }; - groups.set(key, group); - } - group.models.push({ key: value, label: modelChannelLabel(config, value), models: [value] }); + const directModels = models.filter((value) => isDirectSystemModel(config, value)); + if (directModels.length) { + const key = JSON.stringify(["system-channel", channel.id]); + groups.set(key, { + key, + label: channel.name || "未命名渠道", + icon: modelIcon(config, directModels[0]), + scope: "平台服务", + kind: "system-channel", + models: directModels.map((value) => ({ + key: value, + label: configuredModelDisplayName(config, value), + models: [value], + })), + }); } const otherModels = models.filter((value) => !isDirectSystemModel(config, value)); if (otherModels.length) { @@ -52,13 +57,5 @@ export function groupModelsForPicker(config: AiConfig, options: string[]): Model }); } } - for (const group of groups.values()) { - if (group.kind !== "product") continue; - // 元数据按稳定身份取值,不随当前选中项或渠道排序变化;叶子顺序仍遵循后台排序。 - const members = group.models.map((item) => item.models[0]).sort(); - const named = members.find((value) => configuredModelDisplayName(config, value) !== modelOptionName(value)); - group.label = configuredModelDisplayName(config, named || members[0]); - group.icon = members.map((value) => modelIcon(config, value)).find(Boolean) || ""; - } return Array.from(groups.values()); } diff --git a/web/test/model-picker-groups.test.ts b/web/test/model-picker-groups.test.ts index b3243a64b..b675701e1 100644 --- a/web/test/model-picker-groups.test.ts +++ b/web/test/model-picker-groups.test.ts @@ -7,9 +7,9 @@ import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/a import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; -function model(label = "", price = 300000): PublicChannelModel { +function model(label = "", price = 300000, modelKey = "seedance-2.0", displayName = modelKey === "seedance-2.0" ? "Seedance 2.0" : modelKey): PublicChannelModel { return { - id: label || "test-model", modelKey: "seedance-2.0", displayName: "Seedance 2.0", channelLabel: label, + id: `${modelKey}-${label || "default"}`, modelKey, displayName, channelLabel: label, description: label ? `${label}的使用说明` : "", icon: "ByteDance", capability: "video", protocol: "seedance", available: true, pricingMode: "provider", priceLabel: "", @@ -27,14 +27,15 @@ function fixture() { return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config; } -test("one product exposes every channel and preserves prices through catalog normalization", () => { +test("one system channel exposes every model and preserves prices through catalog normalization", () => { const config = fixture(); const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video")); - expect(groups).toHaveLength(1); + expect(groups).toHaveLength(3); expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]); - expect(groups[0]).toMatchObject({ label: "Seedance 2.0", icon: "ByteDance", kind: "product" }); - expect(groups[0].models.map((item) => [item.label, item.models])).toEqual([ - ["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]], + expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([ + ["正常渠道", "system-channel", [["Seedance 2.0", ["a::seedance-2.0"]]]], + ["渠道 B", "system-channel", [["Seedance 2.0", ["b::seedance-2.0"]]]], + ["渠道 C", "system-channel", [["Seedance 2.0", ["c::seedance-2.0"]]]], ]); expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]); }); @@ -50,13 +51,17 @@ test("selection and quote keep the chosen channel even when another channel is c expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" }); }); -test("product keys and capabilities stay distinct despite equal display names", () => { +test("system channel aggregation keeps models distinct within one channel", () => { const config = fixture(); - config.channels[1].models = ["seedance-2.0-fast"]; - config.channels[1].modelCosts![0].model = "seedance-2.0-fast"; - config.channels[2].modelCosts![0].capability = "image"; - const groups = groupModelsForPicker(config, ["a::seedance-2.0", "b::seedance-2.0-fast", "c::seedance-2.0"]); - expect(groups).toHaveLength(3); + const extra = systemChannelModelChannels([{ id: "volc", name: "火山引擎", displayName: "火山引擎", models: [ + model("Seedance 2 Mini", 300000, "seedance-2-mini", "Seedance 2 Mini"), + model("Seedance 2.0 Fast", 300000, "seedance-2-fast", "Seedance 2.0 Fast"), + model("Seedance 2.0", 300000, "seedance-2", "Seedance 2.0"), + ] }]); + const groups = groupModelsForPicker({ ...config, channels: extra }, selectableModelsByCapability({ ...config, channels: extra }, "video")); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ label: "火山引擎", kind: "system-channel" }); + expect(groups[0].models.map((item) => item.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]); }); test("group metadata is stable across channel reorder and current selection", () => { @@ -64,8 +69,8 @@ test("group metadata is stable across channel reorder and current selection", () config.channels[1].modelCosts![0].displayName = "不同展示名"; config.channels[1].modelCosts![0].icon = "Jimeng"; const options = selectableModelsByCapability(config, "video"); - const first = groupModelsForPicker(config, options)[0]; - const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options)[0]; + const first = groupModelsForPicker(config, options).find((group) => group.label === "渠道 C")!; + const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "渠道 C")!; expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]); expect(reordered.models[0].models).toEqual(["c::seedance-2.0"]); }); @@ -78,7 +83,7 @@ test("each channel keeps its own capability restrictions", () => { const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } }; expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe(""); expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe(""); - expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3); + expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video")).every((group) => group.models.length === 1)).toBe(true); }); test("managed logical models and personal channels never become system product routes", () => { @@ -87,7 +92,7 @@ test("managed logical models and personal channels never become system product r config.channels[1].modelCosts![0].logicalModelId = "logical-test"; config.channels[2].scope = "user"; const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]); - expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]); + expect(groups.map((item) => item.kind)).toEqual(["system-channel", "channel", "channel"]); expect(groups[0].models).toHaveLength(1); expect(groupModelsForPicker(config, [])).toEqual([]); });