fix(web): 模型价格 - 合并 PR #372 并修复零积分展示

This commit is contained in:
ddCat
2026-09-01 19:10:23 +08:00
5 changed files with 112 additions and 71 deletions

View File

@@ -10,7 +10,7 @@
"pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
"typecheck": "tsc --noEmit",
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts",
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",

View File

@@ -4,7 +4,7 @@ import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection } from "@/lib/model-pricing";
import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
@@ -29,7 +29,21 @@ type ModelPickerProps = {
showConfiguredModelName?: boolean;
};
export function ModelPicker({ config, value, onChange, capability, className, popoverClassName, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements, showConfiguredModelName = false }: ModelPickerProps) {
export function ModelPicker({
config,
value,
onChange,
capability,
className,
popoverClassName,
fullWidth = false,
placeholder = "选择模型",
onMissingConfig,
showSelectedPrice = true,
variant = "default",
requirements,
showConfiguredModelName = false,
}: ModelPickerProps) {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const pickerId = useId();
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
@@ -62,7 +76,7 @@ export function ModelPicker({ config, value, onChange, capability, className, po
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current, capability, false, requirements);
const currentPrice = modelMenuPrice(config, current, capability, false, requirements);
const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]);
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | undefined>();
const creationVariant = variant === "creation";
@@ -151,11 +165,13 @@ export function ModelPicker({ config, value, onChange, capability, className, po
ref={menuRef}
data-canvas-no-zoom
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[var(--panel-width-compact)]")}
style={{
background: theme.node.panel,
color: theme.node.text,
"--canvas-model-picker-trigger-width": triggerWidth ? String(triggerWidth) + "px" : undefined,
} as CSSProperties}
style={
{
background: theme.node.panel,
color: theme.node.text,
"--canvas-model-picker-trigger-width": triggerWidth ? String(triggerWidth) + "px" : undefined,
} as CSSProperties
}
role="listbox"
aria-label={placeholder}
onKeyDown={handleMenuKeyDown}
@@ -173,7 +189,11 @@ export function ModelPicker({ config, value, onChange, capability, className, po
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
<span className="truncate">{group.label}</span>
{group.scope ? <span className="shrink-0" style={{ color: theme.node.muted }}>{group.scope}</span> : null}
{group.scope ? (
<span className="shrink-0" style={{ color: theme.node.muted }}>
{group.scope}
</span>
) : null}
</div>
<div className="grid min-w-0 gap-1">
{group.models.map((modelGroup) => {
@@ -199,7 +219,16 @@ export function ModelPicker({ config, value, onChange, capability, className, po
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={disabledReason} />
<ModelLabel
config={config}
model={displayModel}
capability={capability}
theme={theme}
creationVariant={creationVariant}
showConfiguredModelName={showConfiguredModelName}
showPrice={creditsEnabled}
disabledReason={disabledReason}
/>
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
@@ -284,7 +313,10 @@ function ModelLabel({
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
const logicalSpec = logicalCost?.logicalCapabilitySpec;
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
const capabilitySummary = disabledReason || logicalCost?.description?.trim() || (logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
const capabilitySummary =
disabledReason ||
logicalCost?.description?.trim() ||
(logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
return (
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
@@ -366,10 +398,7 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
}
type ModelMenuPrice =
| { kind: "tiers"; label: string; compactLabel: string; title: string }
| { kind: "estimate" }
| { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
type ModelMenuPrice = { kind: "tiers"; label: string; compactLabel: string; title: string } | { kind: "estimate" } | { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false, requirements?: ModelRequirements): ModelMenuPrice | null | undefined {
if (!model) return undefined;
@@ -379,7 +408,7 @@ function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapab
if (cost.pricePolicy === "channel") {
const tiers = cost.logicalPriceTiers || [];
if (!tiers.length) return null;
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements);
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements);
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
}
if (cost.billingMode === "token") return { kind: "estimate" };
@@ -400,22 +429,7 @@ function channelTierPriceSummary(
visibleTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
): Extract<ModelMenuPrice, { kind: "tiers" }> {
const fixedRequestValues = visibleTiers
.filter((tier) => tier.billingMode === "fixed_request")
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => value > 0);
const perSecondValues = visibleTiers
.filter((tier) => tier.billingMode === "per_second")
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => value > 0);
const hasTokenTier = visibleTiers.some((tier) => tier.billingMode === "token");
const label = fixedRequestValues.length
? formatPriceRange(fixedRequestValues, "积分")
: perSecondValues.length
? formatPriceRange(perSecondValues, "积分/秒")
: hasTokenTier
? "按量预估"
: "未配置";
const label = priceTierSummaryLabel(visibleTiers);
return {
kind: "tiers",
label,
@@ -424,12 +438,6 @@ function channelTierPriceSummary(
};
}
function formatPriceRange(values: number[], suffix: string) {
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
}
function tierResolutionLabel(value: string) {
const normalized = normalizeTierResolution(value);
return normalized === "*" ? "全部分辨率" : normalized.toUpperCase();
@@ -441,17 +449,17 @@ function tierDurationLabel(seconds: number) {
function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
const selector = tier.selector || {};
const operationLabels: Record<string, string> = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频" };
const operation = selector.operation && selector.operation !== "*" ? (operationLabels[selector.operation] || selector.operation) : "";
const details = [
operation,
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
].filter(Boolean);
return details.length ? details.join(" / ") : "默认规格";
const operationLabels: Record<string, string> = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频" };
const operation = selector.operation && selector.operation !== "*" ? operationLabels[selector.operation] || selector.operation : "";
const details = [
operation,
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
].filter(Boolean);
return details.length ? details.join(" / ") : "默认规格";
}
function tierPriceLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {

View File

@@ -51,6 +51,25 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit
return matched;
}
export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
const unitPrices = (billingMode: ModelPriceTier["billingMode"]) =>
tiers
.filter((tier) => tier.billingMode === billingMode)
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => Number.isFinite(value) && value >= 0);
const fixedRequestValues = unitPrices("fixed_request");
const perSecondValues = unitPrices("per_second");
if (fixedRequestValues.length) return formatPriceRange(fixedRequestValues, "积分");
if (perSecondValues.length) return formatPriceRange(perSecondValues, "积分/秒");
return tiers.some((tier) => tier.billingMode === "token") ? "按量预估" : "未配置";
}
export function formatPriceRange(values: number[], suffix: string) {
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
}
export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
if (!capability || !value) return undefined;
const channel = resolveModelChannel(config, value);

View File

@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { defaultConfig, type AiConfig } from "../src/stores/use-config-store";
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
import { runBackendGenerationTask, runBackendGenerationTaskBatch } from "../src/services/api/generation-task";
import { deleteGenerationTask, formatTaskLog, listGenerationTasks, projectBackendSafeTaskLog, splitGenerationTaskObservationIds, type GenerationTask } from "../src/services/api/task-center";
import { isLocalDreaminaBackgroundTask, localDreaminaCancellationCopy, localDreaminaDetachOutcome, projectLocalDreaminaTask } from "../src/services/local-dreamina-task-projection";
@@ -24,24 +24,25 @@ function sourceSection(source: string, startMarker: string, endMarker: string) {
return compactSource(source.slice(start, end));
}
function remoteBackendConfig(model: string, overrides: Partial<AiConfig> = {}): AiConfig {
const modelName = model.includes("::") ? model.slice(model.indexOf("::") + 2) : model;
function backendModelConfig(model: string) {
const channel = createModelChannel({
id: "system-test-channel",
name: "系统测试渠道",
baseUrl: "/api/system-test-channel",
apiKey: "system",
apiFormat: "openai",
scope: "system",
models: [model],
});
const selectedModel = `${channel.id}::${model}`;
return {
...defaultConfig,
...overrides,
model,
channels: [
{
id: "default",
name: "Backend test channel",
baseUrl: "https://api.example.com",
apiKey: "test-key",
apiFormat: "openai",
interfaceType: "newapi-channel-2",
models: [modelName],
scope: "user",
},
],
channelMode: "remote" as const,
channels: [channel],
model: selectedModel,
imageModel: selectedModel,
videoModel: selectedModel,
audioModel: selectedModel,
};
}
@@ -1307,7 +1308,7 @@ test("remote provider keeps Create resolution semantics and still creates one Ba
{
mode: "video",
prompt: "A remote test clip",
config: remoteBackendConfig("default::grok-imagine-video", { vquality: "720", quality: "auto" }),
config: { ...backendModelConfig("grok-imagine-video"), vquality: "720", quality: "auto" },
},
{
createTask: async (input) => {
@@ -1375,7 +1376,7 @@ test("remote image video and audio references keep Backend parity without Dreami
{
mode: item.mode,
prompt: "Remote parity fixture",
config: remoteBackendConfig("default::provider-neutral-model"),
config: backendModelConfig("provider-neutral-model"),
...item.references,
},
{
@@ -1694,7 +1695,7 @@ test("Create audio upload converts, previews, removes, and submits through the s
projectId: running.projectId,
mode: "video",
prompt: running.prompt,
config: remoteBackendConfig("remote-video-audio-fixture", { videoModel: "remote-video-audio-fixture" }),
config: backendModelConfig("remote-video-audio-fixture"),
...references,
} as Parameters<typeof runBackendGenerationTask>[0],
{

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import { modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import type { ModelRequirements } from "../src/lib/model-selection";
import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store";
@@ -86,6 +86,19 @@ describe("model request pricing", () => {
).toBe(0.125);
});
test("displays zero-price system tiers as configured free pricing", () => {
const fixedConfig = systemConfig({
capability: "image",
tiers: [{ selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
});
const perSecondConfig = systemConfig({
tiers: [{ selector: {}, billingMode: "per_second", unitPriceMicrocredits: 0 }],
});
expect(priceTierSummaryLabel(resolveModelChannel(fixedConfig, fixedConfig.model).modelCosts![0]!.logicalPriceTiers!)).toBe("0 积分");
expect(priceTierSummaryLabel(resolveModelChannel(perSecondConfig, perSecondConfig.model).modelCosts![0]!.logicalPriceTiers!)).toBe("0 积分/秒");
});
test("uses reference count and operation to avoid a text-video price tier", () => {
const config = systemConfig({
tiers: [