fix(model-pricing): commit visual pricing drafts on save
- Commit the open visual editor draft before saving model pricing settings - Show unsaved draft differences against persisted model pricing values - Move model pricing actions into the editor toolbar and refine the visual editor layout
This commit is contained in:
+183
-269
@@ -27,17 +27,10 @@ import {
|
|||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
@@ -63,15 +56,11 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||||
sideDrawerContentClassName,
|
|
||||||
sideDrawerFooterClassName,
|
|
||||||
} from '@/components/drawer-layout'
|
|
||||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||||
import {
|
import {
|
||||||
SettingsControlGroup,
|
SettingsControlGroup,
|
||||||
@@ -124,10 +113,7 @@ export type ModelRatioData = {
|
|||||||
type ModelPricingSheetProps = {
|
type ModelPricingSheetProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSave: (data: ModelRatioData) => void
|
|
||||||
onCancel?: () => void
|
|
||||||
editData?: ModelRatioData | null
|
editData?: ModelRatioData | null
|
||||||
selectedTargetCount?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPricingEditorPanelProps = Omit<
|
type ModelPricingEditorPanelProps = Omit<
|
||||||
@@ -284,20 +270,6 @@ function createInitialLaneState(data?: ModelRatioData | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModeLabel(mode: PricingMode) {
|
|
||||||
if (mode === 'per-request') return 'Per-request'
|
|
||||||
if (mode === 'tiered_expr') return 'Expression'
|
|
||||||
return 'Per-token'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModeBadgeVariant(
|
|
||||||
mode: PricingMode
|
|
||||||
): 'default' | 'secondary' | 'outline' {
|
|
||||||
if (mode === 'per-request') return 'secondary'
|
|
||||||
if (mode === 'tiered_expr') return 'default'
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreviewRows(
|
function buildPreviewRows(
|
||||||
values: ModelPricingFormValues,
|
values: ModelPricingFormValues,
|
||||||
mode: PricingMode,
|
mode: PricingMode,
|
||||||
@@ -391,10 +363,7 @@ function buildPreviewRows(
|
|||||||
export const ModelPricingSheet = forwardRef<
|
export const ModelPricingSheet = forwardRef<
|
||||||
ModelPricingEditorPanelHandle,
|
ModelPricingEditorPanelHandle,
|
||||||
ModelPricingSheetProps
|
ModelPricingSheetProps
|
||||||
>(function ModelPricingSheet(
|
>(function ModelPricingSheet({ open, onOpenChange, editData }, ref) {
|
||||||
{ open, onOpenChange, onSave, onCancel, editData, selectedTargetCount = 0 },
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const title = editData ? t('Edit model pricing') : t('Add model pricing')
|
const title = editData ? t('Edit model pricing') : t('Add model pricing')
|
||||||
const description = editData?.name || t('New model')
|
const description = editData?.name || t('New model')
|
||||||
@@ -411,13 +380,7 @@ export const ModelPricingSheet = forwardRef<
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ModelPricingEditorPanel
|
<ModelPricingEditorPanel
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onSave={onSave}
|
|
||||||
editData={editData}
|
editData={editData}
|
||||||
selectedTargetCount={selectedTargetCount}
|
|
||||||
onCancel={() => {
|
|
||||||
onCancel?.()
|
|
||||||
onOpenChange(false)
|
|
||||||
}}
|
|
||||||
className='h-full rounded-none border-0'
|
className='h-full rounded-none border-0'
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -428,10 +391,7 @@ export const ModelPricingSheet = forwardRef<
|
|||||||
export const ModelPricingEditorPanel = forwardRef<
|
export const ModelPricingEditorPanel = forwardRef<
|
||||||
ModelPricingEditorPanelHandle,
|
ModelPricingEditorPanelHandle,
|
||||||
ModelPricingEditorPanelProps
|
ModelPricingEditorPanelProps
|
||||||
>(function ModelPricingEditorPanel(
|
>(function ModelPricingEditorPanel({ editData, className }, ref) {
|
||||||
{ onSave, editData, selectedTargetCount = 0, onCancel, className },
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
||||||
const [promptPrice, setPromptPrice] = useState('')
|
const [promptPrice, setPromptPrice] = useState('')
|
||||||
@@ -443,7 +403,6 @@ export const ModelPricingEditorPanel = forwardRef<
|
|||||||
})
|
})
|
||||||
const [billingExpr, setBillingExpr] = useState('')
|
const [billingExpr, setBillingExpr] = useState('')
|
||||||
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||||||
const [previewOpen, setPreviewOpen] = useState(true)
|
|
||||||
const isEditMode = !!editData
|
const isEditMode = !!editData
|
||||||
|
|
||||||
const form = useForm<ModelPricingFormValues>({
|
const form = useForm<ModelPricingFormValues>({
|
||||||
@@ -505,7 +464,6 @@ export const ModelPricingEditorPanel = forwardRef<
|
|||||||
setPromptPrice(nextLaneState.promptPrice)
|
setPromptPrice(nextLaneState.promptPrice)
|
||||||
setLanePrices(nextLaneState.prices)
|
setLanePrices(nextLaneState.prices)
|
||||||
setLaneEnabled(nextLaneState.enabled)
|
setLaneEnabled(nextLaneState.enabled)
|
||||||
setPreviewOpen(true)
|
|
||||||
}, [editData, form])
|
}, [editData, form])
|
||||||
|
|
||||||
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
|
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
|
||||||
@@ -763,15 +721,6 @@ export const ModelPricingEditorPanel = forwardRef<
|
|||||||
[form, validatePricingValues, buildSubmitData]
|
[form, validatePricingValues, buildSubmitData]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSubmit = (values: ModelPricingFormValues) => {
|
|
||||||
if (!validatePricingValues()) return
|
|
||||||
|
|
||||||
const data = buildSubmitData(values)
|
|
||||||
onSave(data)
|
|
||||||
form.reset()
|
|
||||||
onCancel?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeName = watchedValues.name || editData?.name || t('New model')
|
const activeName = watchedValues.name || editData?.name || t('New model')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -791,232 +740,197 @@ export const ModelPricingEditorPanel = forwardRef<
|
|||||||
{activeName}
|
{activeName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={getModeBadgeVariant(pricingMode)}>
|
|
||||||
{t(getModeLabel(pricingMode))}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
onSubmit={(event) => event.preventDefault()}
|
||||||
className='flex min-h-0 flex-1 flex-col'
|
className='flex min-h-0 flex-1 flex-col'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
>
|
>
|
||||||
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
|
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
|
||||||
<FieldGroup>
|
<div className='grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'>
|
||||||
{warnings.length > 0 && (
|
<FieldGroup>
|
||||||
<Alert variant='destructive'>
|
{warnings.length > 0 && (
|
||||||
<AlertTriangle data-icon='inline-start' />
|
<Alert variant='destructive'>
|
||||||
<AlertDescription>
|
<AlertTriangle data-icon='inline-start' />
|
||||||
<div className='flex flex-col gap-1'>
|
<AlertDescription>
|
||||||
{warnings.map((warning) => (
|
<div className='flex flex-col gap-1'>
|
||||||
<span key={warning}>{warning}</span>
|
{warnings.map((warning) => (
|
||||||
))}
|
<span key={warning}>{warning}</span>
|
||||||
</div>
|
))}
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='name'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('Model name')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={t('gpt-4')}
|
|
||||||
{...field}
|
|
||||||
disabled={isEditMode}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t('The exact model identifier as used in API requests.')}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={pricingMode}
|
|
||||||
onValueChange={handleModeChange}
|
|
||||||
className='gap-4'
|
|
||||||
>
|
|
||||||
<TabsList className='grid w-full grid-cols-3'>
|
|
||||||
<TabsTrigger value='per-token'>{t('Per-token')}</TabsTrigger>
|
|
||||||
<TabsTrigger value='per-request'>
|
|
||||||
{t('Per-request')}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value='tiered_expr'>
|
|
||||||
{t('Expression')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value='per-token' className='pt-0'>
|
|
||||||
<FieldGroup className='gap-5'>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>{t('Input price')}</FieldLabel>
|
|
||||||
<PriceInput
|
|
||||||
value={promptPrice}
|
|
||||||
placeholder='3'
|
|
||||||
onChange={handlePromptPriceChange}
|
|
||||||
/>
|
|
||||||
<FieldDescription>
|
|
||||||
{t('USD price per 1M input tokens.')}
|
|
||||||
</FieldDescription>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className='grid gap-3 sm:grid-cols-2'>
|
|
||||||
{laneConfigs.map((lane) => {
|
|
||||||
const disabled =
|
|
||||||
lane.key === 'audioOutput' &&
|
|
||||||
(!laneEnabled.audioInput ||
|
|
||||||
!hasValue(lanePrices.audioInput))
|
|
||||||
return (
|
|
||||||
<PriceLane
|
|
||||||
key={lane.key}
|
|
||||||
title={t(lane.titleKey)}
|
|
||||||
description={t(lane.descriptionKey)}
|
|
||||||
placeholder={lane.placeholder}
|
|
||||||
value={lanePrices[lane.key]}
|
|
||||||
enabled={laneEnabled[lane.key]}
|
|
||||||
disabled={disabled}
|
|
||||||
onEnabledChange={(checked) =>
|
|
||||||
handleLaneToggle(lane.key, checked)
|
|
||||||
}
|
|
||||||
onChange={(value) =>
|
|
||||||
handleLanePriceChange(lane.key, value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</FieldGroup>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value='per-request' className='pt-0'>
|
|
||||||
<FieldGroup className='gap-5'>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='price'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className='contents'>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>{t('Fixed price')}</FieldLabel>
|
|
||||||
<FormControl>
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroupAddon>$</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
inputMode='decimal'
|
|
||||||
placeholder='0.01'
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = event.target.value
|
|
||||||
if (numericDraftRegex.test(value)) {
|
|
||||||
field.onChange(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<InputGroupAddon align='inline-end'>
|
|
||||||
{t('per request')}
|
|
||||||
</InputGroupAddon>
|
|
||||||
</InputGroup>
|
|
||||||
</FormControl>
|
|
||||||
<FieldDescription>
|
|
||||||
{t(
|
|
||||||
'Cost in USD per request, regardless of tokens used.'
|
|
||||||
)}
|
|
||||||
</FieldDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</Field>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value='tiered_expr' className='pt-0'>
|
|
||||||
<FieldGroup className='gap-5'>
|
|
||||||
<TieredPricingEditor
|
|
||||||
modelName={watchedValues.name}
|
|
||||||
billingExpr={billingExpr}
|
|
||||||
requestRuleExpr={requestRuleExpr}
|
|
||||||
onBillingExprChange={setBillingExpr}
|
|
||||||
onRequestRuleExprChange={setRequestRuleExpr}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
|
|
||||||
<CollapsibleTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
className='flex w-full justify-between'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>{t('Save preview')}</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'transition-transform',
|
|
||||||
previewOpen && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className='pt-3'>
|
|
||||||
<div className='rounded-lg border'>
|
|
||||||
{previewRows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.key}
|
|
||||||
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
|
|
||||||
>
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
|
||||||
{row.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'min-w-0',
|
|
||||||
row.multiline
|
|
||||||
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
|
||||||
: 'truncate'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
</CollapsibleContent>
|
)}
|
||||||
</Collapsible>
|
|
||||||
</FieldGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter
|
<FormField
|
||||||
className={sideDrawerFooterClassName(
|
control={form.control}
|
||||||
'grid-cols-1 sm:items-center sm:justify-between'
|
name='name'
|
||||||
)}
|
render={({ field }) => (
|
||||||
>
|
<FormItem>
|
||||||
<div className='text-muted-foreground text-xs'>
|
<FormLabel>{t('Model name')}</FormLabel>
|
||||||
{selectedTargetCount > 0
|
<FormControl>
|
||||||
? t('{{count}} selected targets available for bulk copy.', {
|
<Input
|
||||||
count: selectedTargetCount,
|
placeholder={t('gpt-4')}
|
||||||
})
|
{...field}
|
||||||
: t('Changes are written to the settings draft on save.')}
|
disabled={isEditMode}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
'The exact model identifier as used in API requests.'
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={pricingMode}
|
||||||
|
onValueChange={handleModeChange}
|
||||||
|
className='gap-4'
|
||||||
|
>
|
||||||
|
<TabsList className='grid w-full grid-cols-3'>
|
||||||
|
<TabsTrigger value='per-token'>
|
||||||
|
{t('Per-token')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value='per-request'>
|
||||||
|
{t('Per-request')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value='tiered_expr'>
|
||||||
|
{t('Expression')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value='per-token' className='pt-0'>
|
||||||
|
<FieldGroup className='gap-5'>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t('Input price')}</FieldLabel>
|
||||||
|
<PriceInput
|
||||||
|
value={promptPrice}
|
||||||
|
placeholder='3'
|
||||||
|
onChange={handlePromptPriceChange}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{t('USD price per 1M input tokens.')}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className='grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]'>
|
||||||
|
{laneConfigs.map((lane) => {
|
||||||
|
const disabled =
|
||||||
|
lane.key === 'audioOutput' &&
|
||||||
|
(!laneEnabled.audioInput ||
|
||||||
|
!hasValue(lanePrices.audioInput))
|
||||||
|
return (
|
||||||
|
<PriceLane
|
||||||
|
key={lane.key}
|
||||||
|
title={t(lane.titleKey)}
|
||||||
|
description={t(lane.descriptionKey)}
|
||||||
|
placeholder={lane.placeholder}
|
||||||
|
value={lanePrices[lane.key]}
|
||||||
|
enabled={laneEnabled[lane.key]}
|
||||||
|
disabled={disabled}
|
||||||
|
onEnabledChange={(checked) =>
|
||||||
|
handleLaneToggle(lane.key, checked)
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleLanePriceChange(lane.key, value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='per-request' className='pt-0'>
|
||||||
|
<FieldGroup className='gap-5'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='price'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className='contents'>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t('Fixed price')}</FieldLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon>$</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
inputMode='decimal'
|
||||||
|
placeholder='0.01'
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value
|
||||||
|
if (numericDraftRegex.test(value)) {
|
||||||
|
field.onChange(value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align='inline-end'>
|
||||||
|
{t('per request')}
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FieldDescription>
|
||||||
|
{t(
|
||||||
|
'Cost in USD per request, regardless of tokens used.'
|
||||||
|
)}
|
||||||
|
</FieldDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</Field>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='tiered_expr' className='pt-0'>
|
||||||
|
<FieldGroup className='gap-5'>
|
||||||
|
<TieredPricingEditor
|
||||||
|
modelName={watchedValues.name}
|
||||||
|
billingExpr={billingExpr}
|
||||||
|
requestRuleExpr={requestRuleExpr}
|
||||||
|
onBillingExprChange={setBillingExpr}
|
||||||
|
onRequestRuleExprChange={setRequestRuleExpr}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<aside className='bg-muted/20 sticky top-0 rounded-lg border'>
|
||||||
|
<div className='border-b px-3 py-2'>
|
||||||
|
<div className='text-sm font-medium'>{t('Preview')}</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{activeName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='divide-y'>
|
||||||
|
{previewRows.map((row) => (
|
||||||
|
<div key={row.key} className='grid gap-1 px-3 py-2.5'>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 text-sm',
|
||||||
|
row.multiline
|
||||||
|
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
||||||
|
: 'truncate'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-end gap-2'>
|
</div>
|
||||||
<Button type='button' variant='outline' onClick={onCancel}>
|
|
||||||
{t('Cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button type='submit'>
|
|
||||||
{isEditMode ? t('Update') : t('Add')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
SettingsSwitchContent,
|
SettingsSwitchContent,
|
||||||
SettingsSwitchItem,
|
SettingsSwitchItem,
|
||||||
} from '../components/settings-form-layout'
|
} from '../components/settings-form-layout'
|
||||||
import { SettingsPageActionsPortal } from '../components/settings-page-context'
|
|
||||||
import {
|
import {
|
||||||
ModelRatioVisualEditor,
|
ModelRatioVisualEditor,
|
||||||
type ModelRatioVisualEditorHandle,
|
type ModelRatioVisualEditorHandle,
|
||||||
@@ -59,6 +58,7 @@ type ModelFormValues = {
|
|||||||
|
|
||||||
type ModelRatioFormProps = {
|
type ModelRatioFormProps = {
|
||||||
form: UseFormReturn<ModelFormValues>
|
form: UseFormReturn<ModelFormValues>
|
||||||
|
savedValues: ModelFormValues
|
||||||
onSave: (values: ModelFormValues) => Promise<void>
|
onSave: (values: ModelFormValues) => Promise<void>
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
@@ -67,6 +67,7 @@ type ModelRatioFormProps = {
|
|||||||
|
|
||||||
export const ModelRatioForm = memo(function ModelRatioForm({
|
export const ModelRatioForm = memo(function ModelRatioForm({
|
||||||
form,
|
form,
|
||||||
|
savedValues,
|
||||||
onSave,
|
onSave,
|
||||||
onReset,
|
onReset,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -101,7 +102,24 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='flex justify-end'>
|
<div className='flex flex-wrap justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={isResetting}
|
||||||
|
>
|
||||||
|
{t('Reset prices')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||||
|
</Button>
|
||||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||||
{editMode === 'visual' ? (
|
{editMode === 'visual' ? (
|
||||||
<>
|
<>
|
||||||
@@ -118,29 +136,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<SettingsPageActionsPortal>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='destructive'
|
|
||||||
size='sm'
|
|
||||||
onClick={onReset}
|
|
||||||
disabled={isResetting}
|
|
||||||
>
|
|
||||||
{t('Reset prices')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
size='sm'
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
|
||||||
</Button>
|
|
||||||
</SettingsPageActionsPortal>
|
|
||||||
{editMode === 'visual' ? (
|
{editMode === 'visual' ? (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<ModelRatioVisualEditor
|
<ModelRatioVisualEditor
|
||||||
ref={visualEditorRef}
|
ref={visualEditorRef}
|
||||||
|
savedModelPrice={savedValues.ModelPrice}
|
||||||
|
savedModelRatio={savedValues.ModelRatio}
|
||||||
|
savedCacheRatio={savedValues.CacheRatio}
|
||||||
|
savedCreateCacheRatio={savedValues.CreateCacheRatio}
|
||||||
|
savedCompletionRatio={savedValues.CompletionRatio}
|
||||||
|
savedImageRatio={savedValues.ImageRatio}
|
||||||
|
savedAudioRatio={savedValues.AudioRatio}
|
||||||
|
savedAudioCompletionRatio={savedValues.AudioCompletionRatio}
|
||||||
|
savedBillingMode={savedValues.BillingMode}
|
||||||
|
savedBillingExpr={savedValues.BillingExpr}
|
||||||
modelPrice={form.watch('ModelPrice')}
|
modelPrice={form.watch('ModelPrice')}
|
||||||
modelRatio={form.watch('ModelRatio')}
|
modelRatio={form.watch('ModelRatio')}
|
||||||
cacheRatio={form.watch('CacheRatio')}
|
cacheRatio={form.watch('CacheRatio')}
|
||||||
|
|||||||
+328
-183
@@ -49,14 +49,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
import {
|
||||||
DataTableBulkActions,
|
DataTableBulkActions,
|
||||||
DataTableColumnHeader,
|
DataTableColumnHeader,
|
||||||
@@ -78,6 +70,16 @@ import {
|
|||||||
import { formatPricingNumber } from './pricing-format'
|
import { formatPricingNumber } from './pricing-format'
|
||||||
|
|
||||||
type ModelRatioVisualEditorProps = {
|
type ModelRatioVisualEditorProps = {
|
||||||
|
savedModelPrice: string
|
||||||
|
savedModelRatio: string
|
||||||
|
savedCacheRatio: string
|
||||||
|
savedCreateCacheRatio: string
|
||||||
|
savedCompletionRatio: string
|
||||||
|
savedImageRatio: string
|
||||||
|
savedAudioRatio: string
|
||||||
|
savedAudioCompletionRatio: string
|
||||||
|
savedBillingMode: string
|
||||||
|
savedBillingExpr: string
|
||||||
modelPrice: string
|
modelPrice: string
|
||||||
modelRatio: string
|
modelRatio: string
|
||||||
cacheRatio: string
|
cacheRatio: string
|
||||||
@@ -91,7 +93,7 @@ type ModelRatioVisualEditorProps = {
|
|||||||
onChange: (field: string, value: string) => void
|
onChange: (field: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelRow = {
|
type ModelPricingSnapshot = {
|
||||||
name: string
|
name: string
|
||||||
price?: string
|
price?: string
|
||||||
ratio?: string
|
ratio?: string
|
||||||
@@ -107,6 +109,14 @@ type ModelRow = {
|
|||||||
hasConflict: boolean
|
hasConflict: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModelRow = ModelPricingSnapshot & {
|
||||||
|
saved?: ModelPricingSnapshot
|
||||||
|
draft?: ModelPricingSnapshot
|
||||||
|
isDraftChanged: boolean
|
||||||
|
isDraftDeleted: boolean
|
||||||
|
isDraftNew: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ModelRatioVisualEditorHandle = {
|
export type ModelRatioVisualEditorHandle = {
|
||||||
commitOpenEditor: () => Promise<boolean>
|
commitOpenEditor: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
@@ -148,7 +158,10 @@ const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
|
|||||||
return 'success'
|
return 'success'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
|
const getExpressionSummary = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
||||||
if (tierCount > 0) {
|
if (tierCount > 0) {
|
||||||
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
||||||
@@ -156,7 +169,10 @@ const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
|
|||||||
return t('Expression pricing')
|
return t('Expression pricing')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
|
const getPriceSummary = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
if (row.billingMode === 'tiered_expr') {
|
if (row.billingMode === 'tiered_expr') {
|
||||||
return getExpressionSummary(row, t)
|
return getExpressionSummary(row, t)
|
||||||
}
|
}
|
||||||
@@ -181,7 +197,10 @@ const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
|
|||||||
: `${t('Input')} $${inputPrice}`
|
: `${t('Input')} $${inputPrice}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
|
const getPriceDetail = (
|
||||||
|
row: ModelPricingSnapshot,
|
||||||
|
t: (key: string) => string
|
||||||
|
) => {
|
||||||
if (row.billingMode === 'tiered_expr') {
|
if (row.billingMode === 'tiered_expr') {
|
||||||
return row.requestRuleExpr
|
return row.requestRuleExpr
|
||||||
? t('Includes request rules')
|
? t('Includes request rules')
|
||||||
@@ -206,11 +225,172 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
|
|||||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildModelSnapshots = ({
|
||||||
|
modelPrice,
|
||||||
|
modelRatio,
|
||||||
|
cacheRatio,
|
||||||
|
createCacheRatio,
|
||||||
|
completionRatio,
|
||||||
|
imageRatio,
|
||||||
|
audioRatio,
|
||||||
|
audioCompletionRatio,
|
||||||
|
billingMode,
|
||||||
|
billingExpr,
|
||||||
|
}: Pick<
|
||||||
|
ModelRatioVisualEditorProps,
|
||||||
|
| 'modelPrice'
|
||||||
|
| 'modelRatio'
|
||||||
|
| 'cacheRatio'
|
||||||
|
| 'createCacheRatio'
|
||||||
|
| 'completionRatio'
|
||||||
|
| 'imageRatio'
|
||||||
|
| 'audioRatio'
|
||||||
|
| 'audioCompletionRatio'
|
||||||
|
| 'billingMode'
|
||||||
|
| 'billingExpr'
|
||||||
|
>): ModelPricingSnapshot[] => {
|
||||||
|
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'model prices',
|
||||||
|
})
|
||||||
|
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'model ratios',
|
||||||
|
})
|
||||||
|
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'cache ratios',
|
||||||
|
})
|
||||||
|
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||||
|
createCacheRatio,
|
||||||
|
{ fallback: {}, context: 'create cache ratios' }
|
||||||
|
)
|
||||||
|
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'completion ratios',
|
||||||
|
})
|
||||||
|
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'image ratios',
|
||||||
|
})
|
||||||
|
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'audio ratios',
|
||||||
|
})
|
||||||
|
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||||
|
audioCompletionRatio,
|
||||||
|
{ fallback: {}, context: 'audio completion ratios' }
|
||||||
|
)
|
||||||
|
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'billing mode',
|
||||||
|
})
|
||||||
|
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
||||||
|
fallback: {},
|
||||||
|
context: 'billing expression',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelNames = new Set([
|
||||||
|
...Object.keys(priceMap),
|
||||||
|
...Object.keys(ratioMap),
|
||||||
|
...Object.keys(cacheMap),
|
||||||
|
...Object.keys(createCacheMap),
|
||||||
|
...Object.keys(completionMap),
|
||||||
|
...Object.keys(imageMap),
|
||||||
|
...Object.keys(audioMap),
|
||||||
|
...Object.keys(audioCompletionMap),
|
||||||
|
...Object.keys(billingModeMap),
|
||||||
|
...Object.keys(billingExprMap),
|
||||||
|
])
|
||||||
|
|
||||||
|
return Array.from(modelNames).map((name) => {
|
||||||
|
const price = priceMap[name]?.toString() || ''
|
||||||
|
const ratio = ratioMap[name]?.toString() || ''
|
||||||
|
const cache = cacheMap[name]?.toString() || ''
|
||||||
|
const createCache = createCacheMap[name]?.toString() || ''
|
||||||
|
const completion = completionMap[name]?.toString() || ''
|
||||||
|
const image = imageMap[name]?.toString() || ''
|
||||||
|
const audio = audioMap[name]?.toString() || ''
|
||||||
|
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
||||||
|
|
||||||
|
const modeForModel = billingModeMap[name]
|
||||||
|
if (modeForModel === 'tiered_expr') {
|
||||||
|
const fullExpr = billingExprMap[name] || ''
|
||||||
|
const { billingExpr: pureExpr, requestRuleExpr } =
|
||||||
|
splitBillingExprAndRequestRules(fullExpr)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
billingMode: 'tiered_expr',
|
||||||
|
billingExpr: pureExpr,
|
||||||
|
requestRuleExpr,
|
||||||
|
price,
|
||||||
|
ratio,
|
||||||
|
cacheRatio: cache,
|
||||||
|
createCacheRatio: createCache,
|
||||||
|
completionRatio: completion,
|
||||||
|
imageRatio: image,
|
||||||
|
audioRatio: audio,
|
||||||
|
audioCompletionRatio: audioCompletion,
|
||||||
|
hasConflict: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
ratio,
|
||||||
|
cacheRatio: cache,
|
||||||
|
createCacheRatio: createCache,
|
||||||
|
completionRatio: completion,
|
||||||
|
imageRatio: image,
|
||||||
|
audioRatio: audio,
|
||||||
|
audioCompletionRatio: audioCompletion,
|
||||||
|
billingMode: price !== '' ? 'per-request' : 'per-token',
|
||||||
|
hasConflict:
|
||||||
|
price !== '' &&
|
||||||
|
(ratio !== '' ||
|
||||||
|
completion !== '' ||
|
||||||
|
cache !== '' ||
|
||||||
|
createCache !== '' ||
|
||||||
|
image !== '' ||
|
||||||
|
audio !== '' ||
|
||||||
|
audioCompletion !== ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
|
||||||
|
if (!snapshot) return ''
|
||||||
|
return JSON.stringify({
|
||||||
|
price: snapshot.price || '',
|
||||||
|
ratio: snapshot.ratio || '',
|
||||||
|
cacheRatio: snapshot.cacheRatio || '',
|
||||||
|
createCacheRatio: snapshot.createCacheRatio || '',
|
||||||
|
completionRatio: snapshot.completionRatio || '',
|
||||||
|
imageRatio: snapshot.imageRatio || '',
|
||||||
|
audioRatio: snapshot.audioRatio || '',
|
||||||
|
audioCompletionRatio: snapshot.audioCompletionRatio || '',
|
||||||
|
billingMode: snapshot.billingMode || 'per-token',
|
||||||
|
billingExpr: snapshot.billingExpr || '',
|
||||||
|
requestRuleExpr: snapshot.requestRuleExpr || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const ModelRatioVisualEditorComponent = forwardRef<
|
const ModelRatioVisualEditorComponent = forwardRef<
|
||||||
ModelRatioVisualEditorHandle,
|
ModelRatioVisualEditorHandle,
|
||||||
ModelRatioVisualEditorProps
|
ModelRatioVisualEditorProps
|
||||||
>(function ModelRatioVisualEditor(
|
>(function ModelRatioVisualEditor(
|
||||||
{
|
{
|
||||||
|
savedModelPrice,
|
||||||
|
savedModelRatio,
|
||||||
|
savedCacheRatio,
|
||||||
|
savedCreateCacheRatio,
|
||||||
|
savedCompletionRatio,
|
||||||
|
savedImageRatio,
|
||||||
|
savedAudioRatio,
|
||||||
|
savedAudioCompletionRatio,
|
||||||
|
savedBillingMode,
|
||||||
|
savedBillingExpr,
|
||||||
modelPrice,
|
modelPrice,
|
||||||
modelRatio,
|
modelRatio,
|
||||||
cacheRatio,
|
cacheRatio,
|
||||||
@@ -279,120 +459,64 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
}, [columnVisibility])
|
}, [columnVisibility])
|
||||||
|
|
||||||
const models = useMemo(() => {
|
const models = useMemo(() => {
|
||||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
const savedRows = buildModelSnapshots({
|
||||||
fallback: {},
|
modelPrice: savedModelPrice,
|
||||||
context: 'model prices',
|
modelRatio: savedModelRatio,
|
||||||
|
cacheRatio: savedCacheRatio,
|
||||||
|
createCacheRatio: savedCreateCacheRatio,
|
||||||
|
completionRatio: savedCompletionRatio,
|
||||||
|
imageRatio: savedImageRatio,
|
||||||
|
audioRatio: savedAudioRatio,
|
||||||
|
audioCompletionRatio: savedAudioCompletionRatio,
|
||||||
|
billingMode: savedBillingMode,
|
||||||
|
billingExpr: savedBillingExpr,
|
||||||
})
|
})
|
||||||
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
const draftRows = buildModelSnapshots({
|
||||||
fallback: {},
|
modelPrice,
|
||||||
context: 'model ratios',
|
modelRatio,
|
||||||
})
|
cacheRatio,
|
||||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'cache ratios',
|
|
||||||
})
|
|
||||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
|
||||||
createCacheRatio,
|
createCacheRatio,
|
||||||
{ fallback: {}, context: 'create cache ratios' }
|
|
||||||
)
|
|
||||||
const completionMap = safeJsonParse<Record<string, number>>(
|
|
||||||
completionRatio,
|
completionRatio,
|
||||||
{ fallback: {}, context: 'completion ratios' }
|
imageRatio,
|
||||||
)
|
audioRatio,
|
||||||
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'image ratios',
|
|
||||||
})
|
|
||||||
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'audio ratios',
|
|
||||||
})
|
|
||||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
|
||||||
audioCompletionRatio,
|
audioCompletionRatio,
|
||||||
{ fallback: {}, context: 'audio completion ratios' }
|
billingMode,
|
||||||
)
|
billingExpr,
|
||||||
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'billing mode',
|
|
||||||
})
|
|
||||||
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
|
|
||||||
fallback: {},
|
|
||||||
context: 'billing expression',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const modelNames = new Set([
|
const savedByName = new Map(savedRows.map((row) => [row.name, row]))
|
||||||
...Object.keys(priceMap),
|
const draftByName = new Map(draftRows.map((row) => [row.name, row]))
|
||||||
...Object.keys(ratioMap),
|
const modelNames = new Set([...savedByName.keys(), ...draftByName.keys()])
|
||||||
...Object.keys(cacheMap),
|
|
||||||
...Object.keys(createCacheMap),
|
|
||||||
...Object.keys(completionMap),
|
|
||||||
...Object.keys(imageMap),
|
|
||||||
...Object.keys(audioMap),
|
|
||||||
...Object.keys(audioCompletionMap),
|
|
||||||
...Object.keys(billingModeMap),
|
|
||||||
...Object.keys(billingExprMap),
|
|
||||||
])
|
|
||||||
|
|
||||||
const modelData: ModelRow[] = Array.from(modelNames).map((name) => {
|
return Array.from(modelNames)
|
||||||
const price = priceMap[name]?.toString() || ''
|
.map((name) => {
|
||||||
const ratio = ratioMap[name]?.toString() || ''
|
const saved = savedByName.get(name)
|
||||||
const cache = cacheMap[name]?.toString() || ''
|
const draft = draftByName.get(name)
|
||||||
const createCache = createCacheMap[name]?.toString() || ''
|
const displayed = saved ?? draft
|
||||||
const completion = completionMap[name]?.toString() || ''
|
const savedSignature = getSnapshotSignature(saved)
|
||||||
const image = imageMap[name]?.toString() || ''
|
const draftSignature = getSnapshotSignature(draft)
|
||||||
const audio = audioMap[name]?.toString() || ''
|
|
||||||
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
|
||||||
|
|
||||||
const modeForModel = billingModeMap[name]
|
|
||||||
if (modeForModel === 'tiered_expr') {
|
|
||||||
// Tiered_expr models may also retain ratio/price values as fallback
|
|
||||||
// during multi-instance sync delays. We preserve them in the row so
|
|
||||||
// the edit dialog round-trip and the next save don't drop them.
|
|
||||||
const fullExpr = billingExprMap[name] || ''
|
|
||||||
const { billingExpr: pureExpr, requestRuleExpr } =
|
|
||||||
splitBillingExprAndRequestRules(fullExpr)
|
|
||||||
return {
|
return {
|
||||||
name,
|
...displayed!,
|
||||||
billingMode: 'tiered_expr',
|
saved,
|
||||||
billingExpr: pureExpr,
|
draft,
|
||||||
requestRuleExpr,
|
isDraftChanged: savedSignature !== draftSignature,
|
||||||
price,
|
isDraftDeleted: Boolean(saved && !draft),
|
||||||
ratio,
|
isDraftNew: Boolean(!saved && draft),
|
||||||
cacheRatio: cache,
|
|
||||||
createCacheRatio: createCache,
|
|
||||||
completionRatio: completion,
|
|
||||||
imageRatio: image,
|
|
||||||
audioRatio: audio,
|
|
||||||
audioCompletionRatio: audioCompletion,
|
|
||||||
hasConflict: false,
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return {
|
|
||||||
name,
|
|
||||||
price,
|
|
||||||
ratio,
|
|
||||||
cacheRatio: cache,
|
|
||||||
createCacheRatio: createCache,
|
|
||||||
completionRatio: completion,
|
|
||||||
imageRatio: image,
|
|
||||||
audioRatio: audio,
|
|
||||||
audioCompletionRatio: audioCompletion,
|
|
||||||
billingMode: price !== '' ? 'per-request' : 'per-token',
|
|
||||||
hasConflict:
|
|
||||||
price !== '' &&
|
|
||||||
(ratio !== '' ||
|
|
||||||
completion !== '' ||
|
|
||||||
cache !== '' ||
|
|
||||||
createCache !== '' ||
|
|
||||||
image !== '' ||
|
|
||||||
audio !== '' ||
|
|
||||||
audioCompletion !== ''),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return modelData.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}, [
|
}, [
|
||||||
|
savedModelPrice,
|
||||||
|
savedModelRatio,
|
||||||
|
savedCacheRatio,
|
||||||
|
savedCreateCacheRatio,
|
||||||
|
savedCompletionRatio,
|
||||||
|
savedImageRatio,
|
||||||
|
savedAudioRatio,
|
||||||
|
savedAudioCompletionRatio,
|
||||||
|
savedBillingMode,
|
||||||
|
savedBillingExpr,
|
||||||
modelPrice,
|
modelPrice,
|
||||||
modelRatio,
|
modelRatio,
|
||||||
cacheRatio,
|
cacheRatio,
|
||||||
@@ -428,24 +552,25 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
|
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
(model: ModelRow) => {
|
(model: ModelRow) => {
|
||||||
|
const editableModel = model.draft ?? model.saved ?? model
|
||||||
setEditData({
|
setEditData({
|
||||||
name: model.name,
|
name: editableModel.name,
|
||||||
price: model.price,
|
price: editableModel.price,
|
||||||
ratio: model.ratio,
|
ratio: editableModel.ratio,
|
||||||
cacheRatio: model.cacheRatio,
|
cacheRatio: editableModel.cacheRatio,
|
||||||
createCacheRatio: model.createCacheRatio,
|
createCacheRatio: editableModel.createCacheRatio,
|
||||||
completionRatio: model.completionRatio,
|
completionRatio: editableModel.completionRatio,
|
||||||
imageRatio: model.imageRatio,
|
imageRatio: editableModel.imageRatio,
|
||||||
audioRatio: model.audioRatio,
|
audioRatio: editableModel.audioRatio,
|
||||||
audioCompletionRatio: model.audioCompletionRatio,
|
audioCompletionRatio: editableModel.audioCompletionRatio,
|
||||||
billingMode:
|
billingMode:
|
||||||
model.billingMode === 'tiered_expr'
|
editableModel.billingMode === 'tiered_expr'
|
||||||
? 'tiered_expr'
|
? 'tiered_expr'
|
||||||
: model.price && model.price !== ''
|
: editableModel.price && editableModel.price !== ''
|
||||||
? 'per-request'
|
? 'per-request'
|
||||||
: 'per-token',
|
: 'per-token',
|
||||||
billingExpr: model.billingExpr,
|
billingExpr: editableModel.billingExpr,
|
||||||
requestRuleExpr: model.requestRuleExpr,
|
requestRuleExpr: editableModel.requestRuleExpr,
|
||||||
})
|
})
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
if (isMobile) setSheetOpen(true)
|
if (isMobile) setSheetOpen(true)
|
||||||
@@ -459,12 +584,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
if (isMobile) setSheetOpen(true)
|
if (isMobile) setSheetOpen(true)
|
||||||
}, [isMobile])
|
}, [isMobile])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
setEditData(null)
|
|
||||||
setEditorOpen(false)
|
|
||||||
setSheetOpen(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
||||||
(updater) => {
|
(updater) => {
|
||||||
setGlobalFilter((previous) => {
|
setGlobalFilter((previous) => {
|
||||||
@@ -604,6 +723,13 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex items-center gap-2 font-medium'>
|
<div className='flex items-center gap-2 font-medium'>
|
||||||
{row.getValue('name')}
|
{row.getValue('name')}
|
||||||
|
{row.original.isDraftChanged && (
|
||||||
|
<StatusBadge
|
||||||
|
label={t('Draft')}
|
||||||
|
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{row.original.billingMode === 'tiered_expr' && (
|
{row.original.billingMode === 'tiered_expr' && (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t('Tiered')}
|
label={t('Tiered')}
|
||||||
@@ -644,13 +770,45 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex min-w-[180px] flex-col gap-1'>
|
<div className='flex min-w-[180px] flex-col gap-2'>
|
||||||
<span className='font-medium'>
|
<div className='flex flex-col gap-1'>
|
||||||
{getPriceSummary(row.original, t)}
|
<span className='font-medium'>
|
||||||
</span>
|
{getPriceSummary(row.original, t)}
|
||||||
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
</span>
|
||||||
{getPriceDetail(row.original, t)}
|
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||||
</span>
|
{getPriceDetail(row.original, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{row.original.isDraftChanged && (
|
||||||
|
<div className='border-warning/45 bg-warning/10 text-foreground flex max-w-[360px] flex-col gap-1 rounded-md border px-2.5 py-2 shadow-sm'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<StatusBadge
|
||||||
|
label={t('Draft')}
|
||||||
|
variant={row.original.isDraftDeleted ? 'danger' : 'warning'}
|
||||||
|
copyable={false}
|
||||||
|
className='bg-background/70'
|
||||||
|
/>
|
||||||
|
{!row.original.isDraftDeleted && row.original.draft && (
|
||||||
|
<StatusBadge
|
||||||
|
label={t(getModeLabel(row.original.draft.billingMode))}
|
||||||
|
variant={getModeVariant(row.original.draft.billingMode)}
|
||||||
|
copyable={false}
|
||||||
|
className='bg-background/70'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className='truncate text-sm font-medium'>
|
||||||
|
{row.original.isDraftDeleted
|
||||||
|
? t('Will be removed')
|
||||||
|
: getPriceSummary(row.original.draft ?? row.original, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!row.original.isDraftDeleted && row.original.draft && (
|
||||||
|
<span className='text-muted-foreground truncate text-xs'>
|
||||||
|
{getPriceDetail(row.original.draft, t)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
sortingFn: (rowA, rowB) =>
|
sortingFn: (rowA, rowB) =>
|
||||||
@@ -849,18 +1007,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSave = useCallback(
|
|
||||||
(data: ModelRatioData) => {
|
|
||||||
persistPricingData(data)
|
|
||||||
setEditData(data)
|
|
||||||
setEditorOpen(true)
|
|
||||||
toast.success(
|
|
||||||
t('Pricing changes saved to draft. Click "Save model prices" to apply.')
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[persistPricingData, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleBatchCopy = useCallback(() => {
|
const handleBatchCopy = useCallback(() => {
|
||||||
if (!editData) {
|
if (!editData) {
|
||||||
toast.error(t('Open a source model first'))
|
toast.error(t('Open a source model first'))
|
||||||
@@ -901,12 +1047,10 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
[editorOpen, persistPricingData]
|
[editorOpen, persistPricingData]
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<div className='grid min-h-0 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(420px,0.82fr)] xl:grid-cols-[minmax(0,1.1fr)_minmax(520px,0.9fr)]'>
|
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
|
||||||
<div className='flex min-w-0 flex-col gap-4'>
|
<div className='flex min-h-0 min-w-0 flex-col gap-3'>
|
||||||
<DataTableToolbar
|
<DataTableToolbar
|
||||||
table={table}
|
table={table}
|
||||||
searchPlaceholder={t('Search models...')}
|
searchPlaceholder={t('Search models...')}
|
||||||
@@ -948,33 +1092,37 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
: t('No models configured. Use Add model to get started.')}
|
: t('No models configured. Use Add model to get started.')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='overflow-hidden rounded-md border'>
|
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||||
<Table>
|
<table className='w-full caption-bottom text-sm tabular-nums'>
|
||||||
<TableHeader>
|
<thead className='bg-background sticky top-0 z-10'>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<tr key={headerGroup.id} className='border-b'>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
<th
|
||||||
|
key={header.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
|
className='text-foreground h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap'
|
||||||
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</th>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</thead>
|
||||||
<TableBody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
className={
|
className={
|
||||||
editData?.name === row.original.name
|
editData?.name === row.original.name
|
||||||
? 'bg-muted/45'
|
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
|
||||||
: undefined
|
: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors'
|
||||||
}
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
@@ -983,17 +1131,20 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className='p-2 align-middle text-sm whitespace-nowrap'
|
||||||
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</td>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</tbody>
|
||||||
</Table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1002,18 +1153,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='hidden min-w-0 md:block'>
|
<div className='hidden min-h-0 min-w-0 md:block'>
|
||||||
{editorOpen ? (
|
{editorOpen ? (
|
||||||
<ModelPricingEditorPanel
|
<ModelPricingEditorPanel
|
||||||
ref={editorPanelRef}
|
ref={editorPanelRef}
|
||||||
onSave={handleSave}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
editData={editData}
|
editData={editData}
|
||||||
selectedTargetCount={selectedTargetCount}
|
className='h-full min-h-0'
|
||||||
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='bg-card text-muted-foreground sticky top-4 flex h-[calc(100vh-8rem)] min-h-[420px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
|
<div className='bg-card text-muted-foreground flex h-full min-h-0 flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
|
||||||
<div className='text-foreground text-base font-medium'>
|
<div className='text-foreground text-base font-medium'>
|
||||||
{t('Select a model to edit pricing')}
|
{t('Select a model to edit pricing')}
|
||||||
</div>
|
</div>
|
||||||
@@ -1045,10 +1193,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
|||||||
ref={editorPanelRef}
|
ref={editorPanelRef}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
onSave={handleSave}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
editData={editData}
|
editData={editData}
|
||||||
selectedTargetCount={selectedTargetCount}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ export function RatioSettingsCard({
|
|||||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||||
})
|
})
|
||||||
|
const [savedModelValues, setSavedModelValues] = useState(
|
||||||
|
modelNormalizedDefaults.current
|
||||||
|
)
|
||||||
|
|
||||||
const groupNormalizedDefaults = useRef({
|
const groupNormalizedDefaults = useRef({
|
||||||
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
||||||
@@ -315,6 +318,7 @@ export function RatioSettingsCard({
|
|||||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||||
}
|
}
|
||||||
|
setSavedModelValues(modelNormalizedDefaults.current)
|
||||||
|
|
||||||
modelForm.reset({
|
modelForm.reset({
|
||||||
...modelDefaults,
|
...modelDefaults,
|
||||||
@@ -395,6 +399,9 @@ export function RatioSettingsCard({
|
|||||||
const apiKey = apiKeyMap[key as string] || (key as string)
|
const apiKey = apiKeyMap[key as string] || (key as string)
|
||||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelNormalizedDefaults.current = normalized
|
||||||
|
setSavedModelValues(normalized)
|
||||||
},
|
},
|
||||||
[t, updateOption]
|
[t, updateOption]
|
||||||
)
|
)
|
||||||
@@ -462,6 +469,7 @@ export function RatioSettingsCard({
|
|||||||
return (
|
return (
|
||||||
<ModelRatioForm
|
<ModelRatioForm
|
||||||
form={modelForm}
|
form={modelForm}
|
||||||
|
savedValues={savedModelValues}
|
||||||
onSave={saveModelRatios}
|
onSave={saveModelRatios}
|
||||||
onReset={handleResetRatios}
|
onReset={handleResetRatios}
|
||||||
isSaving={updateOption.isPending}
|
isSaving={updateOption.isPending}
|
||||||
|
|||||||
Vendored
+2
@@ -1253,6 +1253,7 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
|
||||||
"Download": "Download",
|
"Download": "Download",
|
||||||
|
"Draft": "Draft",
|
||||||
"Draw": "Draw",
|
"Draw": "Draw",
|
||||||
"Drawing": "Drawing",
|
"Drawing": "Drawing",
|
||||||
"Drawing logs": "Drawing logs",
|
"Drawing logs": "Drawing logs",
|
||||||
@@ -4414,6 +4415,7 @@
|
|||||||
"We could not load the setup status.": "We could not load the setup status.",
|
"We could not load the setup status.": "We could not load the setup status.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "We will prompt your device to confirm using biometrics or your hardware key.",
|
||||||
"We'll be back online shortly.": "We'll be back online shortly.",
|
"We'll be back online shortly.": "We'll be back online shortly.",
|
||||||
|
"Will be removed": "Will be removed",
|
||||||
"Web search": "Web search",
|
"Web search": "Web search",
|
||||||
"Web Search": "Web Search",
|
"Web Search": "Web Search",
|
||||||
"Webhook Configuration:": "Webhook Configuration:",
|
"Webhook Configuration:": "Webhook Configuration:",
|
||||||
|
|||||||
Vendored
+2
@@ -1253,6 +1253,7 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
|
||||||
"Download": "Télécharger",
|
"Download": "Télécharger",
|
||||||
|
"Draft": "Brouillon",
|
||||||
"Draw": "Dessin",
|
"Draw": "Dessin",
|
||||||
"Drawing": "Dessin",
|
"Drawing": "Dessin",
|
||||||
"Drawing logs": "Journaux de dessin",
|
"Drawing logs": "Journaux de dessin",
|
||||||
@@ -4414,6 +4415,7 @@
|
|||||||
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
|
"We could not load the setup status.": "Nous n'avons pas pu charger l'état de la configuration.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "Nous allons demander à votre appareil de confirmer en utilisant la biométrie ou votre clé matérielle.",
|
||||||
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
|
"We'll be back online shortly.": "Nous serons de retour en ligne sous peu.",
|
||||||
|
"Will be removed": "Sera supprimé",
|
||||||
"Web search": "Recherche web",
|
"Web search": "Recherche web",
|
||||||
"Web Search": "Recherche web",
|
"Web Search": "Recherche web",
|
||||||
"Webhook Configuration:": "Configuration du Webhook :",
|
"Webhook Configuration:": "Configuration du Webhook :",
|
||||||
|
|||||||
Vendored
+2
@@ -1253,6 +1253,7 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
|
||||||
"Download": "ダウンロード",
|
"Download": "ダウンロード",
|
||||||
|
"Draft": "下書き",
|
||||||
"Draw": "描画",
|
"Draw": "描画",
|
||||||
"Drawing": "画像生成",
|
"Drawing": "画像生成",
|
||||||
"Drawing logs": "描画ログ",
|
"Drawing logs": "描画ログ",
|
||||||
@@ -4414,6 +4415,7 @@
|
|||||||
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
|
"We could not load the setup status.": "セットアップステータスを読み込めませんでした。",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "生体認証またはハードウェアキーを使用して確認するよう、デバイスにプロンプトが表示されます。",
|
||||||
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
|
"We'll be back online shortly.": "まもなくオンラインに戻ります。",
|
||||||
|
"Will be removed": "削除予定",
|
||||||
"Web search": "ウェブ検索",
|
"Web search": "ウェブ検索",
|
||||||
"Web Search": "Web 検索",
|
"Web Search": "Web 検索",
|
||||||
"Webhook Configuration:": "Webhook設定:",
|
"Webhook Configuration:": "Webhook設定:",
|
||||||
|
|||||||
Vendored
+2
@@ -1253,6 +1253,7 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
|
||||||
"Download": "Скачать",
|
"Download": "Скачать",
|
||||||
|
"Draft": "Черновик",
|
||||||
"Draw": "Рисование",
|
"Draw": "Рисование",
|
||||||
"Drawing": "Рисование",
|
"Drawing": "Рисование",
|
||||||
"Drawing logs": "Журналы рисования",
|
"Drawing logs": "Журналы рисования",
|
||||||
@@ -4414,6 +4415,7 @@
|
|||||||
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
|
"We could not load the setup status.": "Не удалось загрузить статус настройки.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "Мы предложим вашему устройству подтвердить действие с помощью биометрии или аппаратного ключа.",
|
||||||
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
|
"We'll be back online shortly.": "Мы скоро вернемся в сеть.",
|
||||||
|
"Will be removed": "Будет удалено",
|
||||||
"Web search": "Веб-поиск",
|
"Web search": "Веб-поиск",
|
||||||
"Web Search": "Веб-поиск",
|
"Web Search": "Веб-поиск",
|
||||||
"Webhook Configuration:": "Конфигурация веб-хука:",
|
"Webhook Configuration:": "Конфигурация веб-хука:",
|
||||||
|
|||||||
Vendored
+2
@@ -1253,6 +1253,7 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
|
||||||
"Download": "Tải xuống",
|
"Download": "Tải xuống",
|
||||||
|
"Draft": "Bản nháp",
|
||||||
"Draw": "Vẽ",
|
"Draw": "Vẽ",
|
||||||
"Drawing": "Vẽ",
|
"Drawing": "Vẽ",
|
||||||
"Drawing logs": "Nhật ký vẽ",
|
"Drawing logs": "Nhật ký vẽ",
|
||||||
@@ -4414,6 +4415,7 @@
|
|||||||
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
|
"We could not load the setup status.": "Chúng tôi không thể tải trạng thái thiết lập.",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "Chúng tôi sẽ yêu cầu thiết bị của bạn xác nhận bằng cách sử dụng sinh trắc học hoặc khóa bảo mật phần cứng của bạn.",
|
||||||
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
|
"We'll be back online shortly.": "Chúng tôi sẽ sớm trực tuyến trở lại.",
|
||||||
|
"Will be removed": "Sẽ bị xóa",
|
||||||
"Web search": "Tìm kiếm web",
|
"Web search": "Tìm kiếm web",
|
||||||
"Web Search": "Tìm kiếm web",
|
"Web Search": "Tìm kiếm web",
|
||||||
"Webhook Configuration:": "Cấu hình Webhook:",
|
"Webhook Configuration:": "Cấu hình Webhook:",
|
||||||
|
|||||||
Vendored
+2
@@ -1253,6 +1253,7 @@
|
|||||||
"DoubaoVideo": "DoubaoVideo",
|
"DoubaoVideo": "DoubaoVideo",
|
||||||
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
|
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
|
||||||
"Download": "下载",
|
"Download": "下载",
|
||||||
|
"Draft": "草稿",
|
||||||
"Draw": "绘图",
|
"Draw": "绘图",
|
||||||
"Drawing": "绘图",
|
"Drawing": "绘图",
|
||||||
"Drawing logs": "绘制日志",
|
"Drawing logs": "绘制日志",
|
||||||
@@ -4414,6 +4415,7 @@
|
|||||||
"We could not load the setup status.": "我们无法加载设置状态。",
|
"We could not load the setup status.": "我们无法加载设置状态。",
|
||||||
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
|
"We will prompt your device to confirm using biometrics or your hardware key.": "我们将提示您的设备使用生物识别或硬件密钥进行确认。",
|
||||||
"We'll be back online shortly.": "我们将很快恢复在线。",
|
"We'll be back online shortly.": "我们将很快恢复在线。",
|
||||||
|
"Will be removed": "将被移除",
|
||||||
"Web search": "网络搜索",
|
"Web search": "网络搜索",
|
||||||
"Web Search": "网页搜索",
|
"Web Search": "网页搜索",
|
||||||
"Webhook Configuration:": "Webhook 配置:",
|
"Webhook Configuration:": "Webhook 配置:",
|
||||||
|
|||||||
Reference in New Issue
Block a user