Commit a77bdb2c by ccran

feat: 新版前端增加批量导入支持;

parent b9106f6f
......@@ -7,7 +7,7 @@ COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/default ./default
COPY ./VERSION /build/VERSION
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun ../node_modules/@rsbuild/core/bin/rsbuild.js build
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM docker.m.daocloud.io/oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
......@@ -18,7 +18,7 @@ COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun ../node_modules/@rsbuild/core/bin/rsbuild.js build
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM docker.m.daocloud.io/golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -11,6 +11,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"@visactor/vchart-theme-utils": "~1.8.8",
"axios": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
......
......@@ -10,7 +10,7 @@ const semiUiDir = path.resolve(
path.dirname(require.resolve('@douyinfe/semi-ui')),
'../..',
)
const semiUiDateFnsDir = path.resolve(semiUiDir, 'node_modules/date-fns')
const semiUiDateFnsDir = path.resolve(semiUiDir, '../../date-fns')
const resolvePackageRoot = (packageName: string, baseRequire = require) => {
let current = path.dirname(baseRequire.resolve(packageName))
while (current !== path.dirname(current)) {
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowUp01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { type FormEvent, type ReactNode, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner'
type ExcelImportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: ReactNode
isPending: boolean
allowOverwrite?: boolean
onImport: (file: File, overwrite: boolean) => Promise<boolean>
}
export function ExcelImportDialog(props: ExcelImportDialogProps) {
const { t } = useTranslation()
const formId = useId()
const fileInputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [overwrite, setOverwrite] = useState(false)
const reset = () => {
setFile(null)
setOverwrite(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleOpenChange = (open: boolean) => {
if (!open && props.isPending) return
if (!open) reset()
props.onOpenChange(open)
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!file || props.isPending) return
const success = await props.onImport(file, overwrite)
if (!success) return
reset()
props.onOpenChange(false)
}
return (
<Dialog
open={props.open}
onOpenChange={handleOpenChange}
title={props.title}
description={props.description}
showCloseButton={!props.isPending}
bodyClassName='flex flex-col gap-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={props.isPending}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={formId}
disabled={!file || props.isPending}
>
{props.isPending ? (
<Spinner data-icon='inline-start' />
) : (
<HugeiconsIcon
icon={ArrowUp01Icon}
data-icon='inline-start'
aria-hidden='true'
/>
)}
{props.isPending ? t('Importing...') : t('Import')}
</Button>
</>
}
>
{props.children}
<form id={formId} onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor={`${formId}-file`}>
{t('Excel file')}
</FieldLabel>
<Input
ref={fileInputRef}
id={`${formId}-file`}
type='file'
accept='.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
disabled={props.isPending}
onChange={(event) =>
setFile(event.currentTarget.files?.[0] ?? null)
}
/>
<FieldDescription>
{file
? t('Selected file: {{name}}', { name: file.name })
: t(
'Select an .xlsx file to import. The maximum file size is 10 MB.'
)}
</FieldDescription>
</Field>
{props.allowOverwrite ? (
<Field orientation='horizontal' data-disabled={props.isPending}>
<Checkbox
id={`${formId}-overwrite`}
checked={overwrite}
onCheckedChange={setOverwrite}
disabled={props.isPending}
/>
<FieldLabel
htmlFor={`${formId}-overwrite`}
className='font-normal'
>
{t('Overwrite existing models')}
</FieldLabel>
</Field>
) : null}
</FieldGroup>
</form>
</Dialog>
)
}
......@@ -70,6 +70,12 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import {
Form,
FormControl,
FormDescription,
......@@ -600,6 +606,7 @@ export function ChannelMutateDrawer({
)
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [bulkModels, setBulkModels] = useState('')
const [channelKey, setChannelKey] = useState<string | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
......@@ -680,6 +687,7 @@ export function ChannelMutateDrawer({
if (!open) {
setChannelKey(null)
setIsChannelKeyLoading(false)
setBulkModels('')
} else if (channelId) {
setChannelKey(null)
}
......@@ -1394,6 +1402,30 @@ export function ChannelMutateDrawer({
[currentModelsArray, form]
)
const handleAddBulkModels = useCallback(() => {
const parsedModels = [
...new Set(
bulkModels
.split(/[\s,,;;]+/)
.map((model) => model.trim())
.filter(Boolean)
),
]
const currentModelSet = new Set(currentModelsArray)
const newModels = parsedModels.filter(
(model) => !currentModelSet.has(model)
)
if (newModels.length === 0) {
toast.info(t('No new models to add'))
return
}
updateModels(newModels, true)
setBulkModels('')
toast.success(t('Added {{count}} model(s)', { count: newModels.length }))
}, [bulkModels, currentModelsArray, t, updateModels])
// Handle fetching models from upstream
const handleFetchModels = useCallback(async () => {
const type = form.getValues('type')
......@@ -3242,6 +3274,40 @@ export function ChannelMutateDrawer({
copyChipOnClick
/>
</FormControl>
<FieldGroup className='mt-1'>
<Field>
<FieldLabel htmlFor='channel-bulk-models'>
{t('Add models in bulk')}
</FieldLabel>
<Textarea
id='channel-bulk-models'
value={bulkModels}
onChange={(event) =>
setBulkModels(event.target.value)
}
placeholder={t(
'Paste model names here'
)}
rows={3}
/>
<FieldDescription>
{t(
'Paste model names separated by commas, spaces, semicolons, or line breaks.'
)}
</FieldDescription>
<div className='flex justify-end'>
<Button
type='button'
variant='secondary'
size='sm'
onClick={handleAddBulkModels}
disabled={!bulkModels.trim()}
>
{t('Add Models')}
</Button>
</div>
</Field>
</FieldGroup>
{modelMappingGuardrail.exposedTargetModels
.length > 0 && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
......
......@@ -36,6 +36,7 @@ import type {
SyncOverwritePayload,
DeploymentSettingsResponse,
ListDeploymentsResponse,
ImportModelsResponse,
} from './types'
// ============================================================================
......@@ -111,6 +112,27 @@ export async function deleteModel(
return res.data
}
/**
* Import model metadata from an Excel workbook
*/
export async function importModels(
file: File,
overwrite: boolean
): Promise<ImportModelsResponse> {
const formData = new FormData()
formData.append('file', file)
const res = await api.post<ImportModelsResponse>(
'/api/models/import',
formData,
{
params: overwrite ? { overwrite: true } : undefined,
skipBusinessError: true,
}
)
return res.data
}
// ============================================================================
// Vendor Management
// ============================================================================
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { InformationCircleIcon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ExcelImportDialog } from '@/components/excel-import-dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { importModels } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
type ImportModelsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ImportModelsDialog(props: ImportModelsDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { isPending, mutateAsync } = useMutation({
mutationFn: (variables: { file: File; overwrite: boolean }) =>
importModels(variables.file, variables.overwrite),
})
const handleImport = useCallback(
async (file: File, overwrite: boolean) => {
try {
const response = await mutateAsync({ file, overwrite })
if (!response.success) {
toast.error(response.message || t('Model import failed'))
return false
}
const result = response.data
toast.success(
t(
'Model import completed: {{created}} created, {{updated}} updated, {{skipped}} skipped, {{failed}} failed.',
{
created: result?.created ?? 0,
updated: result?.updated ?? 0,
skipped: result?.skipped ?? 0,
failed: result?.failed ?? 0,
}
)
)
await Promise.all([
queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() }),
queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() }),
])
return true
} catch {
return false
}
},
[mutateAsync, queryClient, t]
)
return (
<ExcelImportDialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import models')}
description={t('Import model metadata from an Excel workbook.')}
isPending={isPending}
allowOverwrite
onImport={handleImport}
>
<Alert>
<HugeiconsIcon icon={InformationCircleIcon} aria-hidden='true' />
<AlertTitle>{t('Model import requirements')}</AlertTitle>
<AlertDescription>
{t(
'The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.'
)}
</AlertDescription>
</Alert>
</ExcelImportDialog>
)
}
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { DescriptionDialog } from './dialogs/description-dialog'
import { ImportModelsDialog } from './dialogs/import-models-dialog'
import { MissingModelsDialog } from './dialogs/missing-models-dialog'
import { PrefillGroupManagement } from './dialogs/prefill-group-management'
import { SyncWizardDialog } from './dialogs/sync-wizard-dialog'
......@@ -57,6 +58,11 @@ export function ModelsDialogs() {
onOpenChange={(v) => !v && setOpen(null)}
/>
<ImportModelsDialog
open={open === 'import-models'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Sync Wizard Dialog */}
<SyncWizardDialog
open={open === 'sync-wizard'}
......
......@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowUp01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Plus,
MoreHorizontal,
......@@ -55,6 +57,10 @@ export function ModelsPrimaryButtons() {
setOpen('sync-wizard')
}
const handleImport = () => {
setOpen('import-models')
}
const handlePrefillGroups = () => {
setOpen('prefill-groups')
}
......@@ -91,6 +97,13 @@ export function ModelsPrimaryButtons() {
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleImport}>
{t('Import models')}
<DropdownMenuShortcut>
<HugeiconsIcon icon={ArrowUp01Icon} aria-hidden='true' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handlePrefillGroups}>
......
......@@ -38,6 +38,7 @@ type DialogType =
| 'create-vendor'
| 'update-vendor'
| 'missing-models'
| 'import-models'
| 'sync-wizard'
| 'upstream-conflict'
| 'prefill-groups'
......
......@@ -220,6 +220,23 @@ export interface PrefillGroupsResponse {
data?: PrefillGroup[]
}
export interface ImportModelsResponse {
success: boolean
message?: string
data?: {
created: number
updated: number
skipped: number
failed: number
rows: Array<{
row: number
model_name?: string
status: string
message?: string
}>
}
}
// ============================================================================
// Form Data Types
// ============================================================================
......
......@@ -21,6 +21,7 @@ import { api } from '@/lib/api'
import type {
ConfirmPaymentComplianceResponse,
FetchUpstreamRatiosRequest,
ImportModelPricingResponse,
LogCleanupTask,
SystemOptionsResponse,
SystemTaskListResponse,
......@@ -91,6 +92,20 @@ export async function resetModelRatios() {
return res.data
}
export async function importModelPricing(
file: File
): Promise<ImportModelPricingResponse> {
const formData = new FormData()
formData.append('file', file)
const res = await api.post<ImportModelPricingResponse>(
'/api/option/import_model_pricing',
formData,
{ skipBusinessError: true }
)
return res.data
}
export async function getUpstreamChannels() {
const res = await api.get<UpstreamChannelsResponse>(
'/api/ratio_sync/channels'
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Alert02Icon, InformationCircleIcon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ExcelImportDialog } from '@/components/excel-import-dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { importModelPricing } from '../api'
type ModelPricingImportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ModelPricingImportDialog(props: ModelPricingImportDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { isPending, mutateAsync } = useMutation({
mutationFn: importModelPricing,
})
const handleImport = useCallback(
async (file: File) => {
try {
const response = await mutateAsync(file)
if (!response.success) {
toast.error(response.message || t('Model pricing import failed'))
return false
}
const result = response.data
toast.success(
t(
'Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.',
{
updated: result?.updated ?? 0,
perToken: result?.per_token ?? 0,
perRequest: result?.per_request ?? 0,
failed: result?.failed ?? 0,
}
)
)
await queryClient.invalidateQueries({ queryKey: ['system-options'] })
return true
} catch {
return false
}
},
[mutateAsync, queryClient, t]
)
return (
<ExcelImportDialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import model pricing')}
description={t('Import model prices from an Excel workbook.')}
isPending={isPending}
onImport={handleImport}
>
<div className='flex flex-col gap-3'>
<Alert>
<HugeiconsIcon icon={InformationCircleIcon} aria-hidden='true' />
<AlertTitle>{t('Pricing import requirements')}</AlertTitle>
<AlertDescription>
{t(
'The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.'
)}
</AlertDescription>
</Alert>
<Alert>
<HugeiconsIcon icon={Alert02Icon} aria-hidden='true' />
<AlertTitle>{t('Existing pricing will be replaced')}</AlertTitle>
<AlertDescription>
{t(
'Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.'
)}
</AlertDescription>
</Alert>
</div>
</ExcelImportDialog>
)
}
......@@ -16,9 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ArrowUp01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import type { UseFormReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { JsonCodeEditor } from '@/components/json-code-editor'
......@@ -39,6 +41,7 @@ import {
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { ModelPricingImportDialog } from './model-pricing-import-dialog'
import {
ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle,
......@@ -167,6 +170,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}: ModelRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const [importOpen, setImportOpen] = useState(false)
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const handleFieldChange = useCallback(
......@@ -197,6 +201,19 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<div className='flex flex-wrap justify-end gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setImportOpen(true)}
>
<HugeiconsIcon
icon={ArrowUp01Icon}
data-icon='inline-start'
aria-hidden='true'
/>
{t('Excel import')}
</Button>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
......@@ -331,6 +348,10 @@ export const ModelRatioForm = memo(function ModelRatioForm({
</SettingsForm>
)}
</Form>
<ModelPricingImportDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</div>
)
})
......@@ -39,6 +39,24 @@ export type UpdateOptionResponse = {
message: string
}
export type ImportModelPricingResponse = {
success: boolean
message?: string
data?: {
updated: number
per_token: number
per_request: number
failed: number
rows: Array<{
row: number
model_name?: string
mode?: string
status: string
message?: string
}>
}
}
export type ConfirmPaymentComplianceResponse = {
success: boolean
message: string
......
......@@ -191,6 +191,7 @@
"Add Model": "Add Model",
"Add model pricing": "Add model pricing",
"Add Models": "Add Models",
"Add models in bulk": "Add models in bulk",
"Add new amount": "Add new amount",
"Add new redemption code(s) by providing necessary info.": "Add new redemption code(s) by providing necessary info.",
"Add OAuth Provider": "Add OAuth Provider",
......@@ -1719,6 +1720,8 @@
"Example:": "Example:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
"example.com&#10;company.com": "example.com&#10;company.com",
"Excel file": "Excel file",
"Excel import": "Excel import",
"Excellent": "Excellent",
"Exchange rate is required": "Exchange rate is required",
"Exchange rate must be greater than 0": "Exchange rate must be greater than 0",
......@@ -1727,6 +1730,7 @@
"Exhausted": "Exhausted",
"Existing account will be reused": "Existing account will be reused",
"Existing Models ({{count}})": "Existing Models ({{count}})",
"Existing pricing will be replaced": "Existing pricing will be replaced",
"Exists": "Exists",
"Expand": "Expand",
"Expand All": "Expand All",
......@@ -2256,8 +2260,15 @@
"Image to Video": "Image to Video",
"Image Tokens": "Image Tokens",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.",
"Import": "Import",
"Import model metadata from an Excel workbook.": "Import model metadata from an Excel workbook.",
"Import model prices from an Excel workbook.": "Import model prices from an Excel workbook.",
"Import model pricing": "Import model pricing",
"Import models": "Import models",
"Import to CC Switch": "Import to CC Switch",
"Important": "Important",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.",
"Importing...": "Importing...",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.",
"In Progress": "In Progress",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.",
......@@ -2625,6 +2636,9 @@
"Model enabled successfully": "Model enabled successfully",
"Model fixed pricing": "Model fixed pricing",
"Model Group": "Model Group",
"Model import completed: {{created}} created, {{updated}} updated, {{skipped}} skipped, {{failed}} failed.": "Model import completed: {{created}} created, {{updated}} updated, {{skipped}} skipped, {{failed}} failed.",
"Model import failed": "Model import failed",
"Model import requirements": "Model import requirements",
"Model Limits": "Model Limits",
"Model Mapping": "Model Mapping",
"Model Mapping (JSON)": "Model Mapping (JSON)",
......@@ -2647,6 +2661,8 @@
"Model prices": "Model prices",
"Model prices reset successfully": "Model prices reset successfully",
"Model Pricing": "Model Pricing",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.",
"Model pricing import failed": "Model pricing import failed",
"Model pull failed: {{msg}}": "Model pull failed: {{msg}}",
"Model ratio": "Model ratio",
"Model ratios": "Model ratios",
......@@ -3152,6 +3168,7 @@
"overrides for matching model prefix.": "overrides for matching model prefix.",
"Overrode user quota from {{from}} to {{to}}": "Overrode user quota from {{from}} to {{to}}",
"Overview": "Overview",
"Overwrite existing models": "Overwrite existing models",
"Overwritten": "Overwritten",
"Page": "Page",
"Page {{current}} of {{total}}": "Page {{current}} of {{total}}",
......@@ -3217,6 +3234,8 @@
"Passwords do not match": "Passwords do not match",
"Passwords don't match.": "Passwords don't match.",
"Paste Connection Info": "Paste Connection Info",
"Paste model names here": "Paste model names here",
"Paste model names separated by commas, spaces, semicolons, or line breaks.": "Paste model names separated by commas, spaces, semicolons, or line breaks.",
"Path": "Path",
"Path not set": "Path not set",
"Path Regex (one per line)": "Path Regex (one per line)",
......@@ -3436,6 +3455,7 @@
"Pricing Configuration": "Pricing Configuration",
"Pricing group example": "Pricing group example",
"Pricing groups": "Pricing groups",
"Pricing import requirements": "Pricing import requirements",
"Pricing mode": "Pricing mode",
"Pricing Ratios": "Pricing Ratios",
"Pricing Type": "Pricing Type",
......@@ -3778,6 +3798,7 @@
"Reset usage window": "Reset usage window",
"Resets in:": "Resets in:",
"Resetting...": "Resetting...",
"Resize column": "Resize column",
"Resolve Conflicts": "Resolve Conflicts",
"Resource Configuration": "Resource Configuration",
"Resources": "Resources",
......@@ -3966,6 +3987,7 @@
"Select all (filtered)": "Select all (filtered)",
"Select all models": "Select all models",
"Select All Visible": "Select All Visible",
"Select an .xlsx file to import. The maximum file size is 10 MB.": "Select an .xlsx file to import. The maximum file size is 10 MB.",
"Select an operation mode and enter the amount": "Select an operation mode and enter the amount",
"Select announcement type": "Select announcement type",
"Select at least one field to overwrite.": "Select at least one field to overwrite.",
......@@ -4029,6 +4051,7 @@
"Selected {{count}}": "Selected {{count}}",
"selected channel(s). Leave empty to remove tag.": "selected channel(s). Leave empty to remove tag.",
"Selected conflicts were overwritten successfully.": "Selected conflicts were overwritten successfully.",
"Selected file: {{name}}": "Selected file: {{name}}",
"Selected nodes": "Selected nodes",
"Selected when creating a token and used as the default billing group for API calls.": "Selected when creating a token and used as the default billing group for API calls.",
"Self-Use Mode": "Self-Use Mode",
......@@ -4401,6 +4424,8 @@
"The entered text does not match the required text.": "The entered text does not match the required text.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.",
"The exact model identifier as used in API requests.": "The exact model identifier as used in API requests.",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:",
"The mapped upstream model(s)": "The mapped upstream model(s)",
......
......@@ -191,6 +191,7 @@
"Add Model": "添加模型",
"Add model pricing": "添加模型定价",
"Add Models": "新增模型",
"Add models in bulk": "批量添加模型",
"Add new amount": "添加新金额",
"Add new redemption code(s) by providing necessary info.": "通过提供必要信息添加新的兑换码。",
"Add OAuth Provider": "添加 OAuth 提供商",
......@@ -1719,6 +1720,8 @@
"Example:": "示例:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
"example.com&#10;company.com": "example.com&#10;company.com",
"Excel file": "Excel 文件",
"Excel import": "Excel 导入",
"Excellent": "优秀",
"Exchange rate is required": "汇率为必填项",
"Exchange rate must be greater than 0": "汇率必须大于 0",
......@@ -1727,6 +1730,7 @@
"Exhausted": "已耗尽",
"Existing account will be reused": "将使用现有账户",
"Existing Models ({{count}})": "现有模型 ({{count}})",
"Existing pricing will be replaced": "现有定价将被覆盖",
"Exists": "存在",
"Expand": "展开",
"Expand All": "全部展开",
......@@ -2256,8 +2260,15 @@
"Image to Video": "图生视频",
"Image Tokens": "图像 Token",
"Imagine the pricing table has three groups: default (ratio 1.0), premium (ratio 0.5), and vip (ratio 0.8). Users whose account is in the vip group get user-level perks, and premium is a cheaper channel pool that users can pick for their tokens.": "假设定价分组表里有三个分组:default(倍率 1.0)、premium(倍率 0.5)、vip(倍率 0.8)。账号在 vip 分组的用户享受用户级待遇,premium 则是一个更便宜的渠道池,用户建令牌时可以选它。",
"Import": "导入",
"Import model metadata from an Excel workbook.": "从 Excel 工作簿导入模型元数据。",
"Import model prices from an Excel workbook.": "从 Excel 工作簿导入模型定价。",
"Import model pricing": "导入模型定价",
"Import models": "导入模型",
"Import to CC Switch": "填入 CC Switch",
"Important": "重要",
"Importing replaces the billing mode for matching models: per-token billing clears fixed prices, while per-request billing clears ratio settings.": "导入会覆盖匹配模型的计费方式:按量计费会清除固定价格,按次计费会清除倍率设置。",
"Importing...": "正在导入...",
"In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外层键是用户分组,内层键是计费分组。下面的示例表示:vip 用户按 standard 计费时用 0.8,按 premium 计费时用 0.3。",
"In Progress": "进行中",
"In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可视化编辑器中显示为「额外可见」和「屏蔽」。在 JSON 中,+:(或无前缀)表示添加分组,-: 表示移除分组。",
......@@ -2625,6 +2636,9 @@
"Model enabled successfully": "模型启用成功",
"Model fixed pricing": "模型固定定价",
"Model Group": "模型分组",
"Model import completed: {{created}} created, {{updated}} updated, {{skipped}} skipped, {{failed}} failed.": "模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个。",
"Model import failed": "模型导入失败",
"Model import requirements": "模型导入要求",
"Model Limits": "模型限制",
"Model Mapping": "模型映射",
"Model Mapping (JSON)": "模型映射 (JSON)",
......@@ -2647,6 +2661,8 @@
"Model prices": "模型价格",
"Model prices reset successfully": "模型价格重置成功",
"Model Pricing": "模型定价",
"Model pricing import completed: {{updated}} updated, {{perToken}} per-token, {{perRequest}} per-request, {{failed}} failed.": "模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个。",
"Model pricing import failed": "模型定价导入失败",
"Model pull failed: {{msg}}": "模型拉取失败:{{msg}}",
"Model ratio": "模型倍率",
"Model ratios": "模型比例",
......@@ -3152,6 +3168,7 @@
"overrides for matching model prefix.": "为匹配模型前缀的覆盖价。",
"Overrode user quota from {{from}} to {{to}}": "覆盖用户额度,从 {{from}} 改为 {{to}}",
"Overview": "概览",
"Overwrite existing models": "覆盖已存在的模型",
"Overwritten": "已覆盖",
"Page": "页面",
"Page {{current}} of {{total}}": "第 {{current}} 页,共 {{total}} 页",
......@@ -3217,6 +3234,8 @@
"Passwords do not match": "密码不匹配",
"Passwords don't match.": "两次输入的密码不一致。",
"Paste Connection Info": "粘贴连接信息",
"Paste model names here": "在此粘贴模型名称",
"Paste model names separated by commas, spaces, semicolons, or line breaks.": "使用逗号、空格、分号或换行分隔模型名称。",
"Path": "路径",
"Path not set": "未设置路径",
"Path Regex (one per line)": "路径正则(每行一个)",
......@@ -3436,6 +3455,7 @@
"Pricing Configuration": "定价配置",
"Pricing group example": "定价分组示例",
"Pricing groups": "定价分组",
"Pricing import requirements": "定价导入要求",
"Pricing mode": "定价模式",
"Pricing Ratios": "定价比例",
"Pricing Type": "定价类型",
......@@ -3778,6 +3798,7 @@
"Reset usage window": "重置用量窗口",
"Resets in:": "将于以下时间重置:",
"Resetting...": "重置中...",
"Resize column": "调整列宽",
"Resolve Conflicts": "解决冲突",
"Resource Configuration": "资源配置",
"Resources": "资源",
......@@ -3966,6 +3987,7 @@
"Select all (filtered)": "全选(筛选结果)",
"Select all models": "选择所有模型",
"Select All Visible": "全选当前",
"Select an .xlsx file to import. The maximum file size is 10 MB.": "请选择要导入的 .xlsx 文件,文件大小不能超过 10 MB。",
"Select an operation mode and enter the amount": "选择操作模式并输入金额",
"Select announcement type": "选择公告类型",
"Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。",
......@@ -4029,6 +4051,7 @@
"Selected {{count}}": "已选 {{count}} 个",
"selected channel(s). Leave empty to remove tag.": "选定的渠道。留空以移除标签。",
"Selected conflicts were overwritten successfully.": "选中的冲突已成功覆盖。",
"Selected file: {{name}}": "已选择文件:{{name}}",
"Selected nodes": "已选节点",
"Selected when creating a token and used as the default billing group for API calls.": "创建令牌时选择,用作 API 调用的默认计费分组。",
"Self-Use Mode": "自用模式",
......@@ -4401,6 +4424,8 @@
"The entered text does not match the required text.": "输入文本与要求文本不匹配。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "环境(测试或生产)由你在此粘贴的密钥决定。集成期间使用测试密钥,上线时再切换为生产密钥。",
"The exact model identifier as used in API requests.": "API 请求中使用的确切模型标识符。",
"The first row must contain headers, and model_name is required. For per-request billing, provide fixed_price. Otherwise input_price is required; optional columns are completion_price, cache_price, create_cache_price, image_price, audio_input_price, and audio_output_price.": "第一行必须是表头,且 model_name 为必填列。按次计费请填写 fixed_price;否则必须填写 input_price。可选列:completion_price、cache_price、create_cache_price、image_price、audio_input_price、audio_output_price。",
"The first row must contain headers, and model_name is required. Optional columns: description, icon, tags, vendor_name, endpoints, status, sync_official, name_rule.": "第一行必须是表头,且 model_name 为必填列。可选列:description、icon、tags、vendor_name、endpoints、status、sync_official、name_rule。",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在计费类型冲突(固定价格 vs 比例计费)。确认以继续更改。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重定向里的下列模型尚未添加到\"模型\"列表,调用时会因为缺少可用模型而失败:",
"The mapped upstream model(s)": "映射的上游模型",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment