feat(models): 统一模型规格价格与路由
This commit is contained in:
@@ -3,9 +3,9 @@ import { ConfigProvider, Switch } from "antd";
|
||||
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
|
||||
import { normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { mergedImageCapabilityConfig } from "@/lib/model-selection";
|
||||
import { type AiConfig } from "@/stores/use-config-store";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
@@ -74,7 +74,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
: imageAspectOptions(profile);
|
||||
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio);
|
||||
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
|
||||
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
|
||||
const priceTiers = imageModelPriceTiers(config);
|
||||
const selectAspect = (value: string) => {
|
||||
const option = availableAspects.find((item) => item.value === value);
|
||||
onConfigChange("size", option ? imageOptionValue(profile, option) : "auto");
|
||||
@@ -110,8 +111,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
{profile.quality.supported ? <div className="space-y-2">
|
||||
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
|
||||
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
|
||||
{activeQualityOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{activeQualityOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={quality === item.value} disabled={!hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
@@ -262,12 +263,27 @@ export function imageSizeLabel(size: string) {
|
||||
return resolutionLabel !== size ? resolutionLabel : aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size;
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
function imageModelPriceTiers(config: AiConfig) {
|
||||
const channel = resolveModelChannel(config, config.model || config.imageModel);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model || config.imageModel));
|
||||
return cost?.logicalPriceTiers || [];
|
||||
}
|
||||
|
||||
function hasPriceTierForImageSelection(tiers: ReturnType<typeof imageModelPriceTiers>, quality: string, size: string) {
|
||||
if (!tiers.length) return true;
|
||||
return tiers.some((tier) => {
|
||||
const selector = tier.selector || {};
|
||||
return (!selector.quality || selector.quality === "*" || selector.quality === quality.toLowerCase()) && (!selector.size || selector.size === "*" || selector.size === size.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 cursor-pointer rounded-full px-2 text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
className="h-8 cursor-pointer rounded-full px-2 text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
disabled={disabled}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { quoteLogicalModel, type LogicalModelQuote, type ModelRequestIntent } from "@/services/api/logical-models";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
@@ -53,12 +55,31 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
return channelGroups;
|
||||
}, [config, options]);
|
||||
const storedCurrent = value?.trim() || "";
|
||||
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, requirements) || storedCurrent;
|
||||
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
|
||||
const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined;
|
||||
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
|
||||
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
|
||||
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
|
||||
const currentPrice = modelMenuPrice(config, current);
|
||||
const currentPrice = modelMenuPrice(config, current, capability);
|
||||
const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]);
|
||||
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | undefined>();
|
||||
const creationVariant = variant === "creation";
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSelectedPrice || !creditsEnabled || !quoteRequest) {
|
||||
setRouteQuote(undefined);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setRouteQuote(undefined);
|
||||
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
|
||||
.then((payload) => setRouteQuote(payload.quote))
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setRouteQuote(undefined);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [creditsEnabled, quoteRequest, showSelectedPrice]);
|
||||
|
||||
useEffect(() => {
|
||||
const closeOtherPicker = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
|
||||
@@ -141,26 +162,28 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
<div className="grid min-w-0 gap-1">
|
||||
{group.models.map((modelGroup) => {
|
||||
const selected = modelGroup.models.includes(current);
|
||||
const compatibleModel = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined);
|
||||
const model = compatibleModel || modelGroup.models[0];
|
||||
const displayModel = model;
|
||||
const incompatibleReason = compatibleModel ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力,切换后将重置参数";
|
||||
const model = compatibleModelInGroup(config, modelGroup.models, selectionRequirements, selected ? current : undefined);
|
||||
const displayModel = model || (selected ? current : modelGroup.models[0]);
|
||||
const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], selectionRequirements) || "当前输入不符合该模型能力";
|
||||
return (
|
||||
<button
|
||||
key={modelGroup.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={incompatibleReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
|
||||
className="canvas-model-picker-option"
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
aria-disabled={Boolean(disabledReason)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
|
||||
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||||
onClick={() => {
|
||||
if (!model) return;
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={incompatibleReason} />
|
||||
<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>
|
||||
);
|
||||
@@ -206,7 +229,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
<ModelIcon config={config} model={current} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder}</span>
|
||||
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} quote={routeQuote} compact /> : null}
|
||||
</span>
|
||||
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -257,7 +280,7 @@ function ModelLabel({
|
||||
{capabilitySummary}
|
||||
</span>
|
||||
</span>
|
||||
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
|
||||
{showPrice ? <ModelPrice price={modelMenuPrice(config, model, capability, true)} /> : null}
|
||||
{!creationVariant && meta.time ? (
|
||||
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
|
||||
{meta.time}
|
||||
@@ -326,17 +349,24 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
|
||||
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
|
||||
}
|
||||
|
||||
type ModelMenuPrice = { 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): ModelMenuPrice | null | undefined {
|
||||
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false): ModelMenuPrice | null | undefined {
|
||||
if (!model) return undefined;
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||||
if (!cost) return channel.scope === "system" ? null : undefined;
|
||||
if (cost.billingMode === "token") {
|
||||
return { value: (cost.outputTokenPriceMicrocredits || 0) / 1_000_000, unit: "百万 Token" };
|
||||
if (cost.pricePolicy === "channel") {
|
||||
const tiers = cost.logicalPriceTiers || [];
|
||||
if (!tiers.length) return null;
|
||||
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config);
|
||||
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
|
||||
}
|
||||
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
|
||||
if (cost.billingMode === "token") return { kind: "estimate" };
|
||||
return { kind: "fixed", value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
|
||||
}
|
||||
|
||||
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
|
||||
@@ -349,9 +379,131 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM
|
||||
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
|
||||
}
|
||||
|
||||
function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) {
|
||||
function priceTiersForCurrentSelection(
|
||||
tiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
capability: ModelCapability | undefined,
|
||||
config: AiConfig,
|
||||
) {
|
||||
const requested: Record<string, string> = {};
|
||||
if (capability === "video") {
|
||||
const resolution = normalizeTierResolution(config.vquality);
|
||||
if (resolution !== "*") requested.vquality = resolution;
|
||||
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
|
||||
if (seconds > 0) requested.videoSeconds = String(seconds);
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
|
||||
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
|
||||
}
|
||||
let bestScore = -1;
|
||||
let matched: typeof tiers = [];
|
||||
for (const tier of tiers) {
|
||||
const selector = tier.selector || {};
|
||||
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
|
||||
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
|
||||
const score = conditions.length;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
matched = [tier];
|
||||
} else if (score === bestScore) {
|
||||
matched.push(tier);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function normalizeTierResolution(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw || raw === "*") return "*";
|
||||
return `${normalizeVideoResolution(raw)}p`;
|
||||
}
|
||||
|
||||
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
|
||||
? "按量预估"
|
||||
: "未配置";
|
||||
return {
|
||||
kind: "tiers",
|
||||
label,
|
||||
compactLabel: label,
|
||||
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${tierPriceLabel(tier)}`).join(";")}`,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function tierDurationLabel(seconds: number) {
|
||||
return seconds > 0 ? `${seconds} 秒` : "全部时长";
|
||||
}
|
||||
|
||||
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) : "",
|
||||
].filter(Boolean);
|
||||
return details.length ? details.join(" / ") : "默认规格";
|
||||
}
|
||||
|
||||
function tierPriceLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
|
||||
if (tier.billingMode === "token") return "按量预估";
|
||||
return `${formatPriceRange([tier.unitPriceMicrocredits / 1_000_000], tier.billingMode === "per_second" ? "积分/秒" : "积分")}`;
|
||||
}
|
||||
|
||||
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
|
||||
if (quote) {
|
||||
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 });
|
||||
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`}>
|
||||
<Coins className="size-3" />
|
||||
{compact ? label : `${label} 积分`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (price === undefined) return null;
|
||||
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
|
||||
if (price.kind === "tiers") {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={price.title}>
|
||||
<Coins className="size-3" />
|
||||
{compact ? price.compactLabel : price.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (price.kind === "estimate") {
|
||||
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300">按量预估</span>;
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
|
||||
<Coins className="size-3" />
|
||||
@@ -360,6 +512,31 @@ function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null |
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
if (channel.scope !== "system") return undefined;
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
if (!cost?.logicalModelId) return undefined;
|
||||
const input = requirements?.input;
|
||||
const intent: ModelRequestIntent = {
|
||||
capability,
|
||||
operation: requirements?.videoOperation,
|
||||
inputs: {
|
||||
image: (input?.imageCount || 0) + (input?.characterCount || 0),
|
||||
video: input?.videoCount || 0,
|
||||
audio: input?.audioCount || 0,
|
||||
},
|
||||
options: {
|
||||
...modelRequestOptions(config, capability),
|
||||
...(requirements?.options || {}),
|
||||
...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}),
|
||||
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
|
||||
},
|
||||
};
|
||||
return { logicalModelID: cost.logicalModelId, intent };
|
||||
}
|
||||
|
||||
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
|
||||
const name = modelOptionName(model).toLowerCase();
|
||||
if (capability === "image") {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedan
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const sizeOptions = [
|
||||
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
|
||||
@@ -27,11 +27,12 @@ type VideoSettingsPanelProps = {
|
||||
|
||||
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[292px] space-y-3" }: VideoSettingsPanelProps) {
|
||||
const profile = modelCapabilityConfigFor(config, config.model).video!;
|
||||
const priceTiers = modelPriceTiers(config);
|
||||
if (resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-jimeng-video") {
|
||||
return <JiMengVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
return <JiMengVideoSettingsPanel config={config} profile={profile} priceTiers={priceTiers} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
}
|
||||
if (isSeedanceVideoConfig(config)) {
|
||||
return <SeedanceVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
return <SeedanceVideoSettingsPanel config={config} profile={profile} priceTiers={priceTiers} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
}
|
||||
|
||||
const seconds = normalizeVideoDuration(config.videoSeconds);
|
||||
@@ -53,7 +54,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{configuredResolutions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
<OptionPill key={item.value} selected={resolution === item.value} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
@@ -82,7 +83,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
</SettingGroup>
|
||||
{profile.generateAudio.supported || profile.watermark.supported ? <SettingGroup title="输出" color={theme.node.muted}><div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}</div></SettingGroup> : null}
|
||||
</div>
|
||||
@@ -90,7 +91,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
);
|
||||
}
|
||||
|
||||
function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
|
||||
function JiMengVideoSettingsPanel({ config, profile, priceTiers, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig; priceTiers: ReturnType<typeof modelPriceTiers> }) {
|
||||
const seconds = normalizeVideoDuration(config.videoSeconds);
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
@@ -102,14 +103,14 @@ function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, show
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, "*", value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
</SettingGroup>
|
||||
</div>
|
||||
</ImageSettingsTheme>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
|
||||
function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig; priceTiers: ReturnType<typeof modelPriceTiers> }) {
|
||||
const model = modelOptionName(config.model || config.videoModel);
|
||||
const resolution = normalizeSeedanceResolution(config.vquality, model);
|
||||
const ratio = normalizeSeedanceRatio(config.size);
|
||||
@@ -125,7 +126,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{profile.resolutions.map((value) => {
|
||||
const item = { value, label: value.toUpperCase() };
|
||||
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
|
||||
const disabled = (item.value === "1080p" && isSeedanceFastModel(model)) || !hasPriceTierForVideoSelection(priceTiers, item.value, duration);
|
||||
return (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
@@ -158,7 +159,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="时长" color={theme.node.muted}>
|
||||
<VideoDurationControl profile={profile} value={duration} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
<VideoDurationControl profile={profile} value={duration} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
</SettingGroup>
|
||||
<SettingGroup title="输出" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>
|
||||
@@ -256,21 +257,44 @@ function DurationInput({ value, min, max, theme, onChange }: { value: number; mi
|
||||
);
|
||||
}
|
||||
|
||||
function VideoDurationControl({ profile, value, theme, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; onChange: (value: number) => void }) {
|
||||
function VideoDurationControl({ profile, value, theme, disabled, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; disabled?: (value: number) => boolean; onChange: (value: number) => void }) {
|
||||
if (profile.duration.selection === "range") {
|
||||
const min = profile.duration.min || VIDEO_DURATION_MIN;
|
||||
const max = Math.max(min, profile.duration.max || min);
|
||||
const step = Math.max(1, profile.duration.step || 1);
|
||||
const normalized = normalizeDurationValue(value, profile.duration.default, min, max, step);
|
||||
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={onChange} />;
|
||||
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={(next) => { if (!disabled?.(next)) onChange(next); }} />;
|
||||
}
|
||||
|
||||
const options = videoDurationOptions(profile);
|
||||
return <div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(options.length, 4)}, minmax(0, 1fr))` }}>
|
||||
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
|
||||
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} disabled={disabled?.(option)} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function modelPriceTiers(config: AiConfig) {
|
||||
const channel = resolveModelChannel(config, config.model);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model));
|
||||
return cost?.logicalPriceTiers || [];
|
||||
}
|
||||
|
||||
function hasPriceTierForVideoSelection(tiers: ReturnType<typeof modelPriceTiers>, resolution: string, seconds: number) {
|
||||
if (!tiers.length) return true;
|
||||
const normalizedResolution = normalizeTierResolution(resolution);
|
||||
return tiers.some((tier) => {
|
||||
const selector = tier.selector || {};
|
||||
const tierResolution = selector.vquality || tier.resolution;
|
||||
const tierSeconds = selector.videoSeconds ? Number(selector.videoSeconds) : tier.videoSeconds;
|
||||
return (tierResolution === "*" || !tierResolution || normalizeTierResolution(tierResolution) === normalizedResolution) && (!tierSeconds || tierSeconds === seconds);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTierResolution(value: string) {
|
||||
const normalized = normalizeVideoResolution(value);
|
||||
if (/^\d+$/.test(normalized)) return `${normalized}p`;
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function DurationRangeControl({ value, min, max, step, theme, onChange }: { value: number; min: number; max: number; step: number; theme: CanvasTheme; onChange: (value: number) => void }) {
|
||||
return <div className="space-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user