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); +});