feat: show generation point estimates
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-18 11:58:37 +08:00
parent 799dd3007b
commit fd95b9547e
5 changed files with 380 additions and 82 deletions

View File

@ -192,6 +192,33 @@ const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024;
const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
(typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number],
number
> = {
"480p": 1,
"720p": 1.5,
"1080p": 2,
};
function formatCompactNumber(value: number, fractionDigits = 2) {
if (!Number.isFinite(value)) return "0";
const fixed = value.toFixed(fractionDigits);
return fixed.replace(/\.?0+$/, "");
}
function normalizeAdminMultiplier(value: unknown, fallback = 1) {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) return fallback;
return Math.min(20, Math.max(0.01, Number(parsedValue.toFixed(2))));
}
function roundCreditCost(value: unknown) {
const parsedValue = Number(value);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.max(1, Math.round(parsedValue))
: 0;
}
function createEmptyCanvasSkillTag(): CanvasSkillTag {
return {
@ -448,17 +475,72 @@ function applyRecommendedVideoPricing(config: ImageGenConfig | undefined) {
};
}
function getVideoReferenceDurationCost(config: ImageGenConfig | undefined) {
const durationCostEntries = Object.entries(
config?.creditCostPerVideoByDuration || {}
)
.map(([durationKey, cost]) => ({
duration: Number(durationKey),
cost: roundCreditCost(cost),
}))
.filter((item) => Number.isFinite(item.duration) && item.cost > 0)
.sort((left, right) => left.duration - right.duration);
return (
durationCostEntries[durationCostEntries.length - 1]?.cost ||
roundCreditCost(config?.creditCostPerVideo) ||
recommendedVideoDurationReferenceCosts[
String(
recommendedVideoDurationOptions[recommendedVideoDurationOptions.length - 1]
)
] ||
1
);
}
function getVideoResolutionMultiplier(
config: ImageGenConfig | undefined,
resolution: (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number]
) {
const referenceCost = getVideoReferenceDurationCost(config);
const rawCost =
config?.creditCostPerVideoByResolution?.[resolution] ??
referenceCost * DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS[resolution];
if (referenceCost <= 0) {
return DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS[resolution];
}
return normalizeAdminMultiplier(
Number(rawCost) / referenceCost,
DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS[resolution]
);
}
function getEnhanceProScaleMultiplier(
pricing: PointsSystemConfig["enhanceProPricing"],
factorKey: string,
cost: number
) {
const baseCost = roundCreditCost(pricing.fixedCost);
if (baseCost <= 0) return normalizeAdminMultiplier(factorKey);
return normalizeAdminMultiplier(Number(cost) / baseCost, normalizeAdminMultiplier(factorKey));
}
function applyRecommendedEnhanceProPricing(
pricing: PointsSystemConfig["enhanceProPricing"]
): PointsSystemConfig["enhanceProPricing"] {
const recommended = defaultPointsSystemConfig.enhanceProPricing;
const baseCost = roundCreditCost(pricing.fixedCost || recommended.fixedCost);
return {
...pricing,
chargeMode: recommended.chargeMode,
fixedCost: recommended.fixedCost,
fixedCost: baseCost,
chargeOnlyWhenUpscaling: recommended.chargeOnlyWhenUpscaling,
batchSizeMultiplierEnabled: recommended.batchSizeMultiplierEnabled,
scaleFactorCosts: { ...recommended.scaleFactorCosts },
scaleFactorCosts: Object.fromEntries(
Object.keys(recommended.scaleFactorCosts).map((factorKey) => [
factorKey,
roundCreditCost(baseCost * normalizeAdminMultiplier(factorKey)),
])
),
modelMultipliers: { ...recommended.modelMultipliers },
};
}
@ -2563,6 +2645,9 @@ export function AdminSettingsClient() {
const configuredVideoDurationOptions = getConfiguredVideoDurationOptions(
modelForm.imageGenConfig
);
const videoReferenceDurationCost = getVideoReferenceDurationCost(
modelForm.imageGenConfig
);
const modelKindHint =
(selectedModelProviderType === "runway" || selectedModelProviderType === "krea") &&
!modelForm.modelId.trim()
@ -3002,21 +3087,53 @@ export function AdminSettingsClient() {
});
};
const updateEnhanceProScaleFactorCost = (
factorKey: string,
nextCost: number
) => {
const updateEnhanceProFixedCost = (nextFixedCost: number) => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
const pricing = config.enhanceProPricing;
const fixedCost = roundCreditCost(nextFixedCost);
const scaleFactorCosts = Object.fromEntries(
Object.entries(pricing.scaleFactorCosts).map(([factorKey, cost]) => {
const multiplier = getEnhanceProScaleMultiplier(
pricing,
factorKey,
Number(cost)
);
return [factorKey, roundCreditCost(fixedCost * multiplier)];
})
);
return {
...prev,
pointsSystemConfig: {
...config,
enhanceProPricing: {
...config.enhanceProPricing,
...pricing,
fixedCost,
scaleFactorCosts,
},
},
};
});
};
const updateEnhanceProScaleFactorMultiplier = (
factorKey: string,
nextMultiplier: number
) => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
const pricing = config.enhanceProPricing;
const fixedCost = roundCreditCost(pricing.fixedCost);
const multiplier = normalizeAdminMultiplier(nextMultiplier, Number(factorKey) || 1);
return {
...prev,
pointsSystemConfig: {
...config,
enhanceProPricing: {
...pricing,
scaleFactorCosts: {
...config.enhanceProPricing.scaleFactorCosts,
[factorKey]: Math.max(0, Math.round(nextCost) || 0),
...pricing.scaleFactorCosts,
[factorKey]: roundCreditCost(fixedCost * multiplier),
},
},
},
@ -8531,7 +8648,7 @@ export function AdminSettingsClient() {
图像增强 Pro 积分规则
</Label>
<div className="text-xs text-muted-foreground">
默认按基础 20 积分计算;启用放大后按放大倍数折算,例如 1.25x 为 25 积分、2x 为 40 积分。
按基础积分 × 档位倍率计算;启用放大后可为 1x、1.25x、1.5x、2x、3x、4x 分别设置倍率。
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -8633,19 +8750,9 @@ export function AdminSettingsClient() {
type="number"
value={pointsSystemConfig.enhanceProPricing.fixedCost}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
fixedCost: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
),
},
},
})
updateEnhanceProFixedCost(
Number.parseInt(e.target.value || "0", 10) || 0
)
}
/>
</div>
@ -8681,34 +8788,52 @@ export function AdminSettingsClient() {
<div className="space-y-3 rounded-lg border p-3">
<div className="space-y-1">
<Label>放大倍率积分</Label>
<Label>放大倍率档位</Label>
<div className="text-xs text-muted-foreground">
“放大倍率”计费模式下优先使用精确档位;未配置的小数倍率按基础价 × 倍数自动计算。
“放大倍率”计费模式下按基础积分 × 档位倍率计算;未配置的小数倍率也会按基础积分 × 倍率自动估算。
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{Object.entries(
pointsSystemConfig.enhanceProPricing.scaleFactorCosts
)
.sort(([left], [right]) => Number(left) - Number(right))
.map(([factorKey, cost]) => (
<div key={factorKey} className="space-y-1">
<Label className="text-xs text-muted-foreground">
{factorKey}x
</Label>
<Input
min={0}
type="number"
value={cost}
onChange={(e) =>
updateEnhanceProScaleFactorCost(
factorKey,
Number.parseInt(e.target.value || "0", 10) || 0
)
}
/>
</div>
))}
.map(([factorKey, cost]) => {
const multiplier = getEnhanceProScaleMultiplier(
pointsSystemConfig.enhanceProPricing,
factorKey,
cost
);
const exactCost =
roundCreditCost(
pointsSystemConfig.enhanceProPricing.fixedCost
) * multiplier;
return (
<div key={factorKey} className="space-y-1">
<Label className="text-xs text-muted-foreground">
{factorKey}x 倍率
</Label>
<Input
min={0}
step={0.05}
type="number"
value={formatCompactNumber(multiplier)}
onChange={(e) =>
updateEnhanceProScaleFactorMultiplier(
factorKey,
Number(e.target.value || factorKey)
)
}
/>
<div className="text-[11px] text-muted-foreground">
该档积分:{formatCompactNumber(exactCost)}
{roundCreditCost(exactCost) !== exactCost
? `,扣 ${roundCreditCost(exactCost)}`
: ""}
</div>
</div>
);
})}
</div>
</div>
@ -12319,8 +12444,8 @@ export function AdminSettingsClient() {
推荐视频积分规则
</div>
<p className="text-xs text-muted-foreground">
按 5/10/15 秒 = 1/1.5/2 倍,480p/720p/1080p 基础价 =
100/200/250 积分计算。
按 5/10/15 秒 = 165/350/600 积分,480p/720p/1080p =
600 × 1/1.5/2 倍计算。
</p>
</div>
<Button
@ -12428,8 +12553,8 @@ export function AdminSettingsClient() {
})}
</div>
<p className="text-xs text-muted-foreground">
系统会把最短秒数的参考积分作为 1
倍,其它秒数按比例乘下方分辨率基础价。
系统会把最长秒数的参考积分作为 1
倍,其它秒数按比例乘下方分辨率倍率。
</p>
</div>
) : (
@ -12439,8 +12564,15 @@ export function AdminSettingsClient() {
)}
<div className="space-y-3 border-t pt-4">
<div className="text-sm font-medium">
分辨率基础积分
<div className="space-y-1">
<div className="text-sm font-medium">
分辨率倍率
</div>
<p className="text-xs text-muted-foreground">
当前按 {videoReferenceDurationCost} 积分作为 1 倍参考;例如 720p
填 1.5 时,该分辨率基准为{" "}
{formatCompactNumber(videoReferenceDurationCost * 1.5)} 积分。
</p>
</div>
<div className="grid grid-cols-3 gap-4">
{VIDEO_PRICING_PREVIEW_RESOLUTIONS.map(
@ -12449,27 +12581,25 @@ export function AdminSettingsClient() {
key={resolutionKey}
className="space-y-2"
>
<Label>{resolutionKey} 基础积分</Label>
<Label>{resolutionKey} 倍率</Label>
<Input
type="number"
min={0}
step={1}
step={0.1}
value={
modelForm.imageGenConfig
?.creditCostPerVideoByResolution?.[
resolutionKey
] ??
modelForm.imageGenConfig
?.creditCostPerVideo ??
0
formatCompactNumber(
getVideoResolutionMultiplier(
modelForm.imageGenConfig,
resolutionKey
)
)
}
onChange={(e) => {
const nextValue = Math.max(
0,
Number.parseInt(
e.target.value,
10
) || 0
const multiplier = normalizeAdminMultiplier(
e.target.value,
DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS[
resolutionKey
]
);
setModelForm({
...modelForm,
@ -12479,18 +12609,39 @@ export function AdminSettingsClient() {
...(modelForm.imageGenConfig
?.creditCostPerVideoByResolution ||
{}),
[resolutionKey]: nextValue,
[resolutionKey]: roundCreditCost(
videoReferenceDurationCost *
multiplier
),
},
},
});
}}
/>
<p className="text-[11px] text-muted-foreground">
{videoReferenceDurationCost} ×{" "}
{formatCompactNumber(
getVideoResolutionMultiplier(
modelForm.imageGenConfig,
resolutionKey
)
)}{" "}
={" "}
{roundCreditCost(
videoReferenceDurationCost *
getVideoResolutionMultiplier(
modelForm.imageGenConfig,
resolutionKey
)
)}{" "}
积分
</p>
</div>
)
)}
</div>
<p className="text-xs text-muted-foreground">
配置后按用户选择的视频分辨率取基础价,再叠加上方秒数倍率;未命中再回退到秒数或统一价格。
配置后按用户选择的视频分辨率倍率取基准价,再叠加上方秒数比例;未命中再回退到秒数或统一价格。
</p>
</div>

View File

@ -66,6 +66,7 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability";
import type { Attachment, ChatMessage } from "@/lib/types";
import type {
@ -2916,6 +2917,10 @@ export function CanvasChatPanel({
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
creditCostPerVideoByResolution?: Record<string, number>;
} | null;
}>;
agentModel?: {
@ -2938,6 +2943,10 @@ export function CanvasChatPanel({
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
creditCostPerVideoByResolution?: Record<string, number>;
} | null;
} | null;
}>("/api/models", fetcher);
@ -7827,6 +7836,51 @@ export function CanvasChatPanel({
membershipSummary?.summary?.currentCredits,
]);
const estimatedPointsCost = useMemo(() => {
if (membershipSummary?.isHighVip) return null;
if (membershipSummary?.pointsSystemConfig?.enabled !== true) return null;
if (activeComposerMode === "agent") return null;
if (isVideoModel) {
const cost = getVideoCreditCostFromConfig(
currentModel?.imageGenConfig,
videoParams?.duration,
videoParams?.resolution
);
return cost > 0
? {
cost,
title: `本次预计消耗 ${cost} 积分`,
}
: null;
}
const unitCost = Math.max(
0,
Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0))
);
const imageCount = Math.max(
1,
Math.round(Number(imageParams?.imageCount || 1))
);
const cost = unitCost * imageCount;
return cost > 0
? {
cost,
title: `本次预计消耗 ${cost} 积分(${unitCost} × ${imageCount} 张)`,
}
: null;
}, [
activeComposerMode,
currentModel?.imageGenConfig,
imageParams?.imageCount,
isVideoModel,
membershipSummary?.isHighVip,
membershipSummary?.pointsSystemConfig?.enabled,
videoParams?.duration,
videoParams?.resolution,
]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
const currentSendGuard = getSendGuard(activeChatId);
@ -11478,6 +11532,24 @@ export function CanvasChatPanel({
</Select>
) : null}
{estimatedPointsCost ? (
<div
className={cn(
"flex h-8 items-center gap-1.5 rounded-full px-2.5 text-xs",
isVideoWorkspace
? "border border-border/60 bg-background text-muted-foreground"
: "bg-muted text-muted-foreground"
)}
title={estimatedPointsCost.title}
>
<Sparkles className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">
{estimatedPointsCost.cost}
</span>
<span>积分</span>
</div>
) : null}
<Button
size={isWorkspacePresentation ? "default" : "icon"}
className={cn(

View File

@ -529,6 +529,11 @@ export function Chat({
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
creditCostPerVideoByResolution?: Record<string, number>;
} | null;
}>;
}>("/api/models", fetcher);

View File

@ -7,6 +7,7 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
// 动态模型类型定义
type DynamicModel = {
@ -36,6 +37,10 @@ type DynamicModel = {
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
creditCostPerVideoByResolution?: Record<string, number>;
} | null;
};
@ -206,6 +211,10 @@ function PureMultimodalInput({
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
creditCostPerVideoByResolution?: Record<string, number>;
} | null;
}) {
// 🔍 调试日志:检查图片模型相关props
@ -313,6 +322,51 @@ function PureMultimodalInput({
const showQuickModeSwitch = Boolean(quickSwitchTargets.image || quickSwitchTargets.video);
const estimatedPointsCost = useMemo(() => {
if (membershipSummary?.isHighVip) return null;
if (membershipSummary?.pointsSystemConfig?.enabled !== true) return null;
if (isVideoModel) {
const cost = getVideoCreditCostFromConfig(
imageGenConfig,
videoParams?.duration,
videoParams?.resolution
);
return cost > 0
? {
cost,
title: `本次预计消耗 ${cost} 积分`,
}
: null;
}
if (!isImageModel) return null;
const unitCost = Math.max(
0,
Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0))
);
const imageCount = Math.max(
1,
Math.round(Number(imageParams?.imageCount || 1))
);
const cost = unitCost * imageCount;
return cost > 0
? {
cost,
title: `本次预计消耗 ${cost} 积分(${unitCost} × ${imageCount} 张)`,
}
: null;
}, [
imageGenConfig,
imageParams?.imageCount,
isImageModel,
isVideoModel,
membershipSummary?.isHighVip,
membershipSummary?.pointsSystemConfig?.enabled,
videoParams?.duration,
videoParams?.resolution,
]);
// 每分钟更新一次检查
useEffect(() => {
if (!siteSettings?.usageTimeEnabled) return;
@ -1349,14 +1403,28 @@ function PureMultimodalInput({
{status === "submitted" || status === "streaming" ? (
<StopButton setMessages={setMessages} stop={stop} />
) : (
<PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0 || !isWithinUsageTime}
status={status}
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
<>
{estimatedPointsCost ? (
<div
className="flex h-8 items-center gap-1.5 rounded-full bg-muted px-2.5 text-xs text-muted-foreground"
title={estimatedPointsCost.title}
>
<Sparkles className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">
{estimatedPointsCost.cost}
</span>
<span>积分</span>
</div>
) : null}
<PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0 || !isWithinUsageTime}
status={status}
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
</>
)}
</PromptInputToolbar>
</PromptInput>

View File

@ -3,14 +3,14 @@ export type VideoResolutionCostMap = Record<string, number>;
export const recommendedVideoDurationOptions = [5, 10, 15] as const;
export const recommendedVideoDurationReferenceCosts: VideoDurationCostMap = {
"5": 100,
"10": 150,
"15": 200,
"5": 165,
"10": 350,
"15": 600,
};
export const recommendedVideoResolutionBaseCosts: VideoResolutionCostMap = {
"480p": 100,
"720p": 200,
"1080p": 250,
"480p": 600,
"720p": 900,
"1080p": 1200,
};
export type VideoImageGenConfigLike = {
@ -172,7 +172,9 @@ export function getVideoCreditCostFromConfig(
const resolutionBaseCost = resolutionCostMap[normalizedResolution] || 0;
const durationCost = getDurationCost();
const baseDurationCost =
durationCostEntries.find((item) => item.cost > 0)?.cost ?? null;
[...durationCostEntries]
.reverse()
.find((item) => item.cost > 0)?.cost ?? null;
if (durationCost !== null && baseDurationCost && baseDurationCost > 0) {
return Math.max(