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:
QuentinHsu
2026-06-04 17:22:50 +08:00
parent 39e05118ff
commit 77d3157592
10 changed files with 561 additions and 473 deletions
@@ -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')}
@@ -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}
+2
View File
@@ -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:",
+2
View File
@@ -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 :",
+2
View File
@@ -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設定:",
+2
View File
@@ -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:": "Конфигурация веб-хука:",
+2
View File
@@ -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:",
+2
View File
@@ -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 配置:",