feat(web): 增加参数即时估价与显式免费编辑
估价请求统一使用 350ms 防抖、AbortController 和请求序号,区分免费、计算中、价格不可用与失败状态,并展示候选冻结上限。价格未就绪时阻止生产提交。\n\n计价规则编辑器新增显式免费开关和 9 位价格步进。\n\n验证:Web 88 项测试和生产构建通过。
This commit is contained in:
+4
-1
@@ -896,10 +896,12 @@ export async function uploadFileToStorage(
|
|||||||
export async function estimatePricing(
|
export async function estimatePricing(
|
||||||
token: string,
|
token: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<GatewayPricingEstimate> {
|
): Promise<GatewayPricingEstimate> {
|
||||||
return request<GatewayPricingEstimate>('/api/v1/pricing/estimate', {
|
return request<GatewayPricingEstimate>('/api/v1/pricing/estimate', {
|
||||||
body: input,
|
body: input,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
signal,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1182,7 @@ export async function deleteFileStorageChannel(token: string, channelId: string)
|
|||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
||||||
if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) {
|
if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) {
|
||||||
@@ -1194,6 +1196,7 @@ async function request<T>(
|
|||||||
headers,
|
headers,
|
||||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { GatewayPricingEstimate } from '@easyai-ai-gateway/contracts';
|
||||||
|
import {
|
||||||
|
MEDIA_ESTIMATE_DEBOUNCE_MS,
|
||||||
|
billingEstimateSignature,
|
||||||
|
mediaEstimateFromResponse,
|
||||||
|
} from './PlaygroundPage';
|
||||||
|
|
||||||
|
describe('playground pricing estimate', () => {
|
||||||
|
it('uses the specified 350ms debounce interval', () => {
|
||||||
|
expect(MEDIA_ESTIMATE_DEBOUNCE_MS).toBe(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only changes its signature for billing-relevant parameters', () => {
|
||||||
|
const first = billingEstimateSignature({
|
||||||
|
kind: 'images.generations', model: 'image-model', prompt: 'first',
|
||||||
|
count: 1, quality: 'high', resolution: '2K',
|
||||||
|
});
|
||||||
|
const promptOnly = billingEstimateSignature({
|
||||||
|
kind: 'images.generations', model: 'image-model', prompt: 'second',
|
||||||
|
count: 1, quality: 'high', resolution: '2K',
|
||||||
|
});
|
||||||
|
const changedCount = billingEstimateSignature({
|
||||||
|
kind: 'images.generations', model: 'image-model', prompt: 'second',
|
||||||
|
count: 2, quality: 'high', resolution: '2K',
|
||||||
|
});
|
||||||
|
expect(promptOnly).toBe(first);
|
||||||
|
expect(changedCount).not.toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes explicit free and exposes the reservation ceiling', () => {
|
||||||
|
const response: GatewayPricingEstimate = {
|
||||||
|
candidateCount: 2,
|
||||||
|
currency: 'resource',
|
||||||
|
items: [{ amount: 0, currency: 'resource', isFree: true }],
|
||||||
|
pricingVersion: 'effective-pricing-v2',
|
||||||
|
requestFingerprint: 'fingerprint',
|
||||||
|
reservationAmount: 0,
|
||||||
|
resolver: 'effective-pricing-v2',
|
||||||
|
totalAmount: 0,
|
||||||
|
};
|
||||||
|
expect(mediaEstimateFromResponse(response)).toMatchObject({
|
||||||
|
amount: 0,
|
||||||
|
candidateCount: 2,
|
||||||
|
reservationAmount: 0,
|
||||||
|
status: 'free',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import type { GatewayApiKey, GatewayPricingEstimate, 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 { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react';
|
||||||
import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui';
|
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 type { PlaygroundMode } from '../types';
|
||||||
import {
|
import {
|
||||||
PlaygroundPromptMentionInput,
|
PlaygroundPromptMentionInput,
|
||||||
@@ -56,13 +56,17 @@ import {
|
|||||||
|
|
||||||
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
|
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
|
||||||
const MEDIA_RUNS_STORAGE_LIMIT = 50;
|
const MEDIA_RUNS_STORAGE_LIMIT = 50;
|
||||||
|
export const MEDIA_ESTIMATE_DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
type MediaEstimateState = {
|
type MediaEstimateState = {
|
||||||
amount?: number;
|
amount?: number;
|
||||||
|
reservationAmount?: number;
|
||||||
|
candidateCount?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
pricingVersion?: string;
|
||||||
resolver?: string;
|
resolver?: string;
|
||||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
status: 'idle' | 'loading' | 'ready' | 'free' | 'unavailable' | 'error';
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicWorks = [
|
const publicWorks = [
|
||||||
@@ -107,6 +111,7 @@ export function PlaygroundPage(props: {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const isMountedRef = useRef(false);
|
const isMountedRef = useRef(false);
|
||||||
const pendingMediaModelRef = useRef('');
|
const pendingMediaModelRef = useRef('');
|
||||||
|
const mediaEstimateSequenceRef = useRef(0);
|
||||||
const resumedTaskIdsRef = useRef(new Set<string>());
|
const resumedTaskIdsRef = useRef(new Set<string>());
|
||||||
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
||||||
const mediaUploadAcceptValue = sharedMediaUploadAcceptForMode(props.mode, videoMode);
|
const mediaUploadAcceptValue = sharedMediaUploadAcceptForMode(props.mode, videoMode);
|
||||||
@@ -136,6 +141,10 @@ export function PlaygroundPage(props: {
|
|||||||
supportsQualityControl: mediaCapabilities?.supportsQualityControl,
|
supportsQualityControl: mediaCapabilities?.supportsQualityControl,
|
||||||
});
|
});
|
||||||
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
|
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
|
||||||
|
const mediaEstimateSignature = useMemo(
|
||||||
|
() => billingEstimateSignature(mediaEstimatePayload),
|
||||||
|
[mediaEstimatePayload],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedModel((current) => {
|
setSelectedModel((current) => {
|
||||||
@@ -176,30 +185,34 @@ export function PlaygroundPage(props: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const credential = activeApiKeySecret || props.token;
|
const credential = activeApiKeySecret || props.token;
|
||||||
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
|
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
|
||||||
|
mediaEstimateSequenceRef.current += 1;
|
||||||
setMediaEstimate({ status: 'idle' });
|
setMediaEstimate({ status: 'idle' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
const sequence = mediaEstimateSequenceRef.current + 1;
|
||||||
|
mediaEstimateSequenceRef.current = sequence;
|
||||||
|
const controller = new AbortController();
|
||||||
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
|
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
estimatePricing(credential, mediaEstimatePayload)
|
estimatePricing(credential, mediaEstimatePayload, controller.signal)
|
||||||
.then((estimate) => {
|
.then((estimate) => {
|
||||||
if (cancelled) return;
|
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
|
||||||
setMediaEstimate(mediaEstimateFromResponse(estimate));
|
setMediaEstimate(mediaEstimateFromResponse(estimate));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (cancelled) return;
|
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
|
||||||
|
const unavailable = err instanceof GatewayApiError && err.details.code === 'pricing_unavailable';
|
||||||
setMediaEstimate({
|
setMediaEstimate({
|
||||||
error: err instanceof Error ? err.message : '预计扣费计算失败',
|
error: err instanceof Error ? err.message : unavailable ? '价格不可用' : '预计扣费计算失败',
|
||||||
status: 'error',
|
status: unavailable ? 'unavailable' : 'error',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, 260);
|
}, MEDIA_ESTIMATE_DEBOUNCE_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [activeApiKeySecret, mediaEstimatePayload, props.mode, props.token]);
|
}, [activeApiKeySecret, mediaEstimateSignature, props.mode, props.token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.mode === 'image') {
|
if (props.mode === 'image') {
|
||||||
@@ -313,6 +326,12 @@ export function PlaygroundPage(props: {
|
|||||||
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
|
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate)) {
|
||||||
|
setMediaMessage(mediaEstimate.status === 'unavailable'
|
||||||
|
? '当前参数没有可用价格,已阻止生产生成。'
|
||||||
|
: '请等待当前参数的预计费用计算完成后再生成。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const localId = newLocalId();
|
const localId = newLocalId();
|
||||||
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
|
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
|
||||||
@@ -456,6 +475,7 @@ export function PlaygroundPage(props: {
|
|||||||
imageHasReference={effectiveImageHasReference}
|
imageHasReference={effectiveImageHasReference}
|
||||||
mediaSettings={mediaSettings}
|
mediaSettings={mediaSettings}
|
||||||
mediaEstimate={mediaEstimate}
|
mediaEstimate={mediaEstimate}
|
||||||
|
submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate)}
|
||||||
mediaCapabilities={mediaCapabilities}
|
mediaCapabilities={mediaCapabilities}
|
||||||
uploadAccept={mediaUploadAcceptValue}
|
uploadAccept={mediaUploadAcceptValue}
|
||||||
uploadMessage={mediaUploadMessage}
|
uploadMessage={mediaUploadMessage}
|
||||||
@@ -684,6 +704,7 @@ function Composer(props: {
|
|||||||
imageHasReference?: boolean;
|
imageHasReference?: boolean;
|
||||||
mediaCapabilities?: MediaModelCapabilities;
|
mediaCapabilities?: MediaModelCapabilities;
|
||||||
mediaEstimate?: MediaEstimateState;
|
mediaEstimate?: MediaEstimateState;
|
||||||
|
submitDisabled?: boolean;
|
||||||
mediaSettings?: MediaGenerationSettings;
|
mediaSettings?: MediaGenerationSettings;
|
||||||
mode: PlaygroundMode;
|
mode: PlaygroundMode;
|
||||||
modelOptions: ModelOption[];
|
modelOptions: ModelOption[];
|
||||||
@@ -791,7 +812,7 @@ function Composer(props: {
|
|||||||
<span>{mediaEstimateText(props.mediaEstimate)}</span>
|
<span>{mediaEstimateText(props.mediaEstimate)}</span>
|
||||||
</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} />
|
<ArrowUp size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 {
|
return {
|
||||||
amount: numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items),
|
amount,
|
||||||
|
reservationAmount: numericFromUnknown(response.reservationAmount),
|
||||||
|
candidateCount: response.candidateCount,
|
||||||
currency: response.currency || estimateItemsCurrency(response.items),
|
currency: response.currency || estimateItemsCurrency(response.items),
|
||||||
|
pricingVersion: response.pricingVersion,
|
||||||
resolver: response.resolver,
|
resolver: response.resolver,
|
||||||
status: 'ready',
|
status: explicitFree ? 'free' : 'ready',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,21 +873,79 @@ function estimateItemsCurrency(items: GatewayPricingEstimate['items']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mediaEstimateText(estimate: MediaEstimateState) {
|
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 '--';
|
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) {
|
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 === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`;
|
||||||
|
if (estimate.status === 'free') return '当前计价规则已显式标记为免费';
|
||||||
return '扣费为预计,实际扣费以账单为准';
|
return '扣费为预计,实际扣费以账单为准';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
|
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
|
||||||
if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败';
|
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 '预计扣费估算中';
|
if (estimate.amount === undefined) return '预计扣费估算中';
|
||||||
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
|
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) {
|
function formatEstimateAmount(value: number) {
|
||||||
if (!Number.isFinite(value)) return '--';
|
if (!Number.isFinite(value)) return '--';
|
||||||
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;
|
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;
|
||||||
|
|||||||
@@ -221,7 +221,17 @@ function ModeRuleEditor(props: {
|
|||||||
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
|
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
|
||||||
<Input size="sm" min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
<Input disabled={rule.isFree} size="sm" min="0" step="0.000000001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
||||||
|
</PricingFormRow>
|
||||||
|
<PricingFormRow label="显式免费">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.isFree ?? false}
|
||||||
|
onChange={(event) => patch({ isFree: event.target.checked, basePrice: event.target.checked ? 0 : rule.basePrice })}
|
||||||
|
/>
|
||||||
|
<span>允许该规则以零费用运行</span>
|
||||||
|
</label>
|
||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
|
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
|
||||||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
|
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
|
||||||
@@ -311,11 +321,32 @@ function TextRuleEditor(props: {
|
|||||||
<PricingFormRow label="状态">
|
<PricingFormRow label="状态">
|
||||||
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
|
<PricingFormRow label="显式免费">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.rule.isFree ?? false}
|
||||||
|
onChange={(event) => {
|
||||||
|
const isFree = event.target.checked;
|
||||||
|
props.onChange({
|
||||||
|
...props.rule,
|
||||||
|
isFree,
|
||||||
|
basePrice: isFree ? 0 : props.rule.basePrice,
|
||||||
|
formulaConfig: isFree
|
||||||
|
? { ...(props.rule.formulaConfig ?? {}), inputTokenPrice: 0, cachedInputTokenPrice: 0, outputTokenPrice: 0 }
|
||||||
|
: props.rule.formulaConfig,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>允许该规则以零费用运行</span>
|
||||||
|
</label>
|
||||||
|
</PricingFormRow>
|
||||||
<PricingFormRow label="输入单价/1K tokens">
|
<PricingFormRow label="输入单价/1K tokens">
|
||||||
<Input
|
<Input
|
||||||
|
disabled={props.rule.isFree}
|
||||||
size="sm"
|
size="sm"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.000000001"
|
||||||
type="number"
|
type="number"
|
||||||
value={prices.inputTokenPrice}
|
value={prices.inputTokenPrice}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -329,9 +360,10 @@ function TextRuleEditor(props: {
|
|||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingFormRow label="缓存输入单价/1K tokens">
|
<PricingFormRow label="缓存输入单价/1K tokens">
|
||||||
<Input
|
<Input
|
||||||
|
disabled={props.rule.isFree}
|
||||||
size="sm"
|
size="sm"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.000000001"
|
||||||
type="number"
|
type="number"
|
||||||
value={prices.cachedInputTokenPrice}
|
value={prices.cachedInputTokenPrice}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -345,9 +377,10 @@ function TextRuleEditor(props: {
|
|||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingFormRow label="输出单价/1K tokens">
|
<PricingFormRow label="输出单价/1K tokens">
|
||||||
<Input
|
<Input
|
||||||
|
disabled={props.rule.isFree}
|
||||||
size="sm"
|
size="sm"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.000000001"
|
||||||
type="number"
|
type="number"
|
||||||
value={prices.outputTokenPrice}
|
value={prices.outputTokenPrice}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function ruleToInput(rule: PricingRule): PricingRuleInput {
|
|||||||
resourceType: rule.resourceType,
|
resourceType: rule.resourceType,
|
||||||
unit: rule.unit,
|
unit: rule.unit,
|
||||||
basePrice: rule.basePrice,
|
basePrice: rule.basePrice,
|
||||||
|
isFree: rule.isFree,
|
||||||
currency: rule.currency,
|
currency: rule.currency,
|
||||||
baseWeight: rule.baseWeight ?? {},
|
baseWeight: rule.baseWeight ?? {},
|
||||||
dynamicWeight: rule.dynamicWeight ?? {},
|
dynamicWeight: rule.dynamicWeight ?? {},
|
||||||
@@ -234,6 +235,8 @@ function ruleToInput(rule: PricingRule): PricingRuleInput {
|
|||||||
priority: rule.priority,
|
priority: rule.priority,
|
||||||
status: rule.status,
|
status: rule.status,
|
||||||
metadata: rule.metadata ?? {},
|
metadata: rule.metadata ?? {},
|
||||||
|
effectiveFrom: rule.effectiveFrom,
|
||||||
|
effectiveTo: rule.effectiveTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user