feat: add unset price models tab to model pricing settings (#6124)
* feat: add unset price models tab to model pricing settings * feat: add unset price models tab translations
This commit is contained in:
@@ -110,7 +110,7 @@ const BILLING_SECTIONS = [
|
||||
modelDefaults={getModelDefaults(settings)}
|
||||
groupDefaults={getGroupDefaults(settings)}
|
||||
toolPricesDefault={settings['tool_price_setting.prices']}
|
||||
visibleTabs={['models', 'tool-prices', 'upstream-sync']}
|
||||
visibleTabs={['models', 'unset-models', 'tool-prices', 'upstream-sync']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -61,6 +61,12 @@ export type ModelRow = ModelPricingSnapshot & {
|
||||
export const hasPricingValue = (value?: string) =>
|
||||
value !== undefined && value !== ''
|
||||
|
||||
export const isBasePricingUnset = (snapshot?: ModelPricingSnapshot) =>
|
||||
!snapshot ||
|
||||
(snapshot.billingMode !== 'tiered_expr' &&
|
||||
!hasPricingValue(snapshot.price) &&
|
||||
!hasPricingValue(snapshot.ratio))
|
||||
|
||||
const toNumberOrNull = (value?: string) => {
|
||||
if (!hasPricingValue(value)) return null
|
||||
const num = Number(value)
|
||||
|
||||
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { type UseFormReturn } from 'react-hook-form'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getEnabledModels } from '@/features/channels/api'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -65,6 +67,7 @@ type ModelRatioFormProps = {
|
||||
onReset: () => void
|
||||
isSaving: boolean
|
||||
isResetting: boolean
|
||||
variant?: 'default' | 'unset'
|
||||
}
|
||||
|
||||
type ModelJsonFieldName =
|
||||
@@ -164,11 +167,19 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
onReset,
|
||||
isSaving,
|
||||
isResetting,
|
||||
variant = 'default',
|
||||
}: ModelRatioFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const isUnsetVariant = variant === 'unset'
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
|
||||
|
||||
const enabledModelsQuery = useQuery({
|
||||
queryKey: ['enabled-models'],
|
||||
queryFn: getEnabledModels,
|
||||
enabled: isUnsetVariant,
|
||||
})
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ModelFormValues, value: string) => {
|
||||
form.setValue(field, value, {
|
||||
@@ -194,42 +205,44 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
{t('Reset prices')}
|
||||
</Button>
|
||||
{editMode === 'json' && (
|
||||
{!isUnsetVariant && (
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
{t('Reset prices')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
<Code2 className='mr-2 h-4 w-4' />
|
||||
{t('Switch to JSON')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className='mr-2 h-4 w-4' />
|
||||
{t('Switch to Visual')}
|
||||
</>
|
||||
{editMode === 'json' && (
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
{isSaving ? t('Saving...') : t('Save model prices')}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
<Code2 className='mr-2 h-4 w-4' />
|
||||
{t('Switch to JSON')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className='mr-2 h-4 w-4' />
|
||||
{t('Switch to Visual')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
{editMode === 'visual' ? (
|
||||
@@ -256,6 +269,10 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
audioCompletionRatio={form.watch('AudioCompletionRatio')}
|
||||
billingMode={form.watch('BillingMode')}
|
||||
billingExpr={form.watch('BillingExpr')}
|
||||
candidateModelNames={
|
||||
isUnsetVariant ? enabledModelsQuery.data?.data : undefined
|
||||
}
|
||||
filterMode={isUnsetVariant ? 'unset' : 'all'}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onChange={(field, value) => {
|
||||
@@ -269,28 +286,30 @@ export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ExposeRatioEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Expose ratio API')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow clients to query configured ratios via `/api/ratio`.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
{!isUnsetVariant && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ExposeRatioEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Expose ratio API')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow clients to query configured ratios via `/api/ratio`.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
|
||||
|
||||
@@ -42,12 +42,14 @@ const filterBySelectedValues = (
|
||||
type BuildModelRatioColumnsOptions = {
|
||||
onDelete: (name: string) => void
|
||||
onEdit: (model: ModelRow) => void
|
||||
deleteDisabled?: boolean
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function buildModelRatioColumns({
|
||||
onDelete,
|
||||
onEdit,
|
||||
deleteDisabled,
|
||||
t,
|
||||
}: BuildModelRatioColumnsOptions): ColumnDef<ModelRow>[] {
|
||||
return [
|
||||
@@ -151,6 +153,7 @@ export function buildModelRatioColumns({
|
||||
menuLabel={t('Open menu')}
|
||||
onEdit={() => onEdit(row.original)}
|
||||
onDelete={() => onDelete(row.original.name)}
|
||||
deleteDisabled={deleteDisabled}
|
||||
/>
|
||||
),
|
||||
enableHiding: false,
|
||||
|
||||
+62
-19
@@ -60,6 +60,7 @@ import {
|
||||
import {
|
||||
buildModelSnapshots,
|
||||
getSnapshotSignature,
|
||||
isBasePricingUnset,
|
||||
type ModelRow,
|
||||
} from './model-pricing-snapshots'
|
||||
import { buildModelRatioColumns } from './model-ratio-table-columns'
|
||||
@@ -85,6 +86,8 @@ type ModelRatioVisualEditorProps = {
|
||||
audioCompletionRatio: string
|
||||
billingMode: string
|
||||
billingExpr: string
|
||||
candidateModelNames?: string[]
|
||||
filterMode?: 'all' | 'unset'
|
||||
onChange: (field: string, value: string) => void
|
||||
onSave: () => void | Promise<void>
|
||||
isSaving: boolean
|
||||
@@ -121,6 +124,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
candidateModelNames,
|
||||
filterMode = 'all',
|
||||
onChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
@@ -208,18 +213,23 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
|
||||
const savedByName = new Map(savedRows.map((row) => [row.name, row]))
|
||||
const draftByName = new Map(draftRows.map((row) => [row.name, row]))
|
||||
const modelNames = new Set([...savedByName.keys(), ...draftByName.keys()])
|
||||
const modelNames = new Set([
|
||||
...(candidateModelNames ?? []),
|
||||
...savedByName.keys(),
|
||||
...draftByName.keys(),
|
||||
])
|
||||
|
||||
return Array.from(modelNames)
|
||||
.map((name) => {
|
||||
const saved = savedByName.get(name)
|
||||
const draft = draftByName.get(name)
|
||||
const displayed = saved ?? draft
|
||||
const displayed = saved ??
|
||||
draft ?? { name, billingMode: 'per-token', hasConflict: false }
|
||||
const savedSignature = getSnapshotSignature(saved)
|
||||
const draftSignature = getSnapshotSignature(draft)
|
||||
|
||||
return {
|
||||
...displayed!,
|
||||
...displayed,
|
||||
saved,
|
||||
draft,
|
||||
isDraftChanged: savedSignature !== draftSignature,
|
||||
@@ -228,8 +238,11 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
}
|
||||
})
|
||||
.filter((row) => !row.isDraftDeleted)
|
||||
.filter((row) => filterMode !== 'unset' || isBasePricingUnset(row.saved))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [
|
||||
candidateModelNames,
|
||||
filterMode,
|
||||
savedModelPrice,
|
||||
savedModelRatio,
|
||||
savedCacheRatio,
|
||||
@@ -423,14 +436,25 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
buildModelRatioColumns({
|
||||
onDelete: handleDelete,
|
||||
onEdit: handleEdit,
|
||||
deleteDisabled: filterMode === 'unset',
|
||||
t,
|
||||
}),
|
||||
[handleEdit, handleDelete, t]
|
||||
[handleEdit, handleDelete, filterMode, t]
|
||||
)
|
||||
|
||||
const ensurePageInRange = useCallback((pageCount: number) => {
|
||||
setPagination((prev) =>
|
||||
pageCount > 0 && prev.pageIndex >= pageCount
|
||||
? { ...prev, pageIndex: pageCount - 1 }
|
||||
: prev
|
||||
)
|
||||
}, [])
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
getRowId: (row) => row.name,
|
||||
ensurePageInRange,
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
@@ -585,12 +609,20 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
]
|
||||
)
|
||||
|
||||
const handleBatchCopy = useCallback(() => {
|
||||
const handleBatchCopy = useCallback(async () => {
|
||||
if (!editData) {
|
||||
toast.error(t('Open a source model first'))
|
||||
return
|
||||
}
|
||||
|
||||
let sourceData = editData
|
||||
if (editorOpen && editorPanelRef.current) {
|
||||
const committed = await editorPanelRef.current.commitDraft()
|
||||
if (!committed) return
|
||||
sourceData = committed
|
||||
setEditData(committed)
|
||||
}
|
||||
|
||||
const targetNames = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original.name)
|
||||
@@ -600,15 +632,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
return
|
||||
}
|
||||
|
||||
persistPricingData(editData, targetNames)
|
||||
persistPricingData(sourceData, targetNames)
|
||||
table.resetRowSelection()
|
||||
toast.success(
|
||||
t('Applied {{name}} pricing to {{count}} models', {
|
||||
name: editData.name,
|
||||
name: sourceData.name,
|
||||
count: targetNames.length,
|
||||
})
|
||||
)
|
||||
}, [editData, persistPricingData, t, table])
|
||||
}, [editData, editorOpen, persistPricingData, t, table])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -627,6 +659,13 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
|
||||
const hasRows = table.getRowModel().rows.length > 0
|
||||
|
||||
let emptyStateText = t('No models configured. Use Add model to get started.')
|
||||
if (table.getState().globalFilter) {
|
||||
emptyStateText = t('No models match your search')
|
||||
} else if (filterMode === 'unset') {
|
||||
emptyStateText = t('No models with unset prices')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<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)]'>
|
||||
@@ -658,18 +697,18 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
},
|
||||
]}
|
||||
preActions={
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
filterMode === 'unset' ? undefined : (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{!hasRows ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Use Add model to get started.')}
|
||||
{emptyStateText}
|
||||
</div>
|
||||
) : (
|
||||
<DataTableView
|
||||
@@ -743,10 +782,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
'Use the full-width table to scan prices, then select a row to edit it here.'
|
||||
)}
|
||||
</p>
|
||||
<Button variant='outline' onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
{filterMode !== 'unset' && (
|
||||
<Button variant='outline' onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -790,6 +831,8 @@ export const ModelRatioVisualEditor = memo(
|
||||
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
|
||||
prevProps.billingMode === nextProps.billingMode &&
|
||||
prevProps.billingExpr === nextProps.billingExpr &&
|
||||
prevProps.candidateModelNames === nextProps.candidateModelNames &&
|
||||
prevProps.filterMode === nextProps.filterMode &&
|
||||
prevProps.onChange === nextProps.onChange &&
|
||||
prevProps.onSave === nextProps.onSave &&
|
||||
prevProps.isSaving === nextProps.isSaving
|
||||
|
||||
@@ -136,7 +136,12 @@ const createGroupSchema = (t: Translate) =>
|
||||
|
||||
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
|
||||
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
|
||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||
type RatioTabId =
|
||||
| 'models'
|
||||
| 'unset-models'
|
||||
| 'groups'
|
||||
| 'tool-prices'
|
||||
| 'upstream-sync'
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
modelDefaults: ModelFormValues
|
||||
@@ -392,6 +397,7 @@ export function RatioSettingsCard({
|
||||
|
||||
const tabLabels: Record<RatioTabId, string> = {
|
||||
models: 'Model prices',
|
||||
'unset-models': 'Unset price models',
|
||||
groups: 'Group ratios',
|
||||
'tool-prices': 'Tool prices',
|
||||
'upstream-sync': 'Upstream price sync',
|
||||
@@ -402,11 +408,12 @@ export function RatioSettingsCard({
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
5: 'grid-cols-5',
|
||||
}[visibleTabs.length] ?? 'grid-cols-4'
|
||||
const defaultTab = visibleTabs[0] ?? 'models'
|
||||
|
||||
const renderTabContent = (tab: RatioTabId) => {
|
||||
if (tab === 'models') {
|
||||
if (tab === 'models' || tab === 'unset-models') {
|
||||
return (
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
@@ -415,6 +422,7 @@ export function RatioSettingsCard({
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
isResetting={resetMutation.isPending}
|
||||
variant={tab === 'unset-models' ? 'unset' : 'default'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "No models to add",
|
||||
"No models to copy": "No models to copy",
|
||||
"No models to remove": "No models to remove",
|
||||
"No models with unset prices": "No models with unset prices",
|
||||
"No new models to add": "No new models to add",
|
||||
"No new models yet": "No new models yet",
|
||||
"No nodes": "No nodes",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "Unlimited Quota",
|
||||
"Unsaved changes": "Unsaved changes",
|
||||
"Unset price": "Unset price",
|
||||
"Unset price models": "Unset price models",
|
||||
"Until": "Until",
|
||||
"Untitled": "Untitled",
|
||||
"Untrusted upstream data:": "Untrusted upstream data:",
|
||||
|
||||
Vendored
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "Aucun modèle à ajouter",
|
||||
"No models to copy": "Aucun modèle à copier",
|
||||
"No models to remove": "Aucun modèle à supprimer",
|
||||
"No models with unset prices": "Aucun modèle sans prix",
|
||||
"No new models to add": "Aucun nouveau modèle à ajouter",
|
||||
"No new models yet": "Pas encore de nouveaux modèles",
|
||||
"No nodes": "Aucun nœud",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "Quota illimité",
|
||||
"Unsaved changes": "Modifications non enregistrées",
|
||||
"Unset price": "Prix non défini",
|
||||
"Unset price models": "Modèles sans prix",
|
||||
"Until": "Jusqu'au",
|
||||
"Untitled": "Sans titre",
|
||||
"Untrusted upstream data:": "Données amont non fiables :",
|
||||
|
||||
Vendored
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "追加するモデルがありません",
|
||||
"No models to copy": "コピーするモデルがありません",
|
||||
"No models to remove": "削除するモデルがありません",
|
||||
"No models with unset prices": "価格未設定のモデルはありません",
|
||||
"No new models to add": "追加する新しいモデルはありません",
|
||||
"No new models yet": "新しいモデルはまだありません",
|
||||
"No nodes": "ノードなし",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "無制限のクォータ",
|
||||
"Unsaved changes": "未保存の変更",
|
||||
"Unset price": "価格未設定",
|
||||
"Unset price models": "価格が未設定のモデル",
|
||||
"Until": "まで",
|
||||
"Untitled": "無題",
|
||||
"Untrusted upstream data:": "信頼されていないアップストリームデータ:",
|
||||
|
||||
Vendored
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "Нет моделей для добавления",
|
||||
"No models to copy": "Нет моделей для копирования",
|
||||
"No models to remove": "Нет моделей для удаления",
|
||||
"No models with unset prices": "Нет моделей без цены",
|
||||
"No new models to add": "Нет новых моделей для добавления",
|
||||
"No new models yet": "Новых моделей пока нет",
|
||||
"No nodes": "Нет узлов",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "Неограниченная квота",
|
||||
"Unsaved changes": "Несохранённые изменения",
|
||||
"Unset price": "Цена не задана",
|
||||
"Unset price models": "Модели с неустановленной ценой",
|
||||
"Until": "До",
|
||||
"Untitled": "Без названия",
|
||||
"Untrusted upstream data:": "Недоверенные вышестоящие данные:",
|
||||
|
||||
Vendored
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "Không có mô hình để thêm",
|
||||
"No models to copy": "Không có mô hình nào để sao chép",
|
||||
"No models to remove": "Không có mô hình để xóa",
|
||||
"No models with unset prices": "Không có mô hình chưa thiết lập giá",
|
||||
"No new models to add": "Không có mô hình mới để thêm",
|
||||
"No new models yet": "Chưa có mô hình mới",
|
||||
"No nodes": "Không có nút",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "Hạn mức không giới hạn",
|
||||
"Unsaved changes": "Thay đổi chưa được lưu",
|
||||
"Unset price": "Chưa đặt giá",
|
||||
"Unset price models": "Mô hình chưa thiết lập giá",
|
||||
"Until": "Cho đến",
|
||||
"Untitled": "Không có tiêu đề",
|
||||
"Untrusted upstream data:": "Dữ liệu nguồn không đáng tin cậy:",
|
||||
|
||||
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "無待新增模型",
|
||||
"No models to copy": "沒有模型可複製",
|
||||
"No models to remove": "無待刪除模型",
|
||||
"No models with unset prices": "沒有未設定定價的模型",
|
||||
"No new models to add": "沒有新模型可新增",
|
||||
"No new models yet": "暫無新模型",
|
||||
"No nodes": "無節點",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "無限配額",
|
||||
"Unsaved changes": "未儲存的變更",
|
||||
"Unset price": "未設定價格",
|
||||
"Unset price models": "未設定價格模型",
|
||||
"Until": "至",
|
||||
"Untitled": "未命名",
|
||||
"Untrusted upstream data:": "不受信任的上游數據:",
|
||||
|
||||
Vendored
+2
@@ -2925,6 +2925,7 @@
|
||||
"No models to add": "无待新增模型",
|
||||
"No models to copy": "没有模型可复制",
|
||||
"No models to remove": "无待删除模型",
|
||||
"No models with unset prices": "没有未设置定价的模型",
|
||||
"No new models to add": "没有新模型可添加",
|
||||
"No new models yet": "暂无新模型",
|
||||
"No nodes": "无节点",
|
||||
@@ -4764,6 +4765,7 @@
|
||||
"Unlimited Quota": "无限配额",
|
||||
"Unsaved changes": "未保存的更改",
|
||||
"Unset price": "未设置价格",
|
||||
"Unset price models": "未设置价格模型",
|
||||
"Until": "至",
|
||||
"Untitled": "未命名",
|
||||
"Untrusted upstream data:": "不受信任的上游数据:",
|
||||
|
||||
Reference in New Issue
Block a user