feat: add gateway billing estimate and rate limit details

This commit is contained in:
2026-05-15 01:53:52 +08:00
parent bdc9be63d5
commit 37d0f919e5
14 changed files with 916 additions and 66 deletions
+127 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
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, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import type { PlaygroundMode } from '../types';
import {
PlaygroundPromptMentionInput,
@@ -57,6 +57,14 @@ import {
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
const MEDIA_RUNS_STORAGE_LIMIT = 50;
type MediaEstimateState = {
amount?: number;
currency?: string;
error?: string;
resolver?: string;
status: 'idle' | 'loading' | 'ready' | 'error';
};
const publicWorks = [
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
@@ -94,6 +102,7 @@ export function PlaygroundPage(props: {
const [mediaMessage, setMediaMessage] = useState('');
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
const [mediaEstimate, setMediaEstimate] = useState<MediaEstimateState>({ status: 'idle' });
const [mediaUploading, setMediaUploading] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const isMountedRef = useRef(false);
@@ -118,6 +127,13 @@ export function PlaygroundPage(props: {
: undefined,
[activeModelOption, mediaSettings.resolution, props.mode, videoMode],
);
const mediaEstimatePayload = useMemo(() => {
if (props.mode === 'chat' || !selectedModel) return null;
const normalizedSettings = mediaCapabilities
? normalizeMediaSettingsForCapabilities(mediaSettings, mediaCapabilities, props.mode)
: mediaSettings;
return buildMediaEstimatePayload(props.mode, selectedModel, prompt, normalizedSettings, mediaUploads, videoMode);
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
useEffect(() => {
setSelectedModel((current) => {
@@ -155,6 +171,34 @@ export function PlaygroundPage(props: {
setPrompt((current) => removeInvalidPlaygroundResourceTokens(current, mediaUploads));
}, [mediaUploadSignature, props.mode]);
useEffect(() => {
const credential = activeApiKeySecret || props.token;
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
setMediaEstimate({ status: 'idle' });
return;
}
let cancelled = false;
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
const timer = window.setTimeout(() => {
estimatePricing(credential, mediaEstimatePayload)
.then((estimate) => {
if (cancelled) return;
setMediaEstimate(mediaEstimateFromResponse(estimate));
})
.catch((err) => {
if (cancelled) return;
setMediaEstimate({
error: err instanceof Error ? err.message : '预计扣费计算失败',
status: 'error',
});
});
}, 260);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeApiKeySecret, mediaEstimatePayload, props.mode, props.token]);
useEffect(() => {
if (props.mode === 'image') {
setMediaUploads((current) => current.some((item) => item.kind !== 'image') ? current.filter((item) => item.kind === 'image') : current);
@@ -404,6 +448,7 @@ export function PlaygroundPage(props: {
selectedModel={selectedModel}
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
mediaEstimate={mediaEstimate}
mediaCapabilities={mediaCapabilities}
uploadAccept={mediaUploadAcceptValue}
uploadMessage={mediaUploadMessage}
@@ -631,6 +676,7 @@ function Composer(props: {
compact?: boolean;
imageHasReference?: boolean;
mediaCapabilities?: MediaModelCapabilities;
mediaEstimate?: MediaEstimateState;
mediaSettings?: MediaGenerationSettings;
mode: PlaygroundMode;
modelOptions: ModelOption[];
@@ -727,10 +773,17 @@ function Composer(props: {
onChange={props.onMediaSettingsChange}
/>
)}
<span className="composerEstimatedCharge" aria-label="预计扣费 1 / 张">
<Sparkles size={14} />
<span>1 / </span>
</span>
{props.mode !== 'chat' && props.mediaEstimate && (
<span
className="composerEstimatedCharge"
data-state={props.mediaEstimate.status}
title={mediaEstimateHint(props.mediaEstimate)}
aria-label={mediaEstimateAriaLabel(props.mediaEstimate)}
>
<Sparkles size={14} />
<span>{mediaEstimateText(props.mediaEstimate)}</span>
</span>
)}
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" onClick={props.onSubmit}>
<ArrowUp size={24} />
</Button>
@@ -739,6 +792,74 @@ function Composer(props: {
);
}
function buildMediaEstimatePayload(
mode: Exclude<PlaygroundMode, 'chat'>,
model: string,
prompt: string,
settings: MediaGenerationSettings,
uploads: PlaygroundUpload[],
videoMode: VideoCreateMode,
): Record<string, unknown> {
const requestPrompt = replacePlaygroundResourceTokens(prompt.trim(), uploads, mode);
if (mode === 'video') {
return {
kind: 'videos.generations',
model,
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, uploads, videoMode),
...mediaRequestPayload(settings, 'video'),
};
}
const uploadPayload = sharedMediaUploadRequestPayload(uploads, 'image');
return {
kind: uploads.some((item) => item.kind === 'image') ? 'images.edits' : 'images.generations',
model,
prompt: requestPrompt,
...mediaRequestPayload(settings, 'image'),
...uploadPayload,
};
}
function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
return {
amount: numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items),
currency: response.currency || estimateItemsCurrency(response.items),
resolver: response.resolver,
status: 'ready',
};
}
function estimateItemsTotal(items: GatewayPricingEstimate['items']) {
const total = items.reduce((sum, item) => sum + (numericFromUnknown(item.amount) ?? 0), 0);
return Math.round(total * 1_000_000) / 1_000_000;
}
function estimateItemsCurrency(items: GatewayPricingEstimate['items']) {
return items.find((item) => stringFromUnknown(item.currency))?.currency || 'resource';
}
function mediaEstimateText(estimate: MediaEstimateState) {
if (estimate.amount === undefined) return '--';
return formatEstimateAmount(estimate.amount);
}
function mediaEstimateHint(estimate: MediaEstimateState) {
if (estimate.status === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`;
return '扣费为预计,实际扣费以账单为准';
}
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败';
if (estimate.amount === undefined) return '预计扣费估算中';
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
}
function formatEstimateAmount(value: number) {
if (!Number.isFinite(value)) return '--';
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;
return value.toFixed(digits).replace(/\.?0+$/, '');
}
function mediaPromptPlaceholder(mode: PlaygroundMode) {
if (mode === 'image') return '输入画面描述,可用 @ 或 @资产 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 或 @资产 引用图片、视频或音频资源...';