fix(model-picker): 模型选择 - 修复候选报价显示与同渠道模型跳回 (#556)
Original-Commit: 7a5e17e2112c3c23e081a1ecf3c33a654cf1e907 fix(model-picker): 模型选择 - 修复候选报价显示与同渠道模型跳回
This commit is contained in:
@@ -31,6 +31,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- [x] 前端生产构建通过,修复镜像已部署;首页、创作台路由、入口资源和创作台脚本与本次构建逐字节一致,容器健康与 API 就绪检查通过。构建仍提示部分 chunk 超过 500 kB。
|
||||
- [ ] 在真实创作台使用万有引力渠道的 `minimax-h3-a`,输入“小猫在流泪”,选择 `16:9`、`480P`,检查报价、首次生成和失败后重试;同时确认支持同步音频的模型遵循各自默认值。本次不调用真实上游,生成结果仍待验收。
|
||||
|
||||
## 模型候选价目与同渠道独立选择
|
||||
|
||||
- [x] 候选模型列表展示自身配置的价格或多规格价格范围,不再使用当前模型的分辨率、时长等参数判定其他候选模型「无报价」。选中模型的触发器、请求估价仍按当前参数精确匹配;未配置价格不显示为免费。
|
||||
- [x] 平台直连模型以渠道 ID 和模型 Key 保持独立,同渠道同展示名不再合并成自动选价模型族。选择、生成请求和报价保留用户选中的模型,不再借用同名模型的参考输入上限;个人渠道原有细分模型兼容路由保持不变。
|
||||
- [x] 58 项专项及相邻测试通过,覆盖同渠道同名不同价模型的选择、实际生成配置、报价目标、能力隔离,以及真实价格组件的明暗主题静态渲染;前端类型检查和生产构建通过。构建仍提示部分 chunk 超过 500 kB。
|
||||
- [ ] 云端部署前端后,在真实 MiniMax H3 列表逐项切换高速版、多图一致性、音画融合及满血等选项,确认选中项不跳回、价目与后台一致;调整参数后检查精确报价和实际任务使用的渠道与模型。本轮未读取云端配置或日志、未调用真实付费生成、未部署,静态渲染不代表云端端到端验收完成。
|
||||
|
||||
## 多模态列表与分镜表合并回归
|
||||
|
||||
- [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。
|
||||
|
||||
@@ -311,7 +311,7 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "当前没有可用模型,请联系管理员或检查模型配置";
|
||||
}
|
||||
|
||||
function ModelLabel({
|
||||
export function ModelLabel({
|
||||
config,
|
||||
model,
|
||||
capability,
|
||||
@@ -358,7 +358,8 @@ function ModelLabel({
|
||||
</span>
|
||||
{showPrice ? (
|
||||
<span className="ml-auto shrink-0 pl-2">
|
||||
<ModelPrice price={modelMenuPrice(config, model, capability, !requirements, requirements)} />
|
||||
{/* 候选模型展示自身价目;当前参数的精确报价只在选中后的触发器显示。 */}
|
||||
<ModelPrice price={modelMenuPrice(config, model, capability, true, requirements)} />
|
||||
</span>
|
||||
) : null}
|
||||
{!creationVariant && meta.time ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { configuredModelDisplayName, groupModelsByDisplayName, type DisplayModelGroup } from "@/lib/model-selection";
|
||||
import { configuredModelDisplayName, groupModelsByDisplayName, isDirectSystemModel, type DisplayModelGroup } from "@/lib/model-selection";
|
||||
import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelPickerGroup = {
|
||||
@@ -10,12 +10,7 @@ export type ModelPickerGroup = {
|
||||
models: DisplayModelGroup[];
|
||||
};
|
||||
|
||||
export function isDirectSystemModel(config: AiConfig, value: string) {
|
||||
if (!value) return false;
|
||||
const channel = resolveModelChannel(config, value);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
return channel.scope === "system" && channel.id !== PUBLIC_MODEL_CATALOG_ID && !cost?.logicalModelId;
|
||||
}
|
||||
export { isDirectSystemModel } from "@/lib/model-selection";
|
||||
|
||||
export function modelChannelLabel(config: AiConfig, value: string) {
|
||||
const channel = resolveModelChannel(config, value);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelInputSummary = {
|
||||
textCount: number;
|
||||
@@ -32,12 +32,20 @@ export type ModelReferenceLimits = {
|
||||
maxAudios: number;
|
||||
};
|
||||
|
||||
export function isDirectSystemModel(config: AiConfig, value: string) {
|
||||
if (!value) return false;
|
||||
const channel = resolveModelChannel(config, value);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
return channel.scope === "system" && channel.id !== PUBLIC_MODEL_CATALOG_ID && !cost?.logicalModelId;
|
||||
}
|
||||
|
||||
export function groupModelsByDisplayName(config: AiConfig, models: string[]): DisplayModelGroup[] {
|
||||
const groups = new Map<string, DisplayModelGroup>();
|
||||
models.forEach((model) => {
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const label = configuredModelDisplayName(config, model);
|
||||
const key = `${channel.id}\u0000${label.toLocaleLowerCase()}`;
|
||||
// 平台直连模型是独立的渠道 SKU;同名只用于菜单展示,不能合并能力或自动改选。
|
||||
const key = isDirectSystemModel(config, model) ? JSON.stringify([channel.id, modelOptionName(model)]) : `${channel.id}\u0000${label.toLocaleLowerCase()}`;
|
||||
const current = groups.get(key);
|
||||
if (current) current.models.push(model);
|
||||
else groups.set(key, { key, label, models: [model] });
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups";
|
||||
import { modelCompatibilityError, resolveCompatibleModel } from "../src/lib/model-selection";
|
||||
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 {
|
||||
@@ -62,6 +64,42 @@ 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" });
|
||||
});
|
||||
|
||||
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: [
|
||||
|
||||
58
web/test/model-picker-prices.test.tsx
Normal file
58
web/test/model-picker-prices.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ModelLabel, ModelPicker } from "../src/components/model-picker";
|
||||
import { canvasThemes } from "../src/lib/canvas-theme";
|
||||
import { requestCreditCost } from "../src/lib/model-pricing";
|
||||
import type { ModelRequirements } from "../src/lib/model-selection";
|
||||
import { createModelChannel, defaultConfig, normalizeConfigSnapshot } from "../src/stores/use-config-store";
|
||||
|
||||
function fixture() {
|
||||
const channel = createModelChannel({
|
||||
id: "comfy", name: "Comfy", scope: "system", models: ["h3-multi"],
|
||||
modelCosts: [{
|
||||
model: "h3-multi", displayName: "MiniMax H3", channelLabel: "多图一致性", capability: "video",
|
||||
pricePolicy: "channel", billingMode: "per_second", unitPriceMicrocredits: 100_000,
|
||||
logicalPriceTiers: [480, 720].map((resolution, index) => ({
|
||||
selector: { vquality: resolution + "p", videoSeconds: "6" }, resolution: resolution + "p", videoSeconds: 6,
|
||||
billingMode: "per_second", unitPriceMicrocredits: (index + 1) * 100_000,
|
||||
inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0,
|
||||
})),
|
||||
}],
|
||||
});
|
||||
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: [channel], model: "comfy::h3-multi", videoModel: "comfy::h3-multi" } }).config;
|
||||
}
|
||||
|
||||
const requirements: ModelRequirements = { capability: "video", videoSeconds: "5", options: { vquality: "1080p" } };
|
||||
|
||||
test("candidate rows show their configured prices instead of applying the previous model's specifications", () => {
|
||||
const config = fixture();
|
||||
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={theme}
|
||||
creationVariant showConfiguredModelName={false} requirements={requirements} showPrice showDescription />);
|
||||
expect(markup).toContain("0.1-0.2 积分/秒");
|
||||
expect(markup).not.toContain("当前规格无报价");
|
||||
}
|
||||
});
|
||||
|
||||
test("candidate rows do not disguise missing prices as zero", () => {
|
||||
const config = fixture();
|
||||
config.channels[0].modelCosts![0].logicalPriceTiers = [];
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.dark}
|
||||
creationVariant showConfiguredModelName={false} requirements={requirements} showPrice showDescription />);
|
||||
expect(markup).toContain("未配置");
|
||||
expect(markup).not.toContain("0 积分");
|
||||
});
|
||||
|
||||
test("the selected model and request estimate still require an exact specification match", () => {
|
||||
const config = fixture();
|
||||
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video"
|
||||
requirements={value} onChange={() => {}} />);
|
||||
expect(render(requirements)).toContain("当前规格无报价");
|
||||
const cost = (value: ModelRequirements) => requestCreditCost({ channelMode: "remote", modelCosts: config.channels[0].modelCosts,
|
||||
model: "h3-multi", capability: "video", config, requirements: value, seconds: "6" });
|
||||
expect(cost(requirements)).toBeNull();
|
||||
const matching = { ...requirements, videoSeconds: "6", options: { vquality: "720p" } };
|
||||
expect(render(matching)).toContain("0.2 积分/秒");
|
||||
expect(render(matching)).not.toContain("当前规格无报价");
|
||||
expect(cost(matching)).toBeCloseTo(1.2);
|
||||
});
|
||||
Reference in New Issue
Block a user