fix(canvas): 模型选择 - 按系统渠道聚合模型一级菜单
Co-authored-by: lmj15657306288-ui <lmj15657306288-ui@users.noreply.github.com> Co-authored-by: ZW-Hao <ZW-Hao@users.noreply.github.com>
This commit is contained in:
@@ -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: 当前启用的模型目录
|
||||
|
||||
@@ -19,7 +19,7 @@ description: 信封、错误码与 OpenAPI
|
||||
|
||||
渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。
|
||||
|
||||
创作端按 `capability + modelKey` 精确分组,二级菜单保留各渠道模型的独立选择;选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示名、渠道展示名和 Logo 不参与路由、权限及计费。`source=frontend` 的逻辑模型目录继续按原逻辑模型身份工作,不将逻辑模型的供应线路公开成可选渠道。
|
||||
创作端系统渠道按渠道聚合一级菜单,二级菜单保留该渠道内各模型的独立选择;选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示名、渠道展示名和 Logo 不参与路由、权限及计费。`source=frontend` 的逻辑模型目录继续按原逻辑模型身份工作,不将逻辑模型的供应线路公开成可选渠道。
|
||||
|
||||
同一产品在不同渠道中的 `modelKey` 应保持一致,名称与 Logo 也应统一;不同产品版本使用不同标识。已有模型标识不会自动改写。一级顺序按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道顺序;若历史配置的展示名或 Logo 不一致,以稳定渠道身份排序选取元数据,不随当前选择和渠道排序跳变。
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string, ModelPickerGroup>();
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user