mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
fix(channels): simplify model redirects and clarify panel controls
This commit is contained in:
@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { ArrowExpand02Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -60,6 +62,8 @@ type FloatingWindowProps = {
|
||||
* key so the window reopens where and how the user left it.
|
||||
*/
|
||||
storageKey?: string
|
||||
/** A new request expands the window without resetting its position or size. */
|
||||
expandRequest?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -88,7 +92,7 @@ const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
|
||||
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
|
||||
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
|
||||
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
|
||||
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
|
||||
se: 'bottom-0 right-0 size-8 cursor-nwse-resize',
|
||||
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
|
||||
}
|
||||
|
||||
@@ -199,7 +203,8 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
size.width
|
||||
),
|
||||
size,
|
||||
collapsed: stored?.collapsed ?? false,
|
||||
collapsed:
|
||||
props.expandRequest === undefined && (stored?.collapsed ?? false),
|
||||
}
|
||||
})
|
||||
const windowRef = useRef<HTMLElement>(null)
|
||||
@@ -217,6 +222,13 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
size: { width: number; height: number }
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.expandRequest === undefined) return
|
||||
setState((previous) =>
|
||||
previous.collapsed ? { ...previous, collapsed: false } : previous
|
||||
)
|
||||
}, [props.expandRequest])
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.storageKey) return
|
||||
try {
|
||||
@@ -455,6 +467,9 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
{props.footer}
|
||||
</footer>
|
||||
)}
|
||||
<div className='border-border/50 bg-muted/30 text-muted-foreground flex h-8 shrink-0 items-center justify-end border-t pr-9 pl-4 text-xs select-none'>
|
||||
{t('Drag to resize')}
|
||||
</div>
|
||||
{RESIZE_DIRECTIONS.map((direction) => (
|
||||
<div
|
||||
key={direction}
|
||||
@@ -464,13 +479,23 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'absolute z-10 touch-none',
|
||||
RESIZE_HANDLE_CLASS[direction]
|
||||
RESIZE_HANDLE_CLASS[direction],
|
||||
direction === 'se' &&
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center justify-center rounded-tl-md'
|
||||
)}
|
||||
onPointerDown={(event) => handleResizeStart(event, direction)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
/>
|
||||
>
|
||||
{direction === 'se' && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowExpand02Icon}
|
||||
className='pointer-events-none size-4'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -76,10 +76,13 @@ function resizeHandle(direction: string) {
|
||||
return handle
|
||||
}
|
||||
|
||||
test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => {
|
||||
test('the visible resize hint leads to a bottom-right grip that grows both dimensions and persists them', () => {
|
||||
renderWindow({ storageKey: 'scratch' })
|
||||
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
const handle = resizeHandle('se')
|
||||
expect(screen.getByText('Drag to resize')).toBeVisible()
|
||||
expect(handle.querySelector('svg')).toBeVisible()
|
||||
expect(handle).toHaveClass('size-8', 'cursor-nwse-resize')
|
||||
fireEvent.pointerDown(handle, {
|
||||
button: 0,
|
||||
pointerId: 3,
|
||||
@@ -145,11 +148,13 @@ test('collapses to a pill that keeps the title and reopens, and the close contro
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Scratch pad')).toBeVisible()
|
||||
expect(screen.queryByText('body')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Drag to resize')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand panel' }))
|
||||
expect(
|
||||
screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
).toBeVisible()
|
||||
expect(screen.getByText('Drag to resize')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Close panel' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -2304,10 +2304,14 @@ test('redirecting a model from the model picker publishes it, adds a mapping row
|
||||
})
|
||||
|
||||
test('fetched upstream models can be redirected in bulk with the model list synced', async () => {
|
||||
editingChannel.models = 'custom-model,upstream-model'
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
|
||||
await user.click(
|
||||
await screen.findByRole('tab', { name: 'Existing Models (1)' })
|
||||
)
|
||||
await screen.findByRole('checkbox', { name: 'upstream-model' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Batch redirect' }))
|
||||
@@ -2317,6 +2321,9 @@ test('fetched upstream models can be redirected in bulk with the model list sync
|
||||
expect(
|
||||
dialog.getByRole('tab', { name: /Upstream model list/ })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
dialog.getByRole('checkbox', { name: 'upstream-model' })
|
||||
).not.toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'upstream-model' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-model')
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
@@ -2423,9 +2430,127 @@ test('on wide screens redirecting a fetched model opens the floating panel besid
|
||||
).toHaveValue('upstream')
|
||||
})
|
||||
|
||||
test('a suggested rule in the floating panel maps every matching fetched model and publishes the derived names', async () => {
|
||||
test.each(['current session', 'saved preference'])(
|
||||
'quick redirect expands a panel collapsed in the %s and focuses the new request name',
|
||||
async (collapsedIn) => {
|
||||
useWideScreen()
|
||||
editingChannel.model_mapping = '{"existing-alias":"existing-upstream"}'
|
||||
if (collapsedIn === 'saved preference') {
|
||||
window.localStorage.setItem(
|
||||
'floating-window:channel-model-redirects',
|
||||
JSON.stringify({
|
||||
x: 1000,
|
||||
y: 80,
|
||||
width: 380,
|
||||
height: 360,
|
||||
collapsed: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Fetch from Upstream' })
|
||||
)
|
||||
await screen.findByRole('checkbox', { name: 'upstream-model' })
|
||||
if (collapsedIn === 'current session') {
|
||||
await user.click(screen.getByRole('button', { name: 'Redirect panel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(screen.getByRole('button', { name: 'Expand panel' })).toBeVisible()
|
||||
}
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Redirect upstream-model' })
|
||||
)
|
||||
let panelElement = await screen.findByRole('complementary', {
|
||||
name: 'Model redirects',
|
||||
})
|
||||
let panel = within(panelElement)
|
||||
await waitFor(() =>
|
||||
expect(panel.getByDisplayValue('upstream-model')).toBeVisible()
|
||||
)
|
||||
const request = panel.getAllByRole('combobox', {
|
||||
name: 'Request Model Name',
|
||||
})[1]
|
||||
await waitFor(() => expect(request).toHaveFocus())
|
||||
expect(request).toHaveValue('')
|
||||
expect(panel.getByDisplayValue('existing-alias')).toBeVisible()
|
||||
expect(panel.getByDisplayValue('existing-upstream')).toBeVisible()
|
||||
expect(screen.getByText('1 model(s) redirected')).toBeVisible()
|
||||
if (collapsedIn === 'saved preference') {
|
||||
expect(panelElement).toHaveStyle({
|
||||
left: '1000px',
|
||||
top: '80px',
|
||||
width: '380px',
|
||||
height: '360px',
|
||||
})
|
||||
}
|
||||
|
||||
// Handling the draft must not prevent collapse or swallow the next shortcut for the same model.
|
||||
await user.click(panel.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Model redirects' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Redirect upstream-model' })
|
||||
)
|
||||
panelElement = await screen.findByRole('complementary', {
|
||||
name: 'Model redirects',
|
||||
})
|
||||
panel = within(panelElement)
|
||||
await waitFor(() =>
|
||||
expect(panel.getByDisplayValue('upstream-model')).toBeVisible()
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
panel.getAllByRole('combobox', { name: 'Request Model Name' })[1]
|
||||
).toHaveFocus()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('redirecting a recognized upstream name opens an empty alias draft without changing the model list', async () => {
|
||||
useWideScreen()
|
||||
const baseGet = vi.mocked(api.get).getMockImplementation()
|
||||
vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => {
|
||||
if (url === '/api/channel/fetch_models/42') {
|
||||
return { data: { success: true, data: ['gpt-4o-all'] } }
|
||||
}
|
||||
if (url === '/api/channel/models') {
|
||||
return { data: { success: true, data: [{ id: 'gpt-4o' }] } }
|
||||
}
|
||||
return baseGet?.(url, ...rest)
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Redirect gpt-4o-all' })
|
||||
)
|
||||
const panel = within(
|
||||
await screen.findByRole('complementary', { name: 'Model redirects' })
|
||||
)
|
||||
const request = panel.getByRole('combobox', { name: 'Request Model Name' })
|
||||
await waitFor(() => expect(request).toHaveFocus())
|
||||
expect(request).toHaveValue('')
|
||||
expect(
|
||||
panel.getByRole('combobox', { name: 'Upstream Model Name' })
|
||||
).toHaveValue('gpt-4o-all')
|
||||
expect(screen.queryByLabelText('Published as gpt-4o')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('group', { name: 'Models' })).getByRole('button', {
|
||||
name: 'custom-model',
|
||||
})
|
||||
).toBeVisible()
|
||||
expect(screen.queryByText('1 model(s) redirected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('the redirect panel batch action starts empty and only applies explicitly selected models after preview', async () => {
|
||||
useWideScreen()
|
||||
editingChannel.models = 'custom-model,gpt-4o-all,o3-all'
|
||||
const baseGet = vi.mocked(api.get).getMockImplementation()
|
||||
vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => {
|
||||
if (url === '/api/channel/fetch_models/42') {
|
||||
return { data: { success: true, data: ['gpt-4o-all', 'o3-all'] } }
|
||||
@@ -2439,22 +2564,47 @@ test('a suggested rule in the floating panel maps every matching fetched model a
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
|
||||
await user.click(
|
||||
await screen.findByRole('tab', { name: 'Existing Models (2)' })
|
||||
)
|
||||
await screen.findByRole('checkbox', { name: 'gpt-4o-all' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Redirect panel' }))
|
||||
const panel = within(
|
||||
await screen.findByRole('complementary', { name: 'Model redirects' })
|
||||
)
|
||||
await user.click(panel.getByRole('button', { name: /Strip -all/ }))
|
||||
expect(panel.queryByText('Suggested rules')).not.toBeInTheDocument()
|
||||
await user.click(panel.getByRole('button', { name: 'Batch Add' }))
|
||||
let dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
|
||||
expect(dialog.getByRole('checkbox', { name: 'o3-all' })).not.toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(dialog.getByRole('button', { name: 'Cancel' }))
|
||||
const picker = within(screen.getByRole('group', { name: 'Models' }))
|
||||
expect(picker.getByRole('button', { name: 'gpt-4o-all' })).toBeVisible()
|
||||
expect(
|
||||
picker.queryByRole('button', { name: 'gpt-4o' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(panel.getByRole('button', { name: 'Batch Add' }))
|
||||
dialog = within(screen.getByRole('dialog', { name: 'Batch add mappings' }))
|
||||
expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
|
||||
'gpt-4ogpt-4o-all'
|
||||
)
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(
|
||||
panel.getAllByRole('combobox', { name: 'Request Model Name' })
|
||||
).toHaveLength(2)
|
||||
expect(screen.getByLabelText('Published as gpt-4o')).toBeVisible()
|
||||
expect(screen.getByLabelText('Published as o3')).toBeVisible()
|
||||
const picker = within(screen.getByRole('group', { name: 'Models' }))
|
||||
).toHaveLength(1)
|
||||
expect(picker.getByRole('button', { name: 'gpt-4o' })).toBeVisible()
|
||||
expect(picker.getByRole('button', { name: 'o3' })).toBeVisible()
|
||||
expect(screen.getByText('2 model(s) redirected')).toBeVisible()
|
||||
expect(picker.getByRole('button', { name: 'o3-all' })).toBeVisible()
|
||||
expect(picker.queryByRole('button', { name: 'o3' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 model(s) redirected')).toBeVisible()
|
||||
|
||||
await user.click(panel.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(
|
||||
|
||||
+185
-58
@@ -18,11 +18,15 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import fr from '@/i18n/locales/fr.json'
|
||||
|
||||
import { ModelMappingBatchDialog } from '../model-mapping-batch-dialog'
|
||||
|
||||
test('upstream names are the default source: stripping a suffix derives request names, skips unchanged models, and syncs the model list', async () => {
|
||||
test('creating user aliases starts unselected, previews request-to-upstream pairs, and skips unchanged names', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
@@ -30,113 +34,236 @@ test('upstream names are the default source: stripping a suffix derives request
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o-all', 'o3']}
|
||||
channelModels={['gpt-4o']}
|
||||
channelModels={['gpt-4o-all']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
const dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
expect(
|
||||
dialog.getByRole('tab', { name: /Upstream model list/ })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
dialog.getByRole('radio', {
|
||||
name: 'Upstream names returned by the provider',
|
||||
})
|
||||
screen.getByRole('radio', { name: 'Create aliases for users' })
|
||||
).toBeChecked()
|
||||
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
|
||||
expect(
|
||||
dialog.getByRole('button', { name: 'Add 0 mapping(s)' })
|
||||
).toBeDisabled()
|
||||
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'o3' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
|
||||
'gpt-4ogpt-4o-all'
|
||||
expect(screen.getByRole('combobox', { name: 'Rule' })).toHaveTextContent(
|
||||
'Strip suffix'
|
||||
)
|
||||
expect(screen.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
|
||||
expect(
|
||||
dialog.getByText('1 model(s) unchanged by the rule were skipped')
|
||||
screen.getByRole('button', { name: 'Add 0 mapping(s)' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Example: users call gpt-4o; upstream receives gpt-4o-all.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'o3' }))
|
||||
expect(screen.getByText('Enter a rule to preview mappings.')).toBeVisible()
|
||||
expect(screen.queryByText(/unchanged by the rule/)).not.toBeInTheDocument()
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
const preview = within(screen.getByRole('list', { name: 'Preview' }))
|
||||
expect(preview.getByRole('listitem')).toHaveTextContent('gpt-4ogpt-4o-all')
|
||||
expect(
|
||||
screen.getByText('1 model(s) unchanged by the rule were skipped')
|
||||
).toBeVisible()
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'gpt-4o', to: 'gpt-4o-all' }],
|
||||
syncModels: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('the channel-model source flips to request names, offers add rules, and never syncs the model list', async () => {
|
||||
test('changing upstream names preserves the request name and leaves model-list syncing off', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['x-all']}
|
||||
channelModels={['gemini-2.5-flash']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
const dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
await user.click(dialog.getByRole('tab', { name: /Channel models/ }))
|
||||
expect(
|
||||
dialog.getByRole('radio', { name: 'Request names your users call' })
|
||||
screen.getByRole('radio', { name: 'Change upstream model names' })
|
||||
).toBeChecked()
|
||||
expect(dialog.getByRole('radio', { name: 'Add suffix' })).toBeChecked()
|
||||
expect(
|
||||
dialog.queryByRole('checkbox', { name: /Publish the request names/ })
|
||||
screen.queryByRole('checkbox', { name: /Publish the request names/ })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'gemini-2.5-flash', to: 'gemini-2.5-flash-all' }],
|
||||
syncModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('without an upstream list the dialog offers only channel models, and conflicting request names are reported', async () => {
|
||||
test('switching the model list clears selection without changing the chosen task or naming rule', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o-all']}
|
||||
channelModels={['claude-all']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(screen.getByRole('tab', { name: /Channel models/ }))
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'Create aliases for users' })
|
||||
).toBeChecked()
|
||||
expect(screen.getByRole('textbox', { name: 'Suffix' })).toHaveValue('-all')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add 0 mapping(s)' })
|
||||
).toBeDisabled()
|
||||
await user.click(screen.getByRole('checkbox', { name: 'claude-all' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'claude', to: 'claude-all' }],
|
||||
syncModels: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('creating aliases from channel models reports conflicts and allows opting out of model-list syncing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
channelModels={['claude-all', 'claude-latest']}
|
||||
onApply={vi.fn()}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
const dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
expect(dialog.queryByRole('tab')).not.toBeInTheDocument()
|
||||
expect(
|
||||
dialog.getByRole('radio', { name: 'Request names your users call' })
|
||||
).toBeChecked()
|
||||
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
dialog.getByRole('radio', {
|
||||
name: 'Upstream names returned by the provider',
|
||||
})
|
||||
screen.getByRole('radio', { name: 'Create aliases for users' })
|
||||
)
|
||||
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'claude-all' }))
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'claude-latest' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'claude-all' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'claude-latest' }))
|
||||
await user.type(
|
||||
dialog.getByRole('textbox', { name: 'Suffix' }),
|
||||
screen.getByRole('textbox', { name: 'Suffix' }),
|
||||
'-all, -latest'
|
||||
)
|
||||
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
|
||||
'claudeclaude-all'
|
||||
)
|
||||
expect(
|
||||
dialog.getByText(
|
||||
screen.getByText(
|
||||
'1 model(s) skipped because another model derives the same request name'
|
||||
)
|
||||
).toBeVisible()
|
||||
expect(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })).toBeEnabled()
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: /Publish the request names/ })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'claude', to: 'claude-all' }],
|
||||
syncModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('the alias task can add a prefix and only shows fields for the selected rule', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o']}
|
||||
channelModels={[]}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' }))
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'Prefix' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('combobox', { name: 'Rule' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Add prefix' }))
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'Suffix' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.type(screen.getByRole('textbox', { name: 'Prefix' }), 'team/')
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'team/gpt-4o', to: 'gpt-4o' }],
|
||||
syncModels: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('replacement previews include every selected model even when several share an upstream target', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const models = [
|
||||
'gpt-4o-a',
|
||||
'gpt-4oa',
|
||||
...Array.from({ length: 8 }, (_, n) => `gpt-${n}`),
|
||||
]
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
channelModels={models}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select all models in OpenAI' })
|
||||
)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Rule' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Replace text' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Find' }), '-')
|
||||
const preview = within(screen.getByRole('list', { name: 'Preview' }))
|
||||
expect(preview.getAllByRole('listitem')).toHaveLength(10)
|
||||
expect(preview.getAllByText('gpt4oa')).toHaveLength(2)
|
||||
await user.click(screen.getByRole('button', { name: 'Add 10 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: models.map((from) => ({ from, to: from.replaceAll('-', '') })),
|
||||
syncModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('cancelling after configuring a rule does not apply mappings', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
channelModels={['gpt-4o']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('long translated source labels wrap inside their row without covering the model search', async () => {
|
||||
const i18n = createInstance()
|
||||
await i18n.init({ lng: 'fr', resources: { fr }, keySeparator: false })
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o-all']}
|
||||
channelModels={['gpt-4o']}
|
||||
onApply={vi.fn()}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
)
|
||||
const tabs = screen.getByRole('tablist', { name: 'Sélectionner les modèles' })
|
||||
expect(tabs).toHaveClass('grid-cols-2', 'group-data-horizontal/tabs:h-auto')
|
||||
for (const tab of within(tabs).getAllByRole('tab')) {
|
||||
expect(tab).toHaveClass('whitespace-normal', 'h-auto')
|
||||
}
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Rechercher des modèles...' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -251,3 +251,27 @@ test('models published under another name show that name as a badge next to the
|
||||
)
|
||||
expect(screen.queryByLabelText(/Published as o3/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('long model names remain fully labeled and selectable in a container-responsive list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const model = 'gpt-4.1-mini-deployment-with-a-long-region-name-2025-04-14'
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<UpstreamModelSelection
|
||||
models={[model]}
|
||||
selected={[]}
|
||||
existingModels={[]}
|
||||
showChanges={false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
const checkbox = screen.getByRole('checkbox', { name: model })
|
||||
const category = checkbox.closest('[data-slot="collapsible"]')
|
||||
expect(category).toHaveClass('@container')
|
||||
const grid = checkbox.parentElement?.parentElement
|
||||
expect(grid).toHaveClass('@lg:grid-cols-2')
|
||||
expect(grid).not.toHaveClass('sm:grid-cols-2')
|
||||
expect(screen.getByText(model)).toBeVisible()
|
||||
await user.click(checkbox)
|
||||
expect(onChange).toHaveBeenCalledWith([model])
|
||||
})
|
||||
|
||||
@@ -177,11 +177,7 @@ import {
|
||||
parseModelsString,
|
||||
formatModelsArray,
|
||||
mergeModelMappingPairs,
|
||||
deriveModelMappingPairs,
|
||||
detectModelNamingPatterns,
|
||||
findVerifiedAlias,
|
||||
type ModelMappingPair,
|
||||
type ModelNamingSuggestion,
|
||||
extractRedirectModels,
|
||||
extractMappingSourceModels,
|
||||
hasModelConfigChanged,
|
||||
@@ -483,7 +479,6 @@ export function ChannelMutateDrawer({
|
||||
const mappingDraftTokenRef = useRef(0)
|
||||
const [batchMapping, setBatchMapping] = useState<{
|
||||
source: ModelMappingBatchSource
|
||||
selected: string[]
|
||||
} | null>(null)
|
||||
const [redirectPanelOpen, setRedirectPanelOpen] = useState(false)
|
||||
const [redirectSyncModels, setRedirectSyncModels] = useState(true)
|
||||
@@ -1246,10 +1241,6 @@ export function ChannelMutateDrawer({
|
||||
upstreamModelList.length > 0 ? 'upstream' : 'channel'
|
||||
const canBatchMap =
|
||||
currentModelsArray.length > 0 || upstreamModelList.length > 0
|
||||
const namingSuggestions = useMemo(
|
||||
() => detectModelNamingPatterns(upstreamModelList, allModelsList),
|
||||
[upstreamModelList, allModelsList]
|
||||
)
|
||||
const fetchDiscoveredModels = discovery.fetch
|
||||
const handleFetchModels = useCallback(async () => {
|
||||
const type = form.getValues('type')
|
||||
@@ -1495,9 +1486,7 @@ export function ChannelMutateDrawer({
|
||||
setRedirectPanelOpen(false)
|
||||
}, [])
|
||||
|
||||
// Redirect one fetched upstream model. On wide screens the floating panel
|
||||
// stays beside the list; a name the platform already knows is applied at
|
||||
// once, anything else becomes a draft row awaiting the request name.
|
||||
// Open a draft without guessing or publishing a request name.
|
||||
const handleFetchedRedirect = useCallback(
|
||||
(model: string) => {
|
||||
if (window.innerWidth < REDIRECT_PANEL_MIN_VIEWPORT) {
|
||||
@@ -1505,35 +1494,9 @@ export function ChannelMutateDrawer({
|
||||
return
|
||||
}
|
||||
openRedirectPanel()
|
||||
const alias = findVerifiedAlias(model, namingSuggestions, allModelsList)
|
||||
if (alias) {
|
||||
applyMappingPairs([alias], redirectSyncModels)
|
||||
raiseMappingDraft(alias.from, alias.to, 'from')
|
||||
return
|
||||
}
|
||||
raiseMappingDraft('', model, 'from')
|
||||
},
|
||||
[
|
||||
requestMappingDraft,
|
||||
openRedirectPanel,
|
||||
namingSuggestions,
|
||||
allModelsList,
|
||||
applyMappingPairs,
|
||||
redirectSyncModels,
|
||||
raiseMappingDraft,
|
||||
]
|
||||
)
|
||||
|
||||
const handleApplySuggestion = useCallback(
|
||||
(suggestion: ModelNamingSuggestion) => {
|
||||
const derivation = deriveModelMappingPairs(
|
||||
suggestion.models,
|
||||
'upstream',
|
||||
suggestion.rule
|
||||
)
|
||||
applyMappingPairs(derivation.pairs, redirectSyncModels)
|
||||
},
|
||||
[applyMappingPairs, redirectSyncModels]
|
||||
[requestMappingDraft, openRedirectPanel, raiseMappingDraft]
|
||||
)
|
||||
|
||||
// Handle successful submission
|
||||
@@ -2509,7 +2472,6 @@ export function ChannelMutateDrawer({
|
||||
? () =>
|
||||
setBatchMapping({
|
||||
source: batchMappingSource,
|
||||
selected: [],
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
@@ -3408,9 +3370,6 @@ export function ChannelMutateDrawer({
|
||||
onClick={() =>
|
||||
setBatchMapping({
|
||||
source: 'upstream',
|
||||
selected: discovery.models.filter((model) =>
|
||||
currentModelsArray.includes(model)
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -4997,14 +4956,9 @@ export function ChannelMutateDrawer({
|
||||
mappingValue={formValues.model_mapping || ''}
|
||||
onMappingChange={handlePanelMappingChange}
|
||||
onMappingCommit={handlePanelMappingCommit}
|
||||
suggestions={namingSuggestions}
|
||||
onApplySuggestion={handleApplySuggestion}
|
||||
onOpenRules={() =>
|
||||
onBatchAdd={() =>
|
||||
setBatchMapping({
|
||||
source: 'upstream',
|
||||
selected: upstreamModelList.filter((model) =>
|
||||
currentModelsArray.includes(model)
|
||||
),
|
||||
source: batchMappingSource,
|
||||
})
|
||||
}
|
||||
syncModels={redirectSyncModels}
|
||||
@@ -5044,7 +4998,6 @@ export function ChannelMutateDrawer({
|
||||
upstreamModels={upstreamModelList}
|
||||
channelModels={currentModelsArray}
|
||||
initialSource={batchMapping.source}
|
||||
initialSelected={batchMapping.selected}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setBatchMapping(null)
|
||||
}}
|
||||
|
||||
@@ -16,16 +16,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { type ReactNode, useId, useMemo, useState } from 'react'
|
||||
import { ArrowRight01Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useId, useMemo, 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,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
import {
|
||||
@@ -36,8 +51,6 @@ import {
|
||||
} from '../lib'
|
||||
import { UpstreamModelSelection } from './upstream-model-selection'
|
||||
|
||||
const PREVIEW_LIMIT = 8
|
||||
|
||||
/** Where the selectable names come from. */
|
||||
export type ModelMappingBatchSource = 'upstream' | 'channel'
|
||||
|
||||
@@ -47,16 +60,12 @@ export type ModelMappingBatchResult = {
|
||||
syncModels: boolean
|
||||
}
|
||||
|
||||
type RuleType = ModelMappingRule['type']
|
||||
|
||||
type ModelMappingBatchDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Names returned by the provider's model list; enables the upstream source. */
|
||||
upstreamModels?: string[]
|
||||
channelModels: string[]
|
||||
initialSource?: ModelMappingBatchSource
|
||||
initialSelected?: string[]
|
||||
onApply: (result: ModelMappingBatchResult) => void
|
||||
}
|
||||
|
||||
@@ -65,153 +74,48 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
const id = useId()
|
||||
const upstreamModels = props.upstreamModels ?? []
|
||||
const hasUpstream = upstreamModels.length > 0
|
||||
const [source, setSource] = useState<ModelMappingBatchSource>(
|
||||
() => props.initialSource ?? (hasUpstream ? 'upstream' : 'channel')
|
||||
)
|
||||
const [selected, setSelected] = useState<string[]>(
|
||||
props.initialSelected ?? []
|
||||
const [source, setSource] = useState<ModelMappingBatchSource>(() =>
|
||||
hasUpstream ? (props.initialSource ?? 'upstream') : 'channel'
|
||||
)
|
||||
// Opening a batch action never opts models into a change on the user's behalf.
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [direction, setDirection] = useState<ModelMappingDirection>(
|
||||
source === 'upstream' ? 'upstream' : 'request'
|
||||
)
|
||||
const [ruleType, setRuleType] = useState<RuleType>(
|
||||
const [ruleType, setRuleType] = useState<ModelMappingRule['type']>(
|
||||
direction === 'upstream' ? 'strip-suffix' : 'add-suffix'
|
||||
)
|
||||
const [stripPrefixes, setStripPrefixes] = useState('')
|
||||
const [stripSuffixes, setStripSuffixes] = useState('')
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [suffix, setSuffix] = useState('')
|
||||
const [affix, setAffix] = useState('')
|
||||
const [find, setFind] = useState('')
|
||||
const [replaceWith, setReplaceWith] = useState('')
|
||||
const [syncModels, setSyncModels] = useState(true)
|
||||
|
||||
const candidates =
|
||||
source === 'upstream' ? upstreamModels : props.channelModels
|
||||
const rule = useMemo<ModelMappingRule>(() => {
|
||||
if (ruleType === 'strip-prefix') {
|
||||
return { type: 'strip-prefix', values: stripPrefixes }
|
||||
if (ruleType === 'strip-prefix' || ruleType === 'strip-suffix') {
|
||||
return { type: ruleType, values: affix }
|
||||
}
|
||||
if (ruleType === 'strip-suffix') {
|
||||
return { type: 'strip-suffix', values: stripSuffixes }
|
||||
if (ruleType === 'add-prefix' || ruleType === 'add-suffix') {
|
||||
return { type: ruleType, value: affix }
|
||||
}
|
||||
if (ruleType === 'add-prefix') return { type: 'add-prefix', value: prefix }
|
||||
if (ruleType === 'add-suffix') return { type: 'add-suffix', value: suffix }
|
||||
return { type: 'replace', find, replaceWith }
|
||||
}, [
|
||||
ruleType,
|
||||
stripPrefixes,
|
||||
stripSuffixes,
|
||||
prefix,
|
||||
suffix,
|
||||
find,
|
||||
replaceWith,
|
||||
])
|
||||
}, [ruleType, affix, find, replaceWith])
|
||||
const derivation = useMemo(
|
||||
() => deriveModelMappingPairs(selected, direction, rule),
|
||||
[selected, direction, rule]
|
||||
)
|
||||
const preview = derivation.pairs.slice(0, PREVIEW_LIMIT)
|
||||
const remaining = derivation.pairs.length - preview.length
|
||||
|
||||
const changeDirection = (next: ModelMappingDirection) => {
|
||||
setDirection(next)
|
||||
setRuleType(next === 'upstream' ? 'strip-suffix' : 'add-suffix')
|
||||
}
|
||||
|
||||
const changeSource = (next: ModelMappingBatchSource) => {
|
||||
setSource(next)
|
||||
setSelected([])
|
||||
changeDirection(next === 'upstream' ? 'upstream' : 'request')
|
||||
}
|
||||
|
||||
const replaceControls = (
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<Input
|
||||
aria-label={t('Find')}
|
||||
placeholder='.'
|
||||
value={find}
|
||||
onChange={(event) => setFind(event.target.value)}
|
||||
disabled={ruleType !== 'replace'}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t('Replace with')}
|
||||
placeholder='-'
|
||||
value={replaceWith}
|
||||
onChange={(event) => setReplaceWith(event.target.value)}
|
||||
disabled={ruleType !== 'replace'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const affixHint = t('Separate several values with commas')
|
||||
let ruleOptions: Array<{
|
||||
type: RuleType
|
||||
label: string
|
||||
control: ReactNode
|
||||
hint?: string
|
||||
}>
|
||||
if (direction === 'upstream') {
|
||||
ruleOptions = [
|
||||
{
|
||||
type: 'strip-suffix',
|
||||
label: t('Strip suffix'),
|
||||
hint: affixHint,
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Suffix')}
|
||||
placeholder='-all, -latest'
|
||||
value={stripSuffixes}
|
||||
onChange={(event) => setStripSuffixes(event.target.value)}
|
||||
disabled={ruleType !== 'strip-suffix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'strip-prefix',
|
||||
label: t('Strip prefix'),
|
||||
hint: affixHint,
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Prefix')}
|
||||
placeholder='openai/, anthropic/'
|
||||
value={stripPrefixes}
|
||||
onChange={(event) => setStripPrefixes(event.target.value)}
|
||||
disabled={ruleType !== 'strip-prefix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: 'replace', label: t('Replace text'), control: replaceControls },
|
||||
const hasRule =
|
||||
ruleType === 'replace' ? find.length > 0 : affix.trim().length > 0
|
||||
const isPrefix = ruleType === 'add-prefix' || ruleType === 'strip-prefix'
|
||||
const isStrip = ruleType === 'strip-prefix' || ruleType === 'strip-suffix'
|
||||
const ruleOptions: Array<{ value: ModelMappingRule['type']; label: string }> =
|
||||
[
|
||||
{ value: 'strip-suffix', label: t('Strip suffix') },
|
||||
{ value: 'strip-prefix', label: t('Strip prefix') },
|
||||
{ value: 'add-suffix', label: t('Add suffix') },
|
||||
{ value: 'add-prefix', label: t('Add prefix') },
|
||||
{ value: 'replace', label: t('Replace text') },
|
||||
]
|
||||
} else {
|
||||
ruleOptions = [
|
||||
{
|
||||
type: 'add-suffix',
|
||||
label: t('Add suffix'),
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Suffix')}
|
||||
placeholder='-all'
|
||||
value={suffix}
|
||||
onChange={(event) => setSuffix(event.target.value)}
|
||||
disabled={ruleType !== 'add-suffix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'add-prefix',
|
||||
label: t('Add prefix'),
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Prefix')}
|
||||
placeholder='openai/'
|
||||
value={prefix}
|
||||
onChange={(event) => setPrefix(event.target.value)}
|
||||
disabled={ruleType !== 'add-prefix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: 'replace', label: t('Replace text'), control: replaceControls },
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -219,9 +123,10 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={t('Batch add mappings')}
|
||||
description={t(
|
||||
'Pick models and derive the other side with a rule to create request-to-upstream mappings.'
|
||||
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
|
||||
)}
|
||||
contentClassName='sm:max-w-4xl'
|
||||
bodyClassName='flex flex-col gap-5'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
@@ -233,7 +138,7 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={derivation.pairs.length === 0}
|
||||
disabled={!hasRule || derivation.pairs.length === 0}
|
||||
onClick={() => {
|
||||
props.onApply({
|
||||
pairs: derivation.pairs,
|
||||
@@ -247,24 +152,102 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
<section aria-labelledby={`${id}-models`} className='min-w-0 space-y-3'>
|
||||
<FieldSet>
|
||||
<FieldLegend id={`${id}-task`} variant='label'>
|
||||
{t('What do you want to change?')}
|
||||
</FieldLegend>
|
||||
<RadioGroup
|
||||
aria-labelledby={`${id}-task`}
|
||||
value={direction}
|
||||
className='sm:grid-cols-2'
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'upstream' && value !== 'request') return
|
||||
setDirection(value)
|
||||
setRuleType(value === 'upstream' ? 'strip-suffix' : 'add-suffix')
|
||||
setAffix('')
|
||||
setFind('')
|
||||
setReplaceWith('')
|
||||
}}
|
||||
>
|
||||
<FieldLabel htmlFor={`${id}-aliases`}>
|
||||
<Field orientation='horizontal'>
|
||||
<RadioGroupItem
|
||||
id={`${id}-aliases`}
|
||||
value='upstream'
|
||||
aria-labelledby={`${id}-aliases-label`}
|
||||
aria-describedby={`${id}-aliases-hint`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<span id={`${id}-aliases-label`}>
|
||||
{t('Create aliases for users')}
|
||||
</span>
|
||||
<FieldDescription id={`${id}-aliases-hint`}>
|
||||
{t('Keep upstream names; choose what users call.')}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor={`${id}-upstream`}>
|
||||
<Field orientation='horizontal'>
|
||||
<RadioGroupItem
|
||||
id={`${id}-upstream`}
|
||||
value='request'
|
||||
aria-labelledby={`${id}-upstream-label`}
|
||||
aria-describedby={`${id}-upstream-hint`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<span id={`${id}-upstream-label`}>
|
||||
{t('Change upstream model names')}
|
||||
</span>
|
||||
<FieldDescription id={`${id}-upstream-hint`}>
|
||||
{t(
|
||||
'Keep the names users call; change what is sent upstream.'
|
||||
)}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby={`${id}-models`}
|
||||
className='flex min-w-0 flex-col gap-3'
|
||||
>
|
||||
<h3 id={`${id}-models`} className='text-sm font-semibold'>
|
||||
{t('Select models')}
|
||||
{direction === 'upstream'
|
||||
? t('1. Select upstream models')
|
||||
: t('1. Select models users call')}
|
||||
</h3>
|
||||
{hasUpstream && (
|
||||
<Tabs
|
||||
value={source}
|
||||
onValueChange={(value) =>
|
||||
changeSource(value === 'channel' ? 'channel' : 'upstream')
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'channel' && value !== 'upstream') return
|
||||
setSource(value)
|
||||
setSelected([])
|
||||
}}
|
||||
>
|
||||
<TabsList variant='line' aria-label={t('Select models')}>
|
||||
<TabsTrigger value='upstream'>
|
||||
{t('Upstream model list')} ({upstreamModels.length})
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'
|
||||
aria-label={t('Select models')}
|
||||
>
|
||||
<TabsTrigger
|
||||
value='upstream'
|
||||
className='h-auto min-h-8 whitespace-normal'
|
||||
>
|
||||
<span>
|
||||
{t('Upstream model list')} ({upstreamModels.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='channel'>
|
||||
{t('Channel models')} ({props.channelModels.length})
|
||||
<TabsTrigger
|
||||
value='channel'
|
||||
className='h-auto min-h-8 whitespace-normal'
|
||||
>
|
||||
<span>
|
||||
{t('Channel models')} ({props.channelModels.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -283,161 +266,138 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<div className='min-w-0 space-y-5'>
|
||||
<section aria-labelledby={`${id}-direction`} className='space-y-2'>
|
||||
<h3 id={`${id}-direction`} className='text-sm font-semibold'>
|
||||
{t('Selected names are')}
|
||||
</h3>
|
||||
<RadioGroup
|
||||
value={direction}
|
||||
aria-labelledby={`${id}-direction`}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'upstream' || value === 'request') {
|
||||
changeDirection(value)
|
||||
}
|
||||
}}
|
||||
className='gap-2'
|
||||
>
|
||||
<div className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value='upstream'
|
||||
id={`${id}-direction-upstream`}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className='min-w-0 space-y-0.5'>
|
||||
<Label
|
||||
htmlFor={`${id}-direction-upstream`}
|
||||
className='font-normal'
|
||||
>
|
||||
{t('Upstream names returned by the provider')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Derives the request names your users call')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value='request'
|
||||
id={`${id}-direction-request`}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className='min-w-0 space-y-0.5'>
|
||||
<Label
|
||||
htmlFor={`${id}-direction-request`}
|
||||
className='font-normal'
|
||||
>
|
||||
{t('Request names your users call')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Derives the upstream names sent to the provider')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</section>
|
||||
<section aria-labelledby={`${id}-rule`} className='space-y-3'>
|
||||
<h3 id={`${id}-rule`} className='text-sm font-semibold'>
|
||||
{t('Rule')}
|
||||
</h3>
|
||||
<RadioGroup
|
||||
value={ruleType}
|
||||
aria-labelledby={`${id}-rule`}
|
||||
onValueChange={(value) => {
|
||||
const option = ruleOptions.find((item) => item.type === value)
|
||||
if (option) setRuleType(option.type)
|
||||
}}
|
||||
className='gap-3'
|
||||
>
|
||||
{ruleOptions.map((option) => (
|
||||
<div key={option.type} className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value={option.type}
|
||||
id={`${id}-rule-${option.type}`}
|
||||
className='mt-2'
|
||||
/>
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
<Label
|
||||
htmlFor={`${id}-rule-${option.type}`}
|
||||
className='font-normal'
|
||||
>
|
||||
<section
|
||||
aria-labelledby={`${id}-naming`}
|
||||
className='flex min-w-0 flex-col gap-3'
|
||||
>
|
||||
<h3 id={`${id}-naming`} className='text-sm font-semibold'>
|
||||
{direction === 'upstream'
|
||||
? t('2. Set names users call')
|
||||
: t('2. Set upstream model names')}
|
||||
</h3>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-rule`}>{t('Rule')}</FieldLabel>
|
||||
<Select
|
||||
items={ruleOptions}
|
||||
value={ruleType}
|
||||
onValueChange={(value) => {
|
||||
const option = ruleOptions.find(
|
||||
(item) => item.value === value
|
||||
)
|
||||
if (option) {
|
||||
setRuleType(option.value)
|
||||
setAffix('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${id}-rule`} className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ruleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Label>
|
||||
{option.control}
|
||||
{option.hint && ruleType === option.type && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{option.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</section>
|
||||
{direction === 'upstream' && (
|
||||
<div className='flex items-start gap-3 rounded-md border p-3'>
|
||||
<Checkbox
|
||||
id={`${id}-sync`}
|
||||
className='mt-0.5'
|
||||
checked={syncModels}
|
||||
onCheckedChange={(checked) => setSyncModels(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${id}-sync`} className='leading-5 font-normal'>
|
||||
{t(
|
||||
'Publish the request names and remove the raw upstream names from the model list'
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{ruleType === 'replace' ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-find`}>{t('Find')}</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-find`}
|
||||
placeholder='.'
|
||||
value={find}
|
||||
onChange={(event) => setFind(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-replacement`}>
|
||||
{t('Replace with')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-replacement`}
|
||||
placeholder='-'
|
||||
value={replaceWith}
|
||||
onChange={(event) => setReplaceWith(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-affix`}>
|
||||
{isPrefix ? t('Prefix') : t('Suffix')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-affix`}
|
||||
placeholder={isPrefix ? 'openai/' : '-all'}
|
||||
value={affix}
|
||||
onChange={(event) => setAffix(event.target.value)}
|
||||
/>
|
||||
{isStrip && (
|
||||
<FieldDescription>
|
||||
{t('Separate several values with commas')}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<section aria-labelledby={`${id}-preview`} className='space-y-2'>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<section
|
||||
aria-labelledby={`${id}-preview`}
|
||||
className='mt-2 flex min-w-0 flex-col gap-3 rounded-lg border p-3'
|
||||
>
|
||||
<h3 id={`${id}-preview`} className='text-sm font-semibold'>
|
||||
{t('Preview')}
|
||||
{t('3. Review mappings')}
|
||||
</h3>
|
||||
{selected.length === 0 && (
|
||||
<div className='text-muted-foreground grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 text-xs'>
|
||||
<span>{t('Users call')}</span>
|
||||
<span aria-hidden='true'>→</span>
|
||||
<span>{t('Upstream receives')}</span>
|
||||
</div>
|
||||
{derivation.pairs.length > 0 && hasRule && (
|
||||
<ul
|
||||
aria-label={t('Preview')}
|
||||
className='flex max-h-56 flex-col gap-2 overflow-y-auto font-mono text-xs'
|
||||
>
|
||||
{derivation.pairs.map((pair) => (
|
||||
<li
|
||||
key={pair.from}
|
||||
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
|
||||
>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className='size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{(!hasRule || selected.length === 0) && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Select at least one model to preview mappings.')}
|
||||
{selected.length === 0
|
||||
? t('Select at least one model to preview mappings.')
|
||||
: t('Enter a rule to preview mappings.')}
|
||||
</p>
|
||||
)}
|
||||
{derivation.pairs.length > 0 && (
|
||||
<div className='rounded-md border p-3 font-mono text-xs'>
|
||||
<div className='text-muted-foreground mb-2 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 font-sans'>
|
||||
<span>{t('Request Model Name')}</span>
|
||||
<span className='w-3.5' />
|
||||
<span>{t('Upstream Model Name')}</span>
|
||||
</div>
|
||||
<ul
|
||||
aria-label={t('Preview')}
|
||||
className='max-h-56 space-y-1 overflow-y-auto'
|
||||
>
|
||||
{preview.map((pair) => (
|
||||
<li
|
||||
key={pair.to}
|
||||
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
|
||||
>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
|
||||
<ArrowRight
|
||||
className='mt-0.5 size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
|
||||
</li>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<li className='text-muted-foreground'>
|
||||
{t('+{{count}} more', { count: remaining })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{selected.length === 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Example: users call gpt-4o; upstream receives gpt-4o-all.')}
|
||||
</p>
|
||||
)}
|
||||
{derivation.unchanged.length > 0 && (
|
||||
{hasRule && derivation.unchanged.length > 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('{{count}} model(s) unchanged by the rule were skipped', {
|
||||
count: derivation.unchanged.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{derivation.conflicts.length > 0 && (
|
||||
{hasRule && derivation.conflicts.length > 0 && (
|
||||
<p className='text-warning text-xs'>
|
||||
{t(
|
||||
'{{count}} model(s) skipped because another model derives the same request name',
|
||||
@@ -446,7 +406,24 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{direction === 'upstream' && (
|
||||
<Field orientation='horizontal'>
|
||||
<Checkbox
|
||||
id={`${id}-sync`}
|
||||
checked={syncModels}
|
||||
onCheckedChange={(checked) => setSyncModels(checked === true)}
|
||||
/>
|
||||
<FieldLabel
|
||||
htmlFor={`${id}-sync`}
|
||||
className='leading-5 font-normal'
|
||||
>
|
||||
{t(
|
||||
'Publish the request names and remove the raw upstream names from the model list'
|
||||
)}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -434,6 +434,11 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
|
||||
)}
|
||||
|
||||
<TabsContent value='visual' className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{showFilter && (
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -25,11 +24,9 @@ import {
|
||||
type FloatingWindowPosition,
|
||||
} from '@/components/floating-window'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import type { ModelNamingSuggestion } from '../lib'
|
||||
import {
|
||||
ModelMappingEditor,
|
||||
type ModelMappingDraftRequest,
|
||||
@@ -44,9 +41,7 @@ type ModelRedirectPanelProps = {
|
||||
onMappingChange: (value: string) => void
|
||||
/** Called once an edit settles; the drawer syncs the model list here. */
|
||||
onMappingCommit: (value: string) => void
|
||||
suggestions: ModelNamingSuggestion[]
|
||||
onApplySuggestion: (suggestion: ModelNamingSuggestion) => void
|
||||
onOpenRules: () => void
|
||||
onBatchAdd: () => void
|
||||
syncModels: boolean
|
||||
onSyncModelsChange: (value: boolean) => void
|
||||
sourceModelOptions: string[]
|
||||
@@ -77,6 +72,7 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
|
||||
defaultWidth={props.width}
|
||||
minWidth={340}
|
||||
storageKey='channel-model-redirects'
|
||||
expandRequest={props.draftRequest?.token}
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<div className='flex items-start gap-3'>
|
||||
@@ -99,65 +95,17 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
{props.suggestions.length > 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Suggested rules')}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{props.suggestions.map((suggestion) => {
|
||||
let label = ''
|
||||
if (suggestion.rule.type === 'replace') {
|
||||
label = t('Replace {{find}} with {{replaceWith}}', {
|
||||
find: suggestion.rule.find,
|
||||
replaceWith: suggestion.rule.replaceWith,
|
||||
})
|
||||
} else if ('values' in suggestion.rule) {
|
||||
label = t('Strip {{affix}}', {
|
||||
affix: suggestion.rule.values,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={suggestion.id}
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='xs'
|
||||
disabled={props.disabled}
|
||||
onClick={() => props.onApplySuggestion(suggestion)}
|
||||
>
|
||||
<Wand2 aria-hidden='true' />
|
||||
{label}
|
||||
<Badge variant='outline' className='h-4 px-1 text-[10px]'>
|
||||
{suggestion.models.length}
|
||||
</Badge>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='xs'
|
||||
disabled={props.disabled}
|
||||
onClick={props.onOpenRules}
|
||||
>
|
||||
{t('More rules')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ModelMappingEditor
|
||||
value={props.mappingValue}
|
||||
onChange={props.onMappingChange}
|
||||
onCommit={props.onMappingCommit}
|
||||
disabled={props.disabled}
|
||||
sourceModelOptions={props.sourceModelOptions}
|
||||
targetModelOptions={props.targetModelOptions}
|
||||
draftRequest={props.draftRequest}
|
||||
onDraftRequestHandled={props.onDraftRequestHandled}
|
||||
/>
|
||||
</div>
|
||||
<ModelMappingEditor
|
||||
value={props.mappingValue}
|
||||
onChange={props.onMappingChange}
|
||||
onCommit={props.onMappingCommit}
|
||||
onBatchAdd={props.onBatchAdd}
|
||||
disabled={props.disabled}
|
||||
sourceModelOptions={props.sourceModelOptions}
|
||||
targetModelOptions={props.targetModelOptions}
|
||||
draftRequest={props.draftRequest}
|
||||
onDraftRequestHandled={props.onDraftRequestHandled}
|
||||
/>
|
||||
</FloatingWindow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ function ModelCategory(props: ModelCategoryProps) {
|
||||
const allSelected = selectedCount === props.models.length
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen className='rounded-lg border'>
|
||||
<Collapsible defaultOpen className='@container rounded-lg border'>
|
||||
<div className='flex items-center gap-3 px-3'>
|
||||
<Checkbox
|
||||
aria-label={t('Select all models in {{category}}', {
|
||||
@@ -109,7 +109,7 @@ function ModelCategory(props: ModelCategoryProps) {
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className='border-t px-3 py-2'>
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<div className='grid gap-2 @lg:grid-cols-2'>
|
||||
{props.models.map((model) => (
|
||||
<div key={model} className='flex min-w-0 items-start gap-2'>
|
||||
<Checkbox
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "1 week ago",
|
||||
"1 year ago": "1 year ago",
|
||||
"1. Create an application in your Gotify server": "1. Create an application in your Gotify server",
|
||||
"1. Select models users call": "1. Select models users call",
|
||||
"1. Select upstream models": "1. Select upstream models",
|
||||
"10 / page": "10 / page",
|
||||
"100 / page": "100 / page",
|
||||
"14 Days": "14 Days",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1W",
|
||||
"2. Copy the application token": "2. Copy the application token",
|
||||
"2. Set names users call": "2. Set names users call",
|
||||
"2. Set upstream model names": "2. Set upstream model names",
|
||||
"20 / page": "20 / page",
|
||||
"24 Hours": "24 Hours",
|
||||
"24 hours ago": "24 hours ago",
|
||||
"29 Days": "29 Days",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx, 504 and 524 are always excluded.",
|
||||
"3. Enter your Gotify server URL and token above": "3. Enter your Gotify server URL and token above",
|
||||
"3. Review mappings": "3. Review mappings",
|
||||
"30 Days": "30 Days",
|
||||
"30 days ago": "30 days ago",
|
||||
"30d change": "30d change",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "Change provider",
|
||||
"Change the Passkey domain?": "Change the Passkey domain?",
|
||||
"Change To": "Change To",
|
||||
"Change upstream model names": "Change upstream model names",
|
||||
"Change vendor": "Change vendor",
|
||||
"Changed": "Changed",
|
||||
"Changed / Total": "Changed / Total",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "Create a key for your app or service",
|
||||
"Create a new user group to configure ratio overrides for.": "Create a new user group to configure ratio overrides for.",
|
||||
"Create account": "Create account",
|
||||
"Create aliases for users": "Create aliases for users",
|
||||
"Create an account": "Create an account",
|
||||
"Create an API key to unlock the real request": "Create an API key to unlock the real request",
|
||||
"Create and review invite or credit codes.": "Create and review invite or credit codes.",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "Enter a positive integer",
|
||||
"Enter a positive or negative amount to adjust the quota": "Enter a positive or negative amount to adjust the quota",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "Enter a react-icons component name. Invalid names show no icon.",
|
||||
"Enter a rule to preview mappings.": "Enter a rule to preview mappings.",
|
||||
"Enter a valid condition value.": "Enter a valid condition value.",
|
||||
"Enter a valid email or leave blank": "Enter a valid email or leave blank",
|
||||
"Enter a value and press Enter": "Enter a value and press Enter",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "Example price",
|
||||
"Example spec": "Example spec",
|
||||
"Example:": "Example:",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "Example: users call gpt-4o; upstream receives gpt-4o-all.",
|
||||
"example.com blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com company.com",
|
||||
"Excellent": "Excellent",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "Keep original value",
|
||||
"Keep original value (skip if target exists)": "Keep original value (skip if target exists)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.",
|
||||
"Keep the names users call; change what is sent upstream.": "Keep the names users call; change what is sent upstream.",
|
||||
"Keep the platform ready": "Keep the platform ready",
|
||||
"Keep the session binding when the channel is unavailable": "Keep the session binding when the channel is unavailable",
|
||||
"Keep this above 1 minute to avoid heavy database load": "Keep this above 1 minute to avoid heavy database load",
|
||||
"Keep this vendor": "Keep this vendor",
|
||||
"Keep upstream names; choose what users call.": "Keep upstream names; choose what users call.",
|
||||
"Keep-alive Ping": "Keep-alive Ping",
|
||||
"Keeps compatible responses more repeatable": "Keeps compatible responses more repeatable",
|
||||
"Key": "Key",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "Upstream prices fetched successfully",
|
||||
"Upstream protocol plan": "Upstream protocol plan",
|
||||
"Upstream ratios fetched successfully": "Upstream ratios fetched successfully",
|
||||
"Upstream receives": "Upstream receives",
|
||||
"Upstream request failed": "Upstream request failed",
|
||||
"Upstream Request ID": "Upstream Request ID",
|
||||
"Upstream response": "Upstream response",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "Username Field",
|
||||
"Username or Email": "Username or Email",
|
||||
"Users": "Users",
|
||||
"Users call": "Users call",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "Users call the model on the left. The platform forwards the request to the upstream model on the right.",
|
||||
"Users in {{group}}": "Users in {{group}}",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "Users must wait for a successful drawing before upscales or variations.",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "Welcome to our New API...",
|
||||
"Well-Known URL": "Well-Known URL",
|
||||
"Well-Known URL must start with http:// or https://": "Well-Known URL must start with http:// or https://",
|
||||
"What do you want to change?": "What do you want to change?",
|
||||
"What would you like to know?": "What would you like to know?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.",
|
||||
"When billed as {{group}}": "When billed as {{group}}",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "Il y a 1 semaine",
|
||||
"1 year ago": "Il y a 1 an",
|
||||
"1. Create an application in your Gotify server": "1. Créez une application sur votre serveur Gotify",
|
||||
"1. Select models users call": "1. Sélectionner les modèles appelés",
|
||||
"1. Select upstream models": "1. Sélectionner les modèles en amont",
|
||||
"10 / page": "10 / page",
|
||||
"100 / page": "100 / page",
|
||||
"14 Days": "14 jours",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1S",
|
||||
"2. Copy the application token": "2. Copiez le jeton de l'application",
|
||||
"2. Set names users call": "2. Définir les noms pour les utilisateurs",
|
||||
"2. Set upstream model names": "2. Définir les noms envoyés en amont",
|
||||
"20 / page": "20 / page",
|
||||
"24 Hours": "24 heures",
|
||||
"24 hours ago": "Il y a 24 heures",
|
||||
"29 Days": "29 jours",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx, 504 et 524 sont toujours exclus.",
|
||||
"3. Enter your Gotify server URL and token above": "3. Saisissez l'URL de votre serveur Gotify et le jeton ci-dessus",
|
||||
"3. Review mappings": "3. Vérifier les correspondances",
|
||||
"30 Days": "30 jours",
|
||||
"30 days ago": "Il y a 30 jours",
|
||||
"30d change": "Variation 30 j",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "Changer de fournisseur",
|
||||
"Change the Passkey domain?": "Changer le domaine des clés d’accès ?",
|
||||
"Change To": "Changer en",
|
||||
"Change upstream model names": "Modifier les noms envoyés en amont",
|
||||
"Change vendor": "Changer de fournisseur",
|
||||
"Changed": "Modifications",
|
||||
"Changed / Total": "Modifiés / Total",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "Créer une clé pour votre application ou service",
|
||||
"Create a new user group to configure ratio overrides for.": "Créer un nouveau groupe d'utilisateurs pour configurer les remplacements de ratio.",
|
||||
"Create account": "Créer un compte",
|
||||
"Create aliases for users": "Créer des alias pour les utilisateurs",
|
||||
"Create an account": "Créer un compte",
|
||||
"Create an API key to unlock the real request": "Créez une clé API pour débloquer la requête réelle",
|
||||
"Create and review invite or credit codes.": "Créer et examiner les codes d'invitation ou de crédit.",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "Saisissez un entier positif",
|
||||
"Enter a positive or negative amount to adjust the quota": "Saisir un montant positif ou négatif pour ajuster le quota",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "Saisissez le nom d’un composant react-icons. Les noms invalides n’affichent aucune icône.",
|
||||
"Enter a rule to preview mappings.": "Renseignez une règle pour voir les correspondances.",
|
||||
"Enter a valid condition value.": "Saisissez une valeur de condition valide.",
|
||||
"Enter a valid email or leave blank": "Entrez un e-mail valide ou laissez vide",
|
||||
"Enter a value and press Enter": "Saisir une valeur et appuyer sur Entrée",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "Prix d'exemple",
|
||||
"Example spec": "Spécification d'exemple",
|
||||
"Example:": "Exemple :",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "Exemple : l’utilisateur appelle gpt-4o ; le fournisseur reçoit gpt-4o-all.",
|
||||
"example.com blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com company.com",
|
||||
"Excellent": "Excellent",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "Conserver la valeur originale",
|
||||
"Keep original value (skip if target exists)": "Conserver la valeur originale (ignorer si la cible existe)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Conservez les domaines des clés existantes, un par ligne. Si le domaine principal passe de www.example.com à example.com, gardez www.example.com ici.",
|
||||
"Keep the names users call; change what is sent upstream.": "Conservez les noms utilisés par vos utilisateurs et modifiez ceux envoyés en amont.",
|
||||
"Keep the platform ready": "Gardez la plateforme prête",
|
||||
"Keep the session binding when the channel is unavailable": "Conserver la liaison si le canal est indisponible",
|
||||
"Keep this above 1 minute to avoid heavy database load": "Gardez cette valeur au-dessus de 1 minute pour éviter une charge excessive de la base de données",
|
||||
"Keep this vendor": "Fournisseur à conserver",
|
||||
"Keep upstream names; choose what users call.": "Conservez les noms en amont et définissez ceux utilisés par vos utilisateurs.",
|
||||
"Keep-alive Ping": "Ping de maintien de connexion",
|
||||
"Keeps compatible responses more repeatable": "Rend les réponses compatibles plus reproductibles",
|
||||
"Key": "Clé",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "Prix amont récupérés avec succès",
|
||||
"Upstream protocol plan": "Plan de protocole amont",
|
||||
"Upstream ratios fetched successfully": "Ratios en amont récupérés avec succès",
|
||||
"Upstream receives": "Reçu en amont",
|
||||
"Upstream request failed": "Échec de requête amont",
|
||||
"Upstream Request ID": "ID de requête en amont",
|
||||
"Upstream response": "Réponse amont",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "Champ nom d'utilisateur",
|
||||
"Username or Email": "Nom d'utilisateur ou e-mail",
|
||||
"Users": "Utilisateurs",
|
||||
"Users call": "Appelé par l’utilisateur",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "Les utilisateurs appellent le modèle à gauche. La plateforme transmet la requête au modèle amont à droite.",
|
||||
"Users in {{group}}": "Utilisateurs du groupe {{group}}",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "Les utilisateurs doivent attendre une génération réussie avant les upscales ou variations.",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "Bienvenue sur notre New API...",
|
||||
"Well-Known URL": "URL Well-Known",
|
||||
"Well-Known URL must start with http:// or https://": "L’URL Well-Known doit commencer par http:// ou https://",
|
||||
"What do you want to change?": "Que souhaitez-vous modifier ?",
|
||||
"What would you like to know?": "Que voulez-vous savoir ?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Quand un jeton utilise le groupe auto, le système essaie les groupes de haut en bas jusqu’à trouver un groupe disponible.",
|
||||
"When billed as {{group}}": "Facturé sous {{group}}",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "1週間前",
|
||||
"1 year ago": "1年前",
|
||||
"1. Create an application in your Gotify server": "1. Gotifyサーバーでアプリケーションを作成します",
|
||||
"1. Select models users call": "1. ユーザーが指定するモデルを選択",
|
||||
"1. Select upstream models": "1. 上流モデルを選択",
|
||||
"10 / page": "10 / ページ",
|
||||
"100 / page": "100 / ページ",
|
||||
"14 Days": "14日",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1W",
|
||||
"2. Copy the application token": "2. アプリケーショントークンをコピーします",
|
||||
"2. Set names users call": "2. ユーザーが指定する名前を設定",
|
||||
"2. Set upstream model names": "2. 上流に送る名前を設定",
|
||||
"20 / page": "20 / ページ",
|
||||
"24 Hours": "24時間",
|
||||
"24 hours ago": "24時間前",
|
||||
"29 Days": "29日",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx・504・524 は常に除外されます。",
|
||||
"3. Enter your Gotify server URL and token above": "3. 上記にGotifyサーバーのURLとトークンを入力します",
|
||||
"3. Review mappings": "3. マッピングを確認",
|
||||
"30 Days": "30日",
|
||||
"30 days ago": "30日前",
|
||||
"30d change": "30日変化",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "プロバイダーを変更",
|
||||
"Change the Passkey domain?": "パスキーのドメインを変更しますか?",
|
||||
"Change To": "変更先",
|
||||
"Change upstream model names": "上流に送るモデル名を変更",
|
||||
"Change vendor": "プロバイダーを変更",
|
||||
"Changed": "変更",
|
||||
"Changed / Total": "変更件数 / 合計",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "アプリまたはサービス用のキーを作成",
|
||||
"Create a new user group to configure ratio overrides for.": "レートの上書きを設定するための新しいユーザーグループを作成します。",
|
||||
"Create account": "アカウントを作成",
|
||||
"Create aliases for users": "ユーザー向けの別名を作成",
|
||||
"Create an account": "アカウントを作成",
|
||||
"Create an API key to unlock the real request": "実際のリクエストを使うには API キーを作成してください",
|
||||
"Create and review invite or credit codes.": "招待コードまたはクレジットコードを作成および確認。",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "正の整数を入力してください",
|
||||
"Enter a positive or negative amount to adjust the quota": "クォータを調整するために正または負の値を入力してください",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "react-icons のコンポーネント名を入力してください。無効な名前はアイコンを表示しません。",
|
||||
"Enter a rule to preview mappings.": "ルールを入力するとマッピングが表示されます。",
|
||||
"Enter a valid condition value.": "有効な条件値を入力してください。",
|
||||
"Enter a valid email or leave blank": "有効なメールアドレスを入力するか空白にしてください",
|
||||
"Enter a value and press Enter": "値を入力してEnterを押してください",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "例の価格",
|
||||
"Example spec": "例の仕様",
|
||||
"Example:": "例:",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "例:ユーザーは gpt-4o を指定し、上流は gpt-4o-all を受信します。",
|
||||
"example.com blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com company.com",
|
||||
"Excellent": "優秀",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "元の値を保持",
|
||||
"Keep original value (skip if target exists)": "元の値を保持(ターゲットが存在する場合はスキップ)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "既存のパスキーで使用しているドメインを1行に1つ入力します。メインドメインを www.example.com から example.com に変更した場合は、ここに www.example.com を残してください。",
|
||||
"Keep the names users call; change what is sent upstream.": "ユーザーが指定する名前を保ち、上流に送る名前を変更します。",
|
||||
"Keep the platform ready": "プラットフォームを準備状態に保つ",
|
||||
"Keep the session binding when the channel is unavailable": "チャネルが利用できない場合も関連付けを保持",
|
||||
"Keep this above 1 minute to avoid heavy database load": "データベースへの負荷を避けるため、これを1分以上に保ってください",
|
||||
"Keep this vendor": "このプロバイダーを残す",
|
||||
"Keep upstream names; choose what users call.": "上流の名前を保ち、ユーザーが指定する名前を設定します。",
|
||||
"Keep-alive Ping": "キープアライブPing",
|
||||
"Keeps compatible responses more repeatable": "対応モデルの応答を再現しやすくします",
|
||||
"Key": "キー",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "上流価格を正常に取得しました",
|
||||
"Upstream protocol plan": "上流プロトコルプラン",
|
||||
"Upstream ratios fetched successfully": "アップストリーム比率が正常に取得されました",
|
||||
"Upstream receives": "上流が受信",
|
||||
"Upstream request failed": "上流リクエスト失敗",
|
||||
"Upstream Request ID": "上流リクエストID",
|
||||
"Upstream response": "上流レスポンス",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "ユーザー名フィールド",
|
||||
"Username or Email": "ユーザー名またはメールアドレス",
|
||||
"Users": "ユーザー",
|
||||
"Users call": "ユーザーの指定",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "ユーザーは左側のモデルを呼び出します。プラットフォームはリクエストを右側のアップストリームモデルに転送します。",
|
||||
"Users in {{group}}": "{{group}} のユーザー",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "アップスケールやバリエーションを行う前に、ユーザーは成功した描画を待つ必要があります。",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "New API へようこそ...",
|
||||
"Well-Known URL": "Well-Known URL",
|
||||
"Well-Known URL must start with http:// or https://": "Well-Known URL は http:// または https:// で始まる必要があります",
|
||||
"What do you want to change?": "何を変更しますか?",
|
||||
"What would you like to know?": "何を知りたいですか?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "トークンが auto グループを使用すると、システムは上から順に利用可能なグループを探します。",
|
||||
"When billed as {{group}}": "{{group}} として課金時",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "1 неделю назад",
|
||||
"1 year ago": "1 год назад",
|
||||
"1. Create an application in your Gotify server": "1. Создайте приложение на вашем сервере Gotify",
|
||||
"1. Select models users call": "1. Выберите модели для пользователей",
|
||||
"1. Select upstream models": "1. Выберите модели провайдера",
|
||||
"10 / page": "10 / страница",
|
||||
"100 / page": "100 / страница",
|
||||
"14 Days": "14 дней",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1Н",
|
||||
"2. Copy the application token": "2. Скопируйте токен приложения",
|
||||
"2. Set names users call": "2. Задайте имена для пользователей",
|
||||
"2. Set upstream model names": "2. Задайте имена для провайдера",
|
||||
"20 / page": "20 / страница",
|
||||
"24 Hours": "24 часа",
|
||||
"24 hours ago": "24 часа назад",
|
||||
"29 Days": "29 дней",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx, 504 и 524 всегда исключены.",
|
||||
"3. Enter your Gotify server URL and token above": "3. Введите URL вашего сервера Gotify и токен выше",
|
||||
"3. Review mappings": "3. Проверьте сопоставления",
|
||||
"30 Days": "30 дней",
|
||||
"30 days ago": "30 дней назад",
|
||||
"30d change": "Изменение за 30 дней",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "Сменить поставщика",
|
||||
"Change the Passkey domain?": "Изменить домен ключей доступа?",
|
||||
"Change To": "Изменить на",
|
||||
"Change upstream model names": "Изменить имена моделей для провайдера",
|
||||
"Change vendor": "Изменить поставщика",
|
||||
"Changed": "Изменено",
|
||||
"Changed / Total": "Изменено / Всего",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "Создайте ключ для приложения или сервиса",
|
||||
"Create a new user group to configure ratio overrides for.": "Создайте новую группу пользователей для настройки переопределений соотношений.",
|
||||
"Create account": "Создать аккаунт",
|
||||
"Create aliases for users": "Создать псевдонимы для пользователей",
|
||||
"Create an account": "Создать аккаунт",
|
||||
"Create an API key to unlock the real request": "Создайте API-ключ, чтобы открыть реальный запрос",
|
||||
"Create and review invite or credit codes.": "Создать и просмотреть коды приглашений или кредитов.",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "Введите положительное целое число",
|
||||
"Enter a positive or negative amount to adjust the quota": "Введите положительную или отрицательную сумму для корректировки квоты",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "Введите имя компонента react-icons. Недопустимые имена не отображают значок.",
|
||||
"Enter a rule to preview mappings.": "Введите правило для просмотра сопоставлений.",
|
||||
"Enter a valid condition value.": "Введите допустимое значение условия.",
|
||||
"Enter a valid email or leave blank": "Введите действительный email или оставьте пустым",
|
||||
"Enter a value and press Enter": "Введите значение и нажмите Enter",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "Пример цены",
|
||||
"Example spec": "Пример спецификации",
|
||||
"Example:": "Пример:",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "Пример: пользователь вызывает gpt-4o, провайдер получает gpt-4o-all.",
|
||||
"example.com blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com company.com",
|
||||
"Excellent": "Отлично",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "Сохранить исходное значение",
|
||||
"Keep original value (skip if target exists)": "Сохранить исходное значение (пропустить если цель существует)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Укажите домены существующих ключей доступа, по одному на строку. Если основной домен изменился с www.example.com на example.com, сохраните здесь www.example.com.",
|
||||
"Keep the names users call; change what is sent upstream.": "Сохраните имена для пользователей и измените имена, отправляемые провайдеру.",
|
||||
"Keep the platform ready": "Поддерживайте платформу в готовности",
|
||||
"Keep the session binding when the channel is unavailable": "Сохранять привязку, если канал недоступен",
|
||||
"Keep this above 1 minute to avoid heavy database load": "Держите это значение выше 1 минуты, чтобы избежать высокой нагрузки на базу данных",
|
||||
"Keep this vendor": "Сохранить этого поставщика",
|
||||
"Keep upstream names; choose what users call.": "Сохраните имена у провайдера и задайте имена для пользователей.",
|
||||
"Keep-alive Ping": "Пинг Keep-alive",
|
||||
"Keeps compatible responses more repeatable": "Делает совместимые ответы более воспроизводимыми",
|
||||
"Key": "Ключ",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "Цены провайдера успешно получены",
|
||||
"Upstream protocol plan": "Схема протокола поставщика",
|
||||
"Upstream ratios fetched successfully": "Коэффициенты upstream успешно получены",
|
||||
"Upstream receives": "Провайдер получает",
|
||||
"Upstream request failed": "Запрос провайдеру не выполнен",
|
||||
"Upstream Request ID": "ID вышестоящего запроса",
|
||||
"Upstream response": "Ответ провайдера",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "Поле имени пользователя",
|
||||
"Username or Email": "Имя пользователя или Email",
|
||||
"Users": "Пользователи",
|
||||
"Users call": "Пользователь вызывает",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "Пользователи вызывают модель слева. Платформа перенаправляет запрос вышестоящей модели справа.",
|
||||
"Users in {{group}}": "Пользователи группы {{group}}",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "Пользователи должны дождаться успешного рисунка перед апскейлом или вариациями.",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "Добро пожаловать в наш New API...",
|
||||
"Well-Known URL": "Well-Known URL",
|
||||
"Well-Known URL must start with http:// or https://": "Well-Known URL должен начинаться с http:// или https://",
|
||||
"What do you want to change?": "Что вы хотите изменить?",
|
||||
"What would you like to know?": "Что вы хотели бы узнать?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Когда токен использует группу auto, система перебирает группы сверху вниз, пока не найдёт доступную.",
|
||||
"When billed as {{group}}": "При тарификации по {{group}}",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "1 tuần trước",
|
||||
"1 year ago": "1 năm trước",
|
||||
"1. Create an application in your Gotify server": "1. Tạo một ứng dụng trong máy chủ Gotify của bạn",
|
||||
"1. Select models users call": "1. Chọn mô hình người dùng gọi",
|
||||
"1. Select upstream models": "1. Chọn mô hình phía nhà cung cấp",
|
||||
"10 / page": "10 / trang",
|
||||
"100 / page": "100 / trang",
|
||||
"14 Days": "14 ngày",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1W",
|
||||
"2. Copy the application token": "2. Sao chép token ứng dụng",
|
||||
"2. Set names users call": "2. Đặt tên để người dùng gọi",
|
||||
"2. Set upstream model names": "2. Đặt tên gửi lên nhà cung cấp",
|
||||
"20 / page": "20 / trang",
|
||||
"24 Hours": "24 giờ",
|
||||
"24 hours ago": "24 giờ trước",
|
||||
"29 Days": "29 ngày",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx, 504 và 524 luôn bị loại trừ.",
|
||||
"3. Enter your Gotify server URL and token above": "3. Nhập URL máy chủ Gotify và mã thông báo của bạn ở trên",
|
||||
"3. Review mappings": "3. Kiểm tra ánh xạ",
|
||||
"30 Days": "30 ngày",
|
||||
"30 days ago": "30 ngày trước",
|
||||
"30d change": "Thay đổi 30 ngày",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "Đổi nhà cung cấp",
|
||||
"Change the Passkey domain?": "Đổi tên miền khóa truy cập?",
|
||||
"Change To": "Thay đổi thành",
|
||||
"Change upstream model names": "Đổi tên mô hình gửi lên nhà cung cấp",
|
||||
"Change vendor": "Đổi nhà cung cấp",
|
||||
"Changed": "Đã thay đổi",
|
||||
"Changed / Total": "Đã thay đổi / Tổng số",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "Tạo khóa cho ứng dụng hoặc dịch vụ của bạn",
|
||||
"Create a new user group to configure ratio overrides for.": "Tạo một nhóm người dùng mới để cấu hình ghi đè tỷ lệ.",
|
||||
"Create account": "Tạo tài khoản",
|
||||
"Create aliases for users": "Tạo bí danh cho người dùng",
|
||||
"Create an account": "Tạo tài khoản",
|
||||
"Create an API key to unlock the real request": "Tạo khóa API để mở yêu cầu thật",
|
||||
"Create and review invite or credit codes.": "Tạo và xem xét mã mời hoặc mã tín dụng.",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "Nhập một số nguyên dương",
|
||||
"Enter a positive or negative amount to adjust the quota": "Nhập một giá trị dương hoặc âm để điều chỉnh hạn ngạch",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "Nhập tên component react-icons. Tên không hợp lệ sẽ không hiển thị biểu tượng.",
|
||||
"Enter a rule to preview mappings.": "Nhập quy tắc để xem trước ánh xạ.",
|
||||
"Enter a valid condition value.": "Nhập giá trị điều kiện hợp lệ.",
|
||||
"Enter a valid email or leave blank": "Nhập email hợp lệ hoặc để trống",
|
||||
"Enter a value and press Enter": "Nhập giá trị và nhấn Enter",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "Giá ví dụ",
|
||||
"Example spec": "Thông số ví dụ",
|
||||
"Example:": "Ví dụ:",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "Ví dụ: người dùng gọi gpt-4o, nhà cung cấp nhận gpt-4o-all.",
|
||||
"example.com blocked-site.com": "example.com\nblocked-site.com",
|
||||
"example.com company.com": "example.com\ncompany.com",
|
||||
"Excellent": "Tuyệt vời",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "Giữ giá trị gốc",
|
||||
"Keep original value (skip if target exists)": "Giữ giá trị gốc (bỏ qua nếu đích đã tồn tại)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Giữ các tên miền mà khóa truy cập hiện có sử dụng, mỗi dòng một tên miền. Nếu tên miền chính đổi từ www.example.com sang example.com, hãy giữ www.example.com tại đây.",
|
||||
"Keep the names users call; change what is sent upstream.": "Giữ tên người dùng gọi, đổi tên gửi lên nhà cung cấp.",
|
||||
"Keep the platform ready": "Giữ nền tảng luôn sẵn sàng",
|
||||
"Keep the session binding when the channel is unavailable": "Giữ liên kết phiên khi kênh không khả dụng",
|
||||
"Keep this above 1 minute to avoid heavy database load": "Giữ cái này trên 1 phút để tránh tải nặng cơ sở dữ liệu",
|
||||
"Keep this vendor": "Giữ nhà cung cấp này",
|
||||
"Keep upstream names; choose what users call.": "Giữ tên phía nhà cung cấp, đặt tên để người dùng gọi.",
|
||||
"Keep-alive Ping": "Ping duy trì",
|
||||
"Keeps compatible responses more repeatable": "Giúp phản hồi tương thích dễ lặp lại hơn",
|
||||
"Key": "Khóa",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "Lấy giá upstream thành công",
|
||||
"Upstream protocol plan": "Cấu hình giao thức thượng nguồn",
|
||||
"Upstream ratios fetched successfully": "Đã lấy tỷ lệ upstream thành công",
|
||||
"Upstream receives": "Nhà cung cấp nhận",
|
||||
"Upstream request failed": "Yêu cầu lên nguồn thất bại",
|
||||
"Upstream Request ID": "ID yêu cầu thượng nguồn",
|
||||
"Upstream response": "Phản hồi nguồn",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "Trường Tên người dùng",
|
||||
"Username or Email": "Tên đăng nhập hoặc Email",
|
||||
"Users": "Người dùng",
|
||||
"Users call": "Người dùng gọi",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "Người dùng gọi mô hình bên trái. Nền tảng chuyển tiếp yêu cầu đến mô hình thượng nguồn bên phải.",
|
||||
"Users in {{group}}": "Người dùng trong {{group}}",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "Người dùng phải chờ vẽ thành công trước khi upscale hoặc biến thể.",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "Chào mừng bạn đến với API mới của chúng tôi...",
|
||||
"Well-Known URL": "URL Well-Known",
|
||||
"Well-Known URL must start with http:// or https://": "URL Well-Known phải bắt đầu bằng http:// hoặc https://",
|
||||
"What do you want to change?": "Bạn muốn thay đổi gì?",
|
||||
"What would you like to know?": "Bạn muốn biết gì?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Khi token dùng nhóm auto, hệ thống thử các nhóm từ trên xuống dưới cho đến khi tìm được nhóm khả dụng.",
|
||||
"When billed as {{group}}": "Khi tính phí theo {{group}}",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "1 週前",
|
||||
"1 year ago": "1 年前",
|
||||
"1. Create an application in your Gotify server": "1. 在您的 Gotify 伺服器中建立一個套用程式",
|
||||
"1. Select models users call": "1. 選擇使用者呼叫的模型",
|
||||
"1. Select upstream models": "1. 選擇上游模型",
|
||||
"10 / page": "10 條/頁",
|
||||
"100 / page": "100 條/頁",
|
||||
"14 Days": "14 日",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1 週",
|
||||
"2. Copy the application token": "2. 複製套用程式令牌",
|
||||
"2. Set names users call": "2. 設定使用者呼叫的名稱",
|
||||
"2. Set upstream model names": "2. 設定傳給上游的名稱",
|
||||
"20 / page": "20 條/頁",
|
||||
"24 Hours": "24 小時",
|
||||
"24 hours ago": "24 小時前",
|
||||
"29 Days": "29 日",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx、504、524 一律排除。",
|
||||
"3. Enter your Gotify server URL and token above": "3. 在上方輸入您的 Gotify 伺服器 URL 和令牌",
|
||||
"3. Review mappings": "3. 確認對應結果",
|
||||
"30 Days": "30 日",
|
||||
"30 days ago": "30 日前",
|
||||
"30d change": "30 天變化",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "更換供應商",
|
||||
"Change the Passkey domain?": "變更通行金鑰網域?",
|
||||
"Change To": "更改為",
|
||||
"Change upstream model names": "修改傳給上游的模型名稱",
|
||||
"Change vendor": "設定供應商",
|
||||
"Changed": "變更",
|
||||
"Changed / Total": "已變更 / 總數",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "為你的套用或服務建立金鑰",
|
||||
"Create a new user group to configure ratio overrides for.": "建立一個新的用戶分組來設定比例覆蓋。",
|
||||
"Create account": "建立用戶",
|
||||
"Create aliases for users": "為上游模型設定別名",
|
||||
"Create an account": "建立一個用戶",
|
||||
"Create an API key to unlock the real request": "建立 API 金鑰以解鎖真實請求",
|
||||
"Create and review invite or credit codes.": "建立和審查邀請或信用代碼。",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "請輸入正整數",
|
||||
"Enter a positive or negative amount to adjust the quota": "輸入正數或負數以調整配額",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "輸入 react-icons 組件名。無法解析的名稱不會顯示圖標。",
|
||||
"Enter a rule to preview mappings.": "填寫規則後,這裡會顯示具體對應。",
|
||||
"Enter a valid condition value.": "請輸入有效的條件值。",
|
||||
"Enter a valid email or leave blank": "請輸入有效的電郵地址或留空",
|
||||
"Enter a value and press Enter": "輸入值並按 Enter 鍵",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "示例價格",
|
||||
"Example spec": "示例規格",
|
||||
"Example:": "示例:",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "例如:使用者呼叫 gpt-4o,上游收到 gpt-4o-all。",
|
||||
"example.com blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com company.com",
|
||||
"Excellent": "優秀",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "保留原值",
|
||||
"Keep original value (skip if target exists)": "保留原值(目標已有值時不覆蓋)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "每行填寫一個既有通行金鑰使用的網域。例如,主要網域從 www.example.com 改為 example.com 後,應在此保留 www.example.com。",
|
||||
"Keep the names users call; change what is sent upstream.": "保留使用者呼叫的名稱,只修改傳給上游的名稱。",
|
||||
"Keep the platform ready": "保持平台就緒",
|
||||
"Keep the session binding when the channel is unavailable": "渠道無法使用時保留工作階段綁定",
|
||||
"Keep this above 1 minute to avoid heavy database load": "保持此值大於 1 分鐘以避免資料庫負載過重",
|
||||
"Keep this vendor": "保留此供應商",
|
||||
"Keep upstream names; choose what users call.": "保留上游名稱,設定使用者呼叫的名稱。",
|
||||
"Keep-alive Ping": "保持連線心跳",
|
||||
"Keeps compatible responses more repeatable": "讓相容模型的回覆更可重現",
|
||||
"Key": "金鑰",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "已成功獲取上游價格",
|
||||
"Upstream protocol plan": "上游協議方案",
|
||||
"Upstream ratios fetched successfully": "上游比率獲取成功",
|
||||
"Upstream receives": "上游收到",
|
||||
"Upstream request failed": "上游請求失敗",
|
||||
"Upstream Request ID": "上游請求 ID",
|
||||
"Upstream response": "上游回應",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "用戶名欄位",
|
||||
"Username or Email": "用戶名或電郵",
|
||||
"Users": "用戶",
|
||||
"Users call": "使用者呼叫",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "用戶呼叫左側的模型。平台將請求轉發給右側的上游模型。",
|
||||
"Users in {{group}}": "{{group}} 分組的用戶",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "用戶必須等待成功的繪圖完成,才能進行放大或變體。",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "歡迎使用我們的 New API...",
|
||||
"Well-Known URL": "Well-Known URL",
|
||||
"Well-Known URL must start with http:// or https://": "Well-Known URL 必須以 http:// 或 https:// 開頭",
|
||||
"What do you want to change?": "你想修改什麼?",
|
||||
"What would you like to know?": "您想了解什麼?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "當令牌使用 auto 分組時,系統會按從上到下的順序嘗試,直到找到可用分組。",
|
||||
"When billed as {{group}}": "按 {{group}} 收費時",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"1 week ago": "1 周前",
|
||||
"1 year ago": "1 年前",
|
||||
"1. Create an application in your Gotify server": "1. 在您的 Gotify 服务器中创建一个应用程序",
|
||||
"1. Select models users call": "1. 选择用户调用的模型",
|
||||
"1. Select upstream models": "1. 选择上游模型",
|
||||
"10 / page": "10 条/页",
|
||||
"100 / page": "100 条/页",
|
||||
"14 Days": "14 天",
|
||||
@@ -138,12 +140,15 @@
|
||||
"1M token": "1M token",
|
||||
"1W": "1 周",
|
||||
"2. Copy the application token": "2. 复制应用程序令牌",
|
||||
"2. Set names users call": "2. 设置用户调用的名称",
|
||||
"2. Set upstream model names": "2. 设置发给上游的名称",
|
||||
"20 / page": "20 条/页",
|
||||
"24 Hours": "24 小时",
|
||||
"24 hours ago": "24 小时前",
|
||||
"29 Days": "29 天",
|
||||
"2xx, 504 and 524 are always excluded.": "2xx、504、524 始终排除。",
|
||||
"3. Enter your Gotify server URL and token above": "3. 在上方输入您的 Gotify 服务器 URL 和令牌",
|
||||
"3. Review mappings": "3. 确认映射结果",
|
||||
"30 Days": "30 天",
|
||||
"30 days ago": "30 天前",
|
||||
"30d change": "30 天变化",
|
||||
@@ -954,6 +959,7 @@
|
||||
"Change provider": "更换提供商",
|
||||
"Change the Passkey domain?": "更改通行密钥域名?",
|
||||
"Change To": "更改为",
|
||||
"Change upstream model names": "修改发给上游的模型名",
|
||||
"Change vendor": "设置供应商",
|
||||
"Changed": "变更",
|
||||
"Changed / Total": "已变更 / 总数",
|
||||
@@ -1483,6 +1489,7 @@
|
||||
"Create a key for your app or service": "为你的应用或服务创建密钥",
|
||||
"Create a new user group to configure ratio overrides for.": "创建一个新的用户分组来配置比例覆盖。",
|
||||
"Create account": "创建账户",
|
||||
"Create aliases for users": "给上游模型起别名",
|
||||
"Create an account": "创建一个账户",
|
||||
"Create an API key to unlock the real request": "创建 API 密钥以解锁真实请求",
|
||||
"Create and review invite or credit codes.": "创建和审查邀请或信用代码。",
|
||||
@@ -2110,6 +2117,7 @@
|
||||
"Enter a positive integer": "请输入正整数",
|
||||
"Enter a positive or negative amount to adjust the quota": "输入正数或负数以调整配额",
|
||||
"Enter a react-icons component name. Invalid names show no icon.": "输入 react-icons 组件名。无法解析的名称不会显示图标。",
|
||||
"Enter a rule to preview mappings.": "填写规则后,这里会显示具体映射。",
|
||||
"Enter a valid condition value.": "请输入有效的条件值。",
|
||||
"Enter a valid email or leave blank": "请输入有效的邮箱地址或留空",
|
||||
"Enter a value and press Enter": "输入值并按回车键",
|
||||
@@ -2230,6 +2238,7 @@
|
||||
"Example price": "示例价格",
|
||||
"Example spec": "示例规格",
|
||||
"Example:": "示例:",
|
||||
"Example: users call gpt-4o; upstream receives gpt-4o-all.": "例如:用户调用 gpt-4o,上游收到 gpt-4o-all。",
|
||||
"example.com blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com company.com",
|
||||
"Excellent": "优秀",
|
||||
@@ -3076,10 +3085,12 @@
|
||||
"Keep original value": "保留原值",
|
||||
"Keep original value (skip if target exists)": "保留原值(目标已有值时不覆盖)",
|
||||
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "每行填写一个已有通行密钥使用的域名。例如,主域名从 www.example.com 改成 example.com 后,应在这里保留 www.example.com。",
|
||||
"Keep the names users call; change what is sent upstream.": "用户调用的名称不变,只修改发给上游的名称。",
|
||||
"Keep the platform ready": "保持平台就绪",
|
||||
"Keep the session binding when the channel is unavailable": "渠道不可用时保留会话绑定",
|
||||
"Keep this above 1 minute to avoid heavy database load": "保持此值大于 1 分钟以避免数据库负载过重",
|
||||
"Keep this vendor": "保留此供应商",
|
||||
"Keep upstream names; choose what users call.": "上游名称不变,设置用户调用的名称。",
|
||||
"Keep-alive Ping": "保持连接心跳",
|
||||
"Keeps compatible responses more repeatable": "让兼容模型的回复更可复现",
|
||||
"Key": "密钥",
|
||||
@@ -6286,6 +6297,7 @@
|
||||
"Upstream prices fetched successfully": "已成功获取上游价格",
|
||||
"Upstream protocol plan": "上游协议方案",
|
||||
"Upstream ratios fetched successfully": "上游比率获取成功",
|
||||
"Upstream receives": "上游收到",
|
||||
"Upstream request failed": "上游请求失败",
|
||||
"Upstream Request ID": "上游请求 ID",
|
||||
"Upstream response": "上游响应",
|
||||
@@ -6424,6 +6436,7 @@
|
||||
"Username Field": "用户名字段",
|
||||
"Username or Email": "用户名或电子邮件",
|
||||
"Users": "用户",
|
||||
"Users call": "用户调用",
|
||||
"Users call the model on the left. The platform forwards the request to the upstream model on the right.": "用户调用左侧的模型。平台将请求转发给右侧的上游模型。",
|
||||
"Users in {{group}}": "{{group}} 分组的用户",
|
||||
"Users must wait for a successful drawing before upscales or variations.": "用户必须等待成功的绘图完成,才能进行放大或变体。",
|
||||
@@ -6630,6 +6643,7 @@
|
||||
"Welcome to our New API...": "欢迎使用我们的 New API...",
|
||||
"Well-Known URL": "Well-Known URL",
|
||||
"Well-Known URL must start with http:// or https://": "Well-Known URL 必须以 http:// 或 https:// 开头",
|
||||
"What do you want to change?": "你想修改什么?",
|
||||
"What would you like to know?": "您想了解什么?",
|
||||
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。",
|
||||
"When billed as {{group}}": "按 {{group}} 计费时",
|
||||
|
||||
Reference in New Issue
Block a user