feat(web): 增加参数即时估价与显式免费编辑

估价请求统一使用 350ms 防抖、AbortController 和请求序号,区分免费、计算中、价格不可用与失败状态,并展示候选冻结上限。价格未就绪时阻止生产提交。\n\n计价规则编辑器新增显式免费开关和 9 位价格步进。\n\n验证:Web 88 项测试和生产构建通过。
This commit is contained in:
2026-07-20 23:22:50 +08:00
parent 5114686c35
commit 7cea21f765
5 changed files with 193 additions and 21 deletions
+100 -16
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { GatewayApiKey, GatewayPricingEstimate, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react';
import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import type { PlaygroundMode } from '../types';
import {
PlaygroundPromptMentionInput,
@@ -56,13 +56,17 @@ import {
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
const MEDIA_RUNS_STORAGE_LIMIT = 50;
export const MEDIA_ESTIMATE_DEBOUNCE_MS = 350;
type MediaEstimateState = {
amount?: number;
reservationAmount?: number;
candidateCount?: number;
currency?: string;
error?: string;
pricingVersion?: string;
resolver?: string;
status: 'idle' | 'loading' | 'ready' | 'error';
status: 'idle' | 'loading' | 'ready' | 'free' | 'unavailable' | 'error';
};
const publicWorks = [
@@ -107,6 +111,7 @@ export function PlaygroundPage(props: {
const [settingsOpen, setSettingsOpen] = useState(false);
const isMountedRef = useRef(false);
const pendingMediaModelRef = useRef('');
const mediaEstimateSequenceRef = useRef(0);
const resumedTaskIdsRef = useRef(new Set<string>());
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
const mediaUploadAcceptValue = sharedMediaUploadAcceptForMode(props.mode, videoMode);
@@ -136,6 +141,10 @@ export function PlaygroundPage(props: {
supportsQualityControl: mediaCapabilities?.supportsQualityControl,
});
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
const mediaEstimateSignature = useMemo(
() => billingEstimateSignature(mediaEstimatePayload),
[mediaEstimatePayload],
);
useEffect(() => {
setSelectedModel((current) => {
@@ -176,30 +185,34 @@ export function PlaygroundPage(props: {
useEffect(() => {
const credential = activeApiKeySecret || props.token;
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
mediaEstimateSequenceRef.current += 1;
setMediaEstimate({ status: 'idle' });
return;
}
let cancelled = false;
const sequence = mediaEstimateSequenceRef.current + 1;
mediaEstimateSequenceRef.current = sequence;
const controller = new AbortController();
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
const timer = window.setTimeout(() => {
estimatePricing(credential, mediaEstimatePayload)
estimatePricing(credential, mediaEstimatePayload, controller.signal)
.then((estimate) => {
if (cancelled) return;
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
setMediaEstimate(mediaEstimateFromResponse(estimate));
})
.catch((err) => {
if (cancelled) return;
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
const unavailable = err instanceof GatewayApiError && err.details.code === 'pricing_unavailable';
setMediaEstimate({
error: err instanceof Error ? err.message : '预计扣费计算失败',
status: 'error',
error: err instanceof Error ? err.message : unavailable ? '价格不可用' : '预计扣费计算失败',
status: unavailable ? 'unavailable' : 'error',
});
});
}, 260);
}, MEDIA_ESTIMATE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
controller.abort();
};
}, [activeApiKeySecret, mediaEstimatePayload, props.mode, props.token]);
}, [activeApiKeySecret, mediaEstimateSignature, props.mode, props.token]);
useEffect(() => {
if (props.mode === 'image') {
@@ -313,6 +326,12 @@ export function PlaygroundPage(props: {
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
return;
}
if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate)) {
setMediaMessage(mediaEstimate.status === 'unavailable'
? '当前参数没有可用价格,已阻止生产生成。'
: '请等待当前参数的预计费用计算完成后再生成。');
return;
}
const localId = newLocalId();
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
@@ -456,6 +475,7 @@ export function PlaygroundPage(props: {
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
mediaEstimate={mediaEstimate}
submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate)}
mediaCapabilities={mediaCapabilities}
uploadAccept={mediaUploadAcceptValue}
uploadMessage={mediaUploadMessage}
@@ -684,6 +704,7 @@ function Composer(props: {
imageHasReference?: boolean;
mediaCapabilities?: MediaModelCapabilities;
mediaEstimate?: MediaEstimateState;
submitDisabled?: boolean;
mediaSettings?: MediaGenerationSettings;
mode: PlaygroundMode;
modelOptions: ModelOption[];
@@ -791,7 +812,7 @@ function Composer(props: {
<span>{mediaEstimateText(props.mediaEstimate)}</span>
</span>
)}
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" onClick={props.onSubmit}>
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" disabled={props.submitDisabled} onClick={props.onSubmit}>
<ArrowUp size={24} />
</Button>
</div>
@@ -828,12 +849,17 @@ function buildMediaEstimatePayload(
};
}
function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
export function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
const amount = numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items);
const explicitFree = amount === 0 && response.items.length > 0 && response.items.every((item) => item.isFree === true);
return {
amount: numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items),
amount,
reservationAmount: numericFromUnknown(response.reservationAmount),
candidateCount: response.candidateCount,
currency: response.currency || estimateItemsCurrency(response.items),
pricingVersion: response.pricingVersion,
resolver: response.resolver,
status: 'ready',
status: explicitFree ? 'free' : 'ready',
};
}
@@ -847,21 +873,79 @@ function estimateItemsCurrency(items: GatewayPricingEstimate['items']) {
}
function mediaEstimateText(estimate: MediaEstimateState) {
if (estimate.status === 'loading') return '计算中';
if (estimate.status === 'unavailable') return '价格不可用';
if (estimate.status === 'error') return '估价失败';
if (estimate.status === 'free') return '免费';
if (estimate.amount === undefined) return '--';
return formatEstimateAmount(estimate.amount);
const amount = formatEstimateAmount(estimate.amount);
const reservation = estimate.reservationAmount;
if (reservation !== undefined && reservation > estimate.amount) {
return `预计 ${amount} · 冻结上限 ${formatEstimateAmount(reservation)}`;
}
return `预计 ${amount}`;
}
function mediaEstimateHint(estimate: MediaEstimateState) {
if (estimate.status === 'unavailable' && estimate.error) return `价格不可用:${estimate.error}`;
if (estimate.status === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`;
if (estimate.status === 'free') return '当前计价规则已显式标记为免费';
return '扣费为预计,实际扣费以账单为准';
}
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败';
if (estimate.status === 'unavailable') return estimate.error ? `价格不可用:${estimate.error}` : '价格不可用';
if (estimate.status === 'free') return '当前请求免费';
if (estimate.amount === undefined) return '预计扣费估算中';
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
}
function mediaEstimateAllowsProduction(estimate: MediaEstimateState) {
return estimate.status === 'ready' || estimate.status === 'free';
}
export function billingEstimateSignature(payload: Record<string, unknown> | null) {
if (!payload) return '';
const content = Array.isArray(payload.content)
? payload.content.map((item) => {
const record = item && typeof item === 'object' ? item as Record<string, unknown> : {};
return {
duration: record.duration,
role: record.role,
type: record.type,
hasAudio: Boolean(record.audio_url),
hasImage: Boolean(record.image_url),
hasVideo: Boolean(record.video_url),
};
})
: undefined;
const signature = {
kind: payload.kind,
model: payload.model,
n: payload.n ?? payload.count ?? payload.batch_size ?? payload.batchSize,
quality: payload.quality,
size: payload.size,
resolution: payload.resolution,
duration: payload.duration,
audio: payload.audio,
maxCompletionTokens: payload.max_completion_tokens,
maxOutputTokens: payload.max_output_tokens,
maxTokens: payload.max_tokens,
content,
imageCount: pricingReferenceCount(payload, ['image', 'images', 'image_url', 'image_urls', 'referenceImage', 'reference_image']),
};
return JSON.stringify(signature);
}
function pricingReferenceCount(payload: Record<string, unknown>, keys: string[]) {
return keys.reduce((count, key) => {
const value = payload[key];
if (Array.isArray(value)) return count + value.length;
return count + (value ? 1 : 0);
}, 0);
}
function formatEstimateAmount(value: number) {
if (!Number.isFinite(value)) return '--';
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;