fix(model-picker): 模型选择 - 修复候选报价显示与同渠道模型跳回 (#556)
Original-Commit: 7a5e17e2112c3c23e081a1ecf3c33a654cf1e907 fix(model-picker): 模型选择 - 修复候选报价显示与同渠道模型跳回
This commit is contained in:
1 parent
93bc079b89
commit
19c9a207c9
6 files changed
+119
-12
No files matched your search
@@ -31,6 +31,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
|||||||
- [x] 前端生产构建通过,修复镜像已部署;首页、创作台路由、入口资源和创作台脚本与本次构建逐字节一致,容器健康与 API 就绪检查通过。构建仍提示部分 chunk 超过 500 kB。
|
- [x] 前端生产构建通过,修复镜像已部署;首页、创作台路由、入口资源和创作台脚本与本次构建逐字节一致,容器健康与 API 就绪检查通过。构建仍提示部分 chunk 超过 500 kB。
|
||||||
- [ ] 在真实创作台使用万有引力渠道的 `minimax-h3-a`,输入“小猫在流泪”,选择 `16:9`、`480P`,检查报价、首次生成和失败后重试;同时确认支持同步音频的模型遵循各自默认值。本次不调用真实上游,生成结果仍待验收。
|
- [ ] 在真实创作台使用万有引力渠道的 `minimax-h3-a`,输入“小猫在流泪”,选择 `16:9`、`480P`,检查报价、首次生成和失败后重试;同时确认支持同步音频的模型遵循各自默认值。本次不调用真实上游,生成结果仍待验收。
|
||||||
|
|
||||||
|
## 模型候选价目与同渠道独立选择
|
||||||
|
|
||||||
|
- [x] 候选模型列表展示自身配置的价格或多规格价格范围,不再使用当前模型的分辨率、时长等参数判定其他候选模型「无报价」。选中模型的触发器、请求估价仍按当前参数精确匹配;未配置价格不显示为免费。
|
||||||
|
- [x] 平台直连模型以渠道 ID 和模型 Key 保持独立,同渠道同展示名不再合并成自动选价模型族。选择、生成请求和报价保留用户选中的模型,不再借用同名模型的参考输入上限;个人渠道原有细分模型兼容路由保持不变。
|
||||||
|
- [x] 58 项专项及相邻测试通过,覆盖同渠道同名不同价模型的选择、实际生成配置、报价目标、能力隔离,以及真实价格组件的明暗主题静态渲染;前端类型检查和生产构建通过。构建仍提示部分 chunk 超过 500 kB。
|
||||||
|
- [ ] 云端部署前端后,在真实 MiniMax H3 列表逐项切换高速版、多图一致性、音画融合及满血等选项,确认选中项不跳回、价目与后台一致;调整参数后检查精确报价和实际任务使用的渠道与模型。本轮未读取云端配置或日志、未调用真实付费生成、未部署,静态渲染不代表云端端到端验收完成。
|
||||||
|
|
||||||
## 多模态列表与分镜表合并回归
|
## 多模态列表与分镜表合并回归
|
||||||
|
|
||||||
- [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。
|
- [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
|||||||
return config.models.length ? `暂无匹配的${label}模型` : "当前没有可用模型,请联系管理员或检查模型配置";
|
return config.models.length ? `暂无匹配的${label}模型` : "当前没有可用模型,请联系管理员或检查模型配置";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelLabel({
|
export function ModelLabel({
|
||||||
config,
|
config,
|
||||||
model,
|
model,
|
||||||
capability,
|
capability,
|
||||||
@@ -358,7 +358,8 @@ function ModelLabel({
|
|||||||
</span>
|
</span>
|
||||||
{showPrice ? (
|
{showPrice ? (
|
||||||
<span className="ml-auto shrink-0 pl-2">
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!creationVariant && meta.time ? (
|
{!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";
|
import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
|
||||||
export type ModelPickerGroup = {
|
export type ModelPickerGroup = {
|
||||||
@@ -10,12 +10,7 @@ export type ModelPickerGroup = {
|
|||||||
models: DisplayModelGroup[];
|
models: DisplayModelGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isDirectSystemModel(config: AiConfig, value: string) {
|
export { isDirectSystemModel } from "@/lib/model-selection";
|
||||||
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 modelChannelLabel(config: AiConfig, value: string) {
|
export function modelChannelLabel(config: AiConfig, value: string) {
|
||||||
const channel = resolveModelChannel(config, value);
|
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 { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||||
import { imageSizePresets } from "@/lib/image-size-presets";
|
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 = {
|
export type ModelInputSummary = {
|
||||||
textCount: number;
|
textCount: number;
|
||||||
@@ -32,12 +32,20 @@ export type ModelReferenceLimits = {
|
|||||||
maxAudios: number;
|
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[] {
|
export function groupModelsByDisplayName(config: AiConfig, models: string[]): DisplayModelGroup[] {
|
||||||
const groups = new Map<string, DisplayModelGroup>();
|
const groups = new Map<string, DisplayModelGroup>();
|
||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
const channel = resolveModelChannel(config, model);
|
const channel = resolveModelChannel(config, model);
|
||||||
const label = configuredModelDisplayName(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);
|
const current = groups.get(key);
|
||||||
if (current) current.models.push(model);
|
if (current) current.models.push(model);
|
||||||
else groups.set(key, { key, label, models: [model] });
|
else groups.set(key, { key, label, models: [model] });
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups";
|
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 { modelQuoteRequest, priceTiersForCurrentSelection, priceTierSummaryLabel } from "../src/lib/model-pricing";
|
||||||
import { systemChannelModelChannels } from "../src/lib/user-session";
|
import { systemChannelModelChannels } from "../src/lib/user-session";
|
||||||
import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models";
|
import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models";
|
||||||
import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store";
|
import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store";
|
||||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
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 {
|
function model(label = "", price = 300000, modelKey = "seedance-2.0", displayName = modelKey === "seedance-2.0" ? "Seedance 2.0" : modelKey): PublicChannelModel {
|
||||||
return {
|
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" });
|
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", () => {
|
test("different display names in one channel create distinct first-level groups", () => {
|
||||||
const config = fixture();
|
const config = fixture();
|
||||||
const extra = systemChannelModelChannels([{ id: "volc", name: "火山引擎", displayName: "火山引擎", models: [
|
const extra = systemChannelModelChannels([{ id: "volc", name: "火山引擎", displayName: "火山引擎", models: [
|
||||||
|
|||||||
@@ -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