diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index e311933da..d764ff575 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -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。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。
diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx
index 8a4b1b9c2..7796e6e39 100644
--- a/web/src/components/model-picker.tsx
+++ b/web/src/components/model-picker.tsx
@@ -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({
{showPrice ? (
-
+ {/* 候选模型展示自身价目;当前参数的精确报价只在选中后的触发器显示。 */}
+
) : null}
{!creationVariant && meta.time ? (
diff --git a/web/src/lib/model-picker-groups.ts b/web/src/lib/model-picker-groups.ts
index 7b09732dc..a91fb73c0 100644
--- a/web/src/lib/model-picker-groups.ts
+++ b/web/src/lib/model-picker-groups.ts
@@ -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);
diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts
index 112c81ce8..4fa9b4edf 100644
--- a/web/src/lib/model-selection.ts
+++ b/web/src/lib/model-selection.ts
@@ -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();
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] });
diff --git a/web/test/model-picker-groups.test.ts b/web/test/model-picker-groups.test.ts
index 3d4552e3f..0ddf78701 100644
--- a/web/test/model-picker-groups.test.ts
+++ b/web/test/model-picker-groups.test.ts
@@ -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: [
diff --git a/web/test/model-picker-prices.test.tsx b/web/test/model-picker-prices.test.tsx
new file mode 100644
index 000000000..75ee6628b
--- /dev/null
+++ b/web/test/model-picker-prices.test.tsx
@@ -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();
+ 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();
+ 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( {}} />);
+ 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);
+});