* fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组
进入画布时形象配置与 Live2D 首帧未就绪不再显示默认球,改为加载提示,失败才回退。
新导入模型一级目录默认未分类、二级渠道展示名默认跟随上游 ID;历史非空分组(含与模型 ID 同名)保留。
拉取模型弹窗默认只勾选已存在模型并移除逐项已存在字样;一级分组下拉点击即展开全部选项。
* style(web): 画布与模型导入 - 按 Prettier 规范格式化改动文件
修复 PR 中 Web checks 的格式化检查失败:Live2D 启动器 JSX、模型导入选择工具函数与 Live2D 加载测试按项目 Prettier 配置重新排版,不改变行为。
* test(web): Live2D 生命周期 - 修复 bun 1.3.9 下 text 导入与模块导入冲突
agent-live2d-lifecycle 曾用 with { type: text } 导入 live2d-avatar.tsx,bun 1.3.9 会按路径复用该缓存,导致新增的模块导入拿不到 Live2DAvatar 具名导出并报 SyntaxError。改为 readFileSync 读取源码文本,本地以 bun 1.3.9 复现并验证通过。
173 lines
12 KiB
TypeScript
173 lines
12 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups";
|
|
import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel } from "../src/lib/model-selection";
|
|
import { modelQuoteRequest, priceTiersForCurrentSelection, priceTierSummaryLabel } from "../src/lib/model-pricing";
|
|
import { systemChannelModelChannels } from "../src/lib/user-session";
|
|
import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models";
|
|
import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store";
|
|
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
|
import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generation";
|
|
import { CanvasNodeType } from "../src/types/canvas";
|
|
|
|
function model(label = "", price = 300000, modelKey = "seedance-2.0", displayName = modelKey === "seedance-2.0" ? "Seedance 2.0" : modelKey): PublicChannelModel {
|
|
return {
|
|
id: `${modelKey}-${label || "default"}`, modelKey, displayName, channelLabel: label,
|
|
description: label ? `${label}的使用说明` : "",
|
|
icon: "ByteDance", capability: "video", protocol: "seedance", available: true,
|
|
pricingMode: "provider", priceLabel: "",
|
|
capabilityConfig: defaultModelCapabilityConfig("seedance", "seedance-2.0"),
|
|
priceTiers: [{ id: "tier", selector: {}, resolution: "*", videoSeconds: 0, billingMode: "per_second", unitPriceMicrocredits: price, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0 }],
|
|
};
|
|
}
|
|
|
|
function fixture() {
|
|
const channels: PublicChannelCatalog[] = [
|
|
{ id: "a", name: "正常渠道", displayName: "正常渠道", models: [model()] },
|
|
{ id: "b", name: "渠道 B", displayName: "渠道 B", models: [model("优惠渠道-993")] },
|
|
{ id: "c", name: "渠道 C", displayName: "渠道 C", models: [model("特惠渠道-730", 200000)] },
|
|
];
|
|
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config;
|
|
}
|
|
|
|
test("same model display name groups all channels and preserves their prices", () => {
|
|
const config = fixture();
|
|
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
|
expect(groups).toHaveLength(1);
|
|
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
|
|
expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([
|
|
["Seedance 2.0", "product", [["seedance-2.0", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
|
|
]);
|
|
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
|
|
});
|
|
|
|
test("grouping uses display name even when model keys differ, without merging channel options", () => {
|
|
const channels = systemChannelModelChannels([
|
|
{ id: "mita", name: "秘塔", displayName: "秘塔", models: [model("秘塔(满血渠道)", 90_000, "MiniMax H3", "MiniMax H3")] },
|
|
{ id: "other", name: "其他供应商", displayName: "其他供应商", models: [model("秘塔(满血渠道)", 80_000, "minimax-h3-alt", "MiniMax H3")] },
|
|
]);
|
|
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
|
|
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0].label).toBe("MiniMax H3");
|
|
expect(groups[0].models.map((item) => item.models)).toEqual([["mita::MiniMax H3"], ["other::minimax-h3-alt"]]);
|
|
expect(groups[0].models.map((item) => item.label)).toEqual(["秘塔(满血渠道)", "秘塔(满血渠道)"]);
|
|
});
|
|
|
|
test("catalog projection keeps promotional tags scoped to each channel model", () => {
|
|
const channels = systemChannelModelChannels([
|
|
{ id: "a", name: "A", displayName: "A", models: [{ ...model("A", 100_000), tags: [{ text: "限时特价", color: "purple" }] }] },
|
|
{ id: "b", name: "B", displayName: "B", models: [{ ...model("B", 200_000), tags: [{ text: "官方1折", color: "gold" }] }] },
|
|
]);
|
|
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
|
|
expect(config.channels[0].modelCosts![0].tags).toEqual([{ text: "限时特价", color: "purple" }]);
|
|
expect(config.channels[1].modelCosts![0].tags).toEqual([{ text: "官方1折", color: "gold" }]);
|
|
});
|
|
|
|
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
|
|
const config = fixture();
|
|
const value = "b::seedance-2.0";
|
|
expect(resolveCompatibleModel(config, value, { capability: "video" })).toBe(value);
|
|
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
|
expect(modelQuoteRequest(config, value, "video")).toMatchObject({ channelId: "b", modelKey: "seedance-2.0" });
|
|
config.channels[1].modelCosts![0].channelLabel = "新展示名";
|
|
expect(modelChannelLabel(config, value)).toBe("新展示名");
|
|
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
|
});
|
|
|
|
function sameChannelVariants() {
|
|
const channels = systemChannelModelChannels([{ id: "comfy", name: "Comfy", displayName: "Comfy", models: [
|
|
model("高速版", 100_000, "h3-fast", "MiniMax H3"),
|
|
model("多图一致性", 200_000, "h3-multi", "MiniMax H3"),
|
|
] }]);
|
|
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels, model: "comfy::h3-multi", videoModel: "comfy::h3-multi" } }).config;
|
|
}
|
|
|
|
test("same-channel system variants retain explicit model identity through selection, generation and quote", () => {
|
|
const config = sameChannelVariants();
|
|
const options = selectableModelsByCapability(config, "video");
|
|
expect(groupModelsForPicker(config, options)[0].models).toHaveLength(2);
|
|
expect(groupModelsByDisplayName(config, options).map((group) => group.models)).toEqual(options.map((value) => [value]));
|
|
for (const value of options) {
|
|
expect(resolveCompatibleModel(config, value, { capability: "video" })).toBe(value);
|
|
const generation = buildGenerationConfig(config, {
|
|
id: "video", type: CanvasNodeType.Video, title: "Video", position: { x: 0, y: 0 }, width: 100, height: 100,
|
|
metadata: { model: value, generationMode: "video" },
|
|
}, "video");
|
|
expect(generation.model).toBe(value);
|
|
expect(resolveModelRequestConfig(generation, generation.model)).toMatchObject({ channelId: "comfy", model: value.split("::")[1] });
|
|
expect(modelQuoteRequest(config, value, "video")).toMatchObject({ channelId: "comfy", modelKey: value.split("::")[1] });
|
|
}
|
|
});
|
|
|
|
test("same-channel system variants do not borrow capabilities or reroute incompatible selections", () => {
|
|
const config = sameChannelVariants();
|
|
config.channels[0].modelCosts![0].capabilityConfig!.video!.references.maxImages = 0;
|
|
config.channels[0].modelCosts![1].capabilityConfig!.video!.references.maxImages = 2;
|
|
const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } };
|
|
expect(modelGroupReferenceLimits(config, "comfy::h3-fast", "video")?.maxImages).toBe(0);
|
|
expect(modelCompatibilityError(config, "comfy::h3-fast", requirements)).not.toBe("");
|
|
expect(resolveCompatibleModel(config, "comfy::h3-fast", requirements)).toBe("");
|
|
expect(resolveCompatibleModel(config, "comfy::h3-multi", requirements)).toBe("comfy::h3-multi");
|
|
});
|
|
|
|
test("different display names in one channel create distinct first-level groups", () => {
|
|
const config = fixture();
|
|
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(3);
|
|
expect(groups.map((group) => group.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]);
|
|
expect(groups.every((group) => group.kind === "product" && group.models.length === 1)).toBe(true);
|
|
});
|
|
|
|
test("group metadata is stable across channel reorder and current selection", () => {
|
|
const config = fixture();
|
|
config.channels[1].modelCosts![0].displayName = "不同展示名";
|
|
config.channels[1].modelCosts![0].icon = "Jimeng";
|
|
const options = selectableModelsByCapability(config, "video");
|
|
const first = groupModelsForPicker(config, options).find((group) => group.label === "不同展示名")!;
|
|
const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "不同展示名")!;
|
|
expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]);
|
|
expect(reordered.models[0].models).toEqual(["b::seedance-2.0"]);
|
|
});
|
|
|
|
test("each channel keeps its own capability restrictions", () => {
|
|
const config = fixture();
|
|
config.channels[0].modelCosts![0].capabilityConfig!.video!.references.maxImages = 0;
|
|
config.channels[1].modelCosts![0].capabilityConfig!.video!.references.maxImages = 2;
|
|
config.channels[1].modelCosts![0].capabilityConfig!.video!.operations = ["image_to_video"];
|
|
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);
|
|
});
|
|
|
|
test("managed logical models and personal channels never become system product routes", () => {
|
|
const config = fixture();
|
|
config.channels[1].id = "managed";
|
|
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[0].models).toHaveLength(1);
|
|
expect(groupModelsForPicker(config, [])).toEqual([]);
|
|
});
|
|
|
|
test("new models share 未分类 while explicit groups equal to model IDs remain intact", () => {
|
|
const channels = systemChannelModelChannels([{ id: "upstream", name: "Provider", displayName: "Provider", models: [
|
|
model("upstream-a", 300000, "a", "未分类"),
|
|
model("upstream-b", 300000, "b", "未分类"),
|
|
model("upstream-c", 300000, "c", "c"),
|
|
] }]);
|
|
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
|
|
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
|
expect(groups.map((group) => group.label)).toEqual(["未分类", "c"]);
|
|
expect(groups[0].models.map((item) => [item.label, item.models])).toEqual([
|
|
["upstream-a", ["upstream::a"]], ["upstream-b", ["upstream::b"]],
|
|
]);
|
|
expect(groups[1].models[0].models).toEqual(["upstream::c"]);
|
|
});
|