refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+648
View File
@@ -0,0 +1,648 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { getGroups as getUserGroups } from '@/features/users/api'
import { api, type ApiRequestConfig } from '@/lib/api'
import type {
AddChannelRequest,
BatchDeleteParams,
BatchSetTagParams,
Channel,
ChannelBalanceResponse,
ChannelOpsResponse,
ChannelTestResponse,
CopyChannelParams,
CopyChannelResponse,
FetchModelsResponse,
GetChannelResponse,
GetChannelsParams,
GetChannelsResponse,
MultiKeyManageParams,
MultiKeyStatusResponse,
SearchChannelsParams,
SearchChannelsResponse,
TagOperationParams,
} from './types'
const channelActionConfig = (
config: ApiRequestConfig = {}
): ApiRequestConfig => ({
...config,
skipBusinessError: true,
skipErrorHandler: true,
})
export type CodexUsageResponse = {
success: boolean
message?: string
upstream_status?: number
data?: Record<string, unknown>
}
export type CodexResetCreditsResponse = CodexUsageResponse
export type CodexUsageResetResponse = CodexUsageResponse
export type CodexCredentialRefreshResponse = {
success: boolean
message?: string
data?: {
expires_at?: string
last_refresh?: string
account_id?: string
email?: string
channel_id?: number
channel_type?: number
channel_name?: string
}
}
// ============================================================================
// Base Channel CRUD Operations
// ============================================================================
/**
* Get paginated list of channels
*/
export async function getChannels(
params: GetChannelsParams = {}
): Promise<GetChannelsResponse> {
const res = await api.get('/api/channel', { params })
return res.data
}
/**
* Search channels with filters
*/
export async function searchChannels(
params: SearchChannelsParams
): Promise<SearchChannelsResponse> {
const res = await api.get('/api/channel/search', { params })
return res.data
}
/**
* Get single channel by ID
*/
export async function getChannel(id: number): Promise<GetChannelResponse> {
const res = await api.get(`/api/channel/${id}`)
return res.data
}
/**
* Get channel operations summary for administrators
*/
export async function getChannelOps(): Promise<ChannelOpsResponse> {
const res = await api.get('/api/channel/ops', channelActionConfig())
return res.data
}
/**
* Create new channel(s)
* Supports single, batch, and multi-key modes
*/
export async function createChannel(
data: AddChannelRequest
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel', data, channelActionConfig())
return res.data
}
/**
* Update existing channel
*/
export async function updateChannel(
id: number,
data: Partial<Channel>
): Promise<{ success: boolean; message?: string; data?: Channel }> {
const res = await api.put(
'/api/channel/',
{ id, ...data },
channelActionConfig()
)
return res.data
}
/**
* Update channel enabled/disabled status.
*/
export async function updateChannelStatus(
id: number,
status: number
): Promise<{ success: boolean; message?: string; data?: boolean }> {
const res = await api.post(
`/api/channel/${id}/status`,
{ status },
channelActionConfig()
)
return res.data
}
/**
* Batch update channel enabled/disabled status.
*/
export async function batchUpdateChannelStatus(
ids: number[],
status: number
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post(
'/api/channel/status/batch',
{ ids, status },
channelActionConfig()
)
return res.data
}
/**
* Delete single channel
*/
export async function deleteChannel(
id: number
): Promise<{ success: boolean; message?: string }> {
const res = await api.delete(`/api/channel/${id}`, channelActionConfig())
return res.data
}
/**
* Batch delete channels
*/
export async function batchDeleteChannels(
data: BatchDeleteParams
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post('/api/channel/batch', data, channelActionConfig())
return res.data
}
/**
* Batch set tag for channels
*/
export async function batchSetChannelTag(
data: BatchSetTagParams
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post(
'/api/channel/batch/tag',
data,
channelActionConfig()
)
return res.data
}
// ============================================================================
// Channel Operations
// ============================================================================
/**
* Test channel connectivity
*/
export async function testChannel(
id: number,
params?: { model?: string; endpoint_type?: string; stream?: boolean }
): Promise<ChannelTestResponse> {
const res = await api.get(
`/api/channel/test/${id}`,
channelActionConfig({ params })
)
return res.data
}
/**
* Update channel balance
*/
export async function updateChannelBalance(
id: number
): Promise<ChannelBalanceResponse> {
const res = await api.get(
`/api/channel/update_balance/${id}`,
channelActionConfig()
)
return res.data
}
/**
* Fetch available models from upstream provider
*/
export async function fetchUpstreamModels(
id: number
): Promise<FetchModelsResponse> {
const res = await api.get(
`/api/channel/fetch_models/${id}`,
channelActionConfig()
)
return res.data
}
/**
* Copy/clone a channel
*/
export async function copyChannel(
id: number,
params: CopyChannelParams = {}
): Promise<CopyChannelResponse> {
const res = await api.post(
`/api/channel/copy/${id}`,
null,
channelActionConfig({ params })
)
return res.data
}
/**
* Fix channel abilities
*/
export async function fixChannelAbilities(): Promise<{
success: boolean
message?: string
data?: { success: number; fails: number }
}> {
const res = await api.post(
'/api/channel/fix',
undefined,
channelActionConfig()
)
return res.data
}
/**
* Delete all disabled channels
*/
export async function deleteDisabledChannels(): Promise<{
success: boolean
message?: string
data?: number
}> {
const res = await api.delete('/api/channel/disabled', channelActionConfig())
return res.data
}
/**
* Get channel key (requires 2FA verification)
*/
export async function getChannelKey(
id: number,
proofToken?: string
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
const res = await api.post(
`/api/channel/${id}/key`,
undefined,
channelActionConfig({
headers: proofToken ? { 'X-Security-Proof': proofToken } : undefined,
})
)
return res.data
}
// ============================================================================
// Codex Channel Operations
// ============================================================================
export async function refreshCodexCredential(
channelId: number
): Promise<CodexCredentialRefreshResponse> {
const res = await api.post(
`/api/channel/${channelId}/codex/refresh`,
{},
channelActionConfig()
)
return res.data
}
export async function getCodexUsage(
channelId: number
): Promise<CodexUsageResponse> {
const res = await api.get(
`/api/channel/${channelId}/codex/usage`,
channelActionConfig({ disableDuplicate: true })
)
return res.data
}
export async function getCodexResetCredits(
channelId: number
): Promise<CodexResetCreditsResponse> {
const res = await api.get(
`/api/channel/${channelId}/codex/usage/reset-credits`,
channelActionConfig({ disableDuplicate: true })
)
return res.data
}
export async function resetCodexUsage(
channelId: number
): Promise<CodexUsageResetResponse> {
const res = await api.post(
`/api/channel/${channelId}/codex/usage/reset`,
{},
channelActionConfig({ disableDuplicate: true })
)
return res.data
}
// ============================================================================
// Multi-Key Management
// ============================================================================
/**
* Manage multi-key channel operations
*/
export async function manageMultiKeys(
params: MultiKeyManageParams
): Promise<MultiKeyStatusResponse | { success: boolean; message?: string }> {
const res = await api.post(
'/api/channel/multi_key/manage',
params,
channelActionConfig()
)
return res.data
}
/**
* Get key status for multi-key channel
*/
export async function getMultiKeyStatus(
channelId: number,
page = 1,
pageSize = 50,
status?: number
): Promise<MultiKeyStatusResponse> {
return manageMultiKeys({
channel_id: channelId,
action: 'get_key_status',
page,
page_size: pageSize,
status,
}) as Promise<MultiKeyStatusResponse>
}
/**
* Enable a specific key in multi-key channel
*/
export async function enableMultiKey(
channelId: number,
keyIndex: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'enable_key',
key_index: keyIndex,
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Disable a specific key in multi-key channel
*/
export async function disableMultiKey(
channelId: number,
keyIndex: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'disable_key',
key_index: keyIndex,
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Delete a specific key in multi-key channel
*/
export async function deleteMultiKey(
channelId: number,
keyIndex: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'delete_key',
key_index: keyIndex,
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Enable all keys in multi-key channel
*/
export async function enableAllMultiKeys(
channelId: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'enable_all_keys',
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Disable all keys in multi-key channel
*/
export async function disableAllMultiKeys(
channelId: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'disable_all_keys',
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Delete all disabled keys in multi-key channel
*/
export async function deleteDisabledMultiKeys(
channelId: number
): Promise<{ success: boolean; message?: string; data?: number }> {
return manageMultiKeys({
channel_id: channelId,
action: 'delete_disabled_keys',
}) as Promise<{ success: boolean; message?: string; data?: number }>
}
// ============================================================================
// Tag Operations
// ============================================================================
/**
* Enable all channels with a specific tag
*/
export async function enableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
const res = await api.post(
'/api/channel/tag/enabled',
{ tag },
channelActionConfig()
)
return res.data
}
/**
* Disable all channels with a specific tag
*/
export async function disableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
const res = await api.post(
'/api/channel/tag/disabled',
{ tag },
channelActionConfig()
)
return res.data
}
/**
* Edit all channels with a specific tag
*/
export async function editTagChannels(
params: TagOperationParams
): Promise<{ success: boolean; message?: string }> {
const res = await api.put('/api/channel/tag', params, channelActionConfig())
return res.data
}
/**
* Get models for a specific tag
*/
export async function getTagModels(
tag: string
): Promise<{ success: boolean; message?: string; data?: string }> {
const res = await api.get('/api/channel/tag/models', { params: { tag } })
return res.data
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Fetch models from the current unsaved channel form configuration.
*/
export async function fetchModels(data: {
base_url: string
type: number
key?: string
channel_id?: number
advanced_custom?: string
header_override?: string
proxy?: string
}): Promise<FetchModelsResponse> {
const res = await api.post(
'/api/channel/fetch_models',
data,
channelActionConfig()
)
return res.data
}
/**
* Delete an Ollama model from a channel
*/
export async function deleteOllamaModel(params: {
channel_id: number
model_name: string
}): Promise<{ success: boolean; message?: string }> {
const res = await api.delete(
'/api/channel/ollama/delete',
channelActionConfig({ data: params })
)
return res.data
}
/**
* Test all enabled channels
*/
export async function testAllChannels(): Promise<{
success: boolean
message?: string
}> {
const res = await api.get('/api/channel/test', channelActionConfig())
return res.data
}
/**
* Update balance for all enabled channels
*/
export async function updateAllChannelsBalance(): Promise<{
success: boolean
message?: string
}> {
const res = await api.get(
'/api/channel/update_balance',
channelActionConfig()
)
return res.data
}
/**
* Get all available models
*/
export async function getAllModels(): Promise<{
success: boolean
message?: string
data?: Array<{ id: string; [key: string]: unknown }>
}> {
const res = await api.get('/api/channel/models')
return res.data
}
/**
* Get all enabled models
*/
export async function getEnabledModels(): Promise<{
success: boolean
message?: string
data?: string[]
}> {
const res = await api.get('/api/channel/models_enabled')
return res.data
}
// ============================================================================
// Ollama Utilities
// ============================================================================
/**
* Check Ollama version for a given channel
*/
export async function getOllamaVersion(
channelId: number
): Promise<{ success: boolean; message?: string; data?: { version: string } }> {
const res = await api.get(`/api/channel/ollama/version/${channelId}`)
return res.data
}
// ============================================================================
// Group Management
// ============================================================================
/**
* Get all available groups (re-exported from users API for convenience)
*/
export const getGroups = getUserGroups
// ============================================================================
// Prefill Groups (Model Groups)
// ============================================================================
/**
* Get prefill groups for quick model selection
*/
export async function getPrefillGroups(
type: 'model' | 'group' = 'model'
): Promise<{
success: boolean
message?: string
data?: Array<{ id: number; name: string; items: string | string[] }>
}> {
const res = await api.get('/api/prefill_group', { params: { type } })
return res.data
}
+186
View File
@@ -0,0 +1,186 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
const SENSITIVE_MASK = '••••'
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so the table's information and interactions are
* preserved: row selection, provider/multi-key/IO.NET type badge, id,
* name/remark + warning icons, status (with tooltips), groups, inline
* priority/weight spinners, balance refresh, response/test times, tag
* expand-collapse, and the per-row (or per-tag) actions menu.
*/
function ChannelCardComponent({
row,
isSelected,
}: {
row: Row<Channel>
isSelected: boolean
}) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original)
const cells = row.getAllCells()
const renderCell = (id: string) => {
const cell = cells.find((c) => c.column.id === id)
if (!cell || !cell.column.columnDef.cell) {
return null
}
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const fieldLabels: Record<string, string> = {
balance: t('Used / Remaining'),
response_time: t('Response'),
test_time: t('Last Tested'),
}
const groups = parseGroupsList(row.original.group ?? '')
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const nameCell = renderCell('name')
const statusCell = renderCell('status')
const actionsCell = renderCell('actions')
const priorityCell = renderCell('priority')
const weightCell = renderCell('weight')
const balanceCell = renderCell('balance')
const responseCell = renderCell('response_time')
const testCell = renderCell('test_time')
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
// In card view the enable/disable state is already conveyed by the inline
// power toggle, so the plain "Enabled"/"Disabled" badge is redundant. Keep
// only the informative states (e.g. auto-disabled, unknown) and tag rows.
const showStatusBadge =
isTagRow ||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
return (
<ChannelRowActionsLayoutContext.Provider value='card'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* Row 1: selection + type, with status badge + actions menu */}
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
{actionsCell}
</div>
</div>
{/* Body: left column (id/name + balance) paired with a right-aligned
column (priority/weight + response/test time). */}
<div className='flex items-start justify-between gap-3'>
{/* Left column */}
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
)}
{nameCell}
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>
{fieldLabels.balance}
</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</div>
</div>
</div>
{/* Right column (sits on the right, content left-aligned). A single
grid with content-sized columns keeps Priority/Weight and
Response/Last Tested aligned without wasting horizontal space. */}
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.test_time}
</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
{/* Last row: groups span the full width, showing every group (no label) */}
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='-ml-1.5 flex flex-wrap gap-1'>
{groups.map((g) => (
<GroupBadge
key={g}
group={g}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
</div>
</div>
</ChannelRowActionsLayoutContext.Provider>
)
}
/**
* Memoized so each card only re-renders when its own react-table row reference
* changes, instead of every card re-rendering whenever the parent table state
* (filters, pagination, sensitive toggle, etc.) updates.
*/
export const ChannelCard = memo(ChannelCardComponent)
@@ -0,0 +1,28 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createContext } from 'react'
/**
* Where channel row-derived controls are being rendered. Card view can tune
* compact display details while table view keeps the full desktop treatment.
*/
export type ChannelRowActionsLayout = 'table' | 'card'
export const ChannelRowActionsLayoutContext =
createContext<ChannelRowActionsLayout>('table')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useChannels } from './channels-provider'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import { ChannelTestDialog } from './dialogs/channel-test-dialog'
import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
import { EditTagDialog } from './dialogs/edit-tag-dialog'
import { FetchModelsDialog } from './dialogs/fetch-models-dialog'
import { MultiKeyManageDialog } from './dialogs/multi-key-manage-dialog'
import { OllamaModelsDialog } from './dialogs/ollama-models-dialog'
import { TagBatchEditDialog } from './dialogs/tag-batch-edit-dialog'
import { UpstreamUpdateDialog } from './dialogs/upstream-update-dialog'
import { ChannelMutateDrawer } from './drawers/channel-mutate-drawer'
export function ChannelsDialogs() {
const { open, setOpen, currentRow, upstream } = useChannels()
return (
<>
{/* Channel Create/Update Drawer */}
<ChannelMutateDrawer
open={open === 'create-channel' || open === 'update-channel'}
onOpenChange={(v) => !v && setOpen(null)}
currentRow={open === 'update-channel' ? currentRow : null}
/>
{/* Test Channel Dialog */}
<ChannelTestDialog
open={open === 'test-channel'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Balance Query Dialog */}
<BalanceQueryDialog
open={open === 'balance-query'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Fetch Models Dialog */}
<FetchModelsDialog
open={open === 'fetch-models'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Ollama Models Dialog */}
<OllamaModelsDialog
open={open === 'ollama-models'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Copy Channel Dialog */}
<CopyChannelDialog
open={open === 'copy-channel'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Multi-Key Management Dialog */}
<MultiKeyManageDialog
open={open === 'multi-key-manage'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Tag Batch Edit Dialog */}
<TagBatchEditDialog
open={open === 'tag-batch-edit'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Edit Tag Dialog */}
<EditTagDialog
open={open === 'edit-tag'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Upstream Model Update Dialog */}
<UpstreamUpdateDialog
open={upstream.showModal}
addModels={upstream.addModels}
removeModels={upstream.removeModels}
preferredTab={upstream.preferredTab}
confirmLoading={upstream.applyLoading}
onConfirm={upstream.applyUpdates}
onCancel={upstream.closeModal}
/>
</>
)
}
@@ -0,0 +1,330 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import {
Plus,
MoreHorizontal,
Settings2,
Trash2,
Tags,
TestTube,
DollarSign,
ListChecks,
SortAsc,
RefreshCw,
ArrowUpFromLine,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
handleDeleteAllDisabled,
handleFixAbilities,
handleTestAllChannels,
handleUpdateAllBalances,
} from '../lib'
import { useChannels } from './channels-provider'
export function ChannelsPrimaryButtons() {
const { t } = useTranslation()
const {
setOpen,
setCurrentRow,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
batchMode,
setBatchMode,
upstream,
} = useChannels()
const queryClient = useQueryClient()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showConsistencyDialog, setShowConsistencyDialog] = useState(false)
const [isRepairingConsistency, setIsRepairingConsistency] = useState(false)
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const handleTagModeToggle = (checked: boolean) => {
localStorage.setItem('enable-tag-mode', String(checked))
setEnableTagMode(checked)
}
const handleIdSortToggle = (checked: boolean) => {
localStorage.setItem('channels-id-sort', String(checked))
setIdSort(checked)
}
const handleBatchModeToggle = (checked: boolean) => {
setBatchMode(checked)
}
return (
<>
<div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' />
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer text-sm'
>
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<SortAsc className='text-muted-foreground h-4 w-4' />
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
{t('Sort by ID')}
</Label>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</div>
{/* Create Channel */}
<Tooltip>
<TooltipTrigger render={<span className='inline-flex' />}>
<Button
onClick={() => {
if (!canEditSensitive) return
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
disabled={!canEditSensitive}
>
<Plus className='h-4 w-4' />
<span className='max-sm:hidden'>{t('Create Channel')}</span>
<span className='sm:hidden'>{t('Create')}</span>
</Button>
</TooltipTrigger>
{!canEditSensitive && (
<TooltipContent>
{t('No permission to perform this action')}
</TooltipContent>
)}
</Tooltip>
{/* More Actions */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
<MoreHorizontal className='h-4 w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{/* Mobile-only: toggle switches */}
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
>
<ListChecks className='mr-2 h-4 w-4' />
{t('Batch Operations')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
>
<Tags className='mr-2 h-4 w-4' />
{t('Tag Mode')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={idSort}
onCheckedChange={handleIdSortToggle}
>
<SortAsc className='mr-2 h-4 w-4' />
{t('Sort by ID')}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator className='sm:hidden' />
<DropdownMenuItem
onClick={() => {
handleTestAllChannels(queryClient)
}}
>
{t('Test All Channels')}
<DropdownMenuShortcut>
<TestTube className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleUpdateAllBalances(queryClient)
}}
>
{t('Update All Balances')}
<DropdownMenuShortcut>
<DollarSign className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => upstream.detectAllUpdates()}
disabled={upstream.detectAllLoading}
>
{t('Detect All Upstream Updates')}
<DropdownMenuShortcut>
<RefreshCw className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => upstream.applyAllUpdates()}
disabled={upstream.applyAllLoading}
>
{t('Apply All Upstream Updates')}
<DropdownMenuShortcut>
<ArrowUpFromLine className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setShowConsistencyDialog(true)
}}
>
{t('Repair Channel Consistency')}
<DropdownMenuShortcut>
<Settings2 className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
if (!canEditSensitive) return
setShowDeleteDialog(true)
}}
disabled={!canEditSensitive}
className='text-destructive focus:text-destructive'
>
{t('Delete All Disabled')}
<DropdownMenuShortcut>
<Trash2 className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title={t('Delete All Disabled Channels?')}
desc={t(
'This will permanently delete all manually and automatically disabled channels. This action cannot be undone.'
)}
destructive
handleConfirm={() => {
if (!canEditSensitive) return
handleDeleteAllDisabled(queryClient, (_count) => {
// eslint-disable-next-line no-console
console.log(`Deleted ${_count} channels`)
})
setShowDeleteDialog(false)
}}
/>
<ConfirmDialog
open={showConsistencyDialog}
onOpenChange={setShowConsistencyDialog}
title={t('Repair channel consistency?')}
desc={t(
'This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?'
)}
confirmText={t('Repair')}
isLoading={isRepairingConsistency}
handleConfirm={async () => {
setIsRepairingConsistency(true)
try {
await handleFixAbilities(queryClient, (_result) => {
// eslint-disable-next-line no-console
console.log('Repair channel consistency result:', _result)
})
setShowConsistencyDialog(false)
} finally {
setIsRepairingConsistency(false)
}
}}
/>
</>
)
}
@@ -0,0 +1,151 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useQueryClient } from '@tanstack/react-query'
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
} from 'react'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
import { channelsQueryKeys } from '../lib'
import type { Channel } from '../types'
// ============================================================================
// Types
// ============================================================================
type DialogType =
| 'create-channel'
| 'update-channel'
| 'test-channel'
| 'balance-query'
| 'fetch-models'
| 'ollama-models'
| 'multi-key-manage'
| 'tag-batch-edit'
| 'edit-tag'
| 'copy-channel'
| null
type UpstreamUpdateState = ReturnType<typeof useChannelUpstreamUpdates>
type ChannelsContextType = {
open: DialogType
setOpen: (open: DialogType) => void
currentRow: Channel | null
setCurrentRow: (row: Channel | null) => void
currentTag: string | null
setCurrentTag: (tag: string | null) => void
enableTagMode: boolean
setEnableTagMode: (enabled: boolean) => void
idSort: boolean
setIdSort: (enabled: boolean) => void
batchMode: boolean
setBatchMode: (enabled: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
upstream: UpstreamUpdateState
}
// ============================================================================
// Context
// ============================================================================
const ChannelsContext = createContext<ChannelsContextType | undefined>(
undefined
)
// ============================================================================
// Provider
// ============================================================================
export function ChannelsProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState<DialogType>(null)
const [currentRow, setCurrentRow] = useState<Channel | null>(null)
const [currentTag, setCurrentTag] = useState<string | null>(null)
const [enableTagMode, setEnableTagMode] = useState(() => {
return localStorage.getItem('enable-tag-mode') === 'true'
})
const [idSort, setIdSort] = useState(() => {
return localStorage.getItem('channels-id-sort') === 'true'
})
const [batchMode, setBatchMode] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const queryClient = useQueryClient()
const refreshChannels = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKeys.all })
}, [queryClient])
const upstream = useChannelUpstreamUpdates(refreshChannels)
// useState setters are stable, so the context value only needs to change when
// an actual state value changes. Memoizing avoids handing every consumer
// (including all channel cards/cells) a brand-new object on each render.
const value = useMemo<ChannelsContextType>(
() => ({
open,
setOpen,
currentRow,
setCurrentRow,
currentTag,
setCurrentTag,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
batchMode,
setBatchMode,
sensitiveVisible,
setSensitiveVisible,
upstream,
}),
[
open,
currentRow,
currentTag,
enableTagMode,
idSort,
batchMode,
sensitiveVisible,
upstream,
]
)
return (
<ChannelsContext.Provider value={value}>
{children}
</ChannelsContext.Provider>
)
}
// ============================================================================
// Hook
// ============================================================================
export function useChannels() {
const context = useContext(ChannelsContext)
if (!context) {
throw new Error('useChannels must be used within ChannelsProvider')
}
return context
}
+495
View File
@@ -0,0 +1,495 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import type {
ColumnFiltersState,
OnChangeFn,
SortingState,
Row,
} from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
CHANNEL_STATUS,
CHANNEL_STATUS_OPTIONS,
} from '../constants'
import {
channelsQueryKeys,
aggregateChannelsByTag,
isTagAggregateRow,
getChannelTypeIcon,
getChannelTypeLabel,
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { ChannelCard } from './channel-card'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY = 'channels:column-visibility'
const CHANNELS_COLUMN_SIZING_STORAGE_KEY = 'channels:column-sizing'
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
const CHANNELS_STATUS_FILTER_STORAGE_KEY = 'channel-status-filter'
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
'id',
'name',
'priority',
'balance',
'response_time',
'test_time',
])
function isDisabledChannelRow(channel: Channel) {
return (
!isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
)
}
export function ChannelsTable() {
const { t } = useTranslation()
const {
enableTagMode,
idSort,
batchMode,
sensitiveVisible,
setSensitiveVisible,
} = useChannels()
const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
// URL state management
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: {
defaultPage: 1,
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
},
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{
columnId: 'status',
searchKey: 'status',
type: 'array',
deserialize: (value) => {
if (value !== undefined) return value
const stored = localStorage.getItem(
CHANNELS_STATUS_FILTER_STORAGE_KEY
)
return stored === 'enabled' || stored === 'disabled' ? [stored] : []
},
},
{ columnId: 'type', searchKey: 'type', type: 'array' },
{ columnId: 'group', searchKey: 'group', type: 'array' },
{ columnId: 'model', searchKey: 'model', type: 'string' },
],
})
const handleColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (
updater
) => {
onColumnFiltersChange((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
const status = next.find((f) => f.id === 'status')?.value as
| string[]
| undefined
localStorage.setItem(
CHANNELS_STATUS_FILTER_STORAGE_KEY,
status?.[0] ?? 'all'
)
return next
})
}
// Extract filters from column filters
const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter = useMemo(
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
[columnFilters]
)
const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const {
value: modelFilter,
inputValue: modelFilterInput,
onChange: onModelFilterInputChange,
onCompositionStart: onModelFilterCompositionStart,
onCompositionEnd: onModelFilterCompositionEnd,
resetInput: resetModelFilterInput,
} = useDebouncedColumnFilter({
columnFilters,
columnId: 'model',
onColumnFiltersChange,
})
// Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
const sortParams = useMemo(() => {
const activeSort = sorting[0]
if (
!activeSort ||
!CHANNEL_SORTABLE_COLUMNS.has(activeSort.id as ChannelSortBy)
) {
return {}
}
return {
sort_by: activeSort.id as ChannelSortBy,
sort_order: activeSort.desc ? 'desc' : 'asc',
} as const
}, [sorting])
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
setSorting((previous) => {
const next = typeof updater === 'function' ? updater(previous) : updater
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
return next
})
}
// Fetch groups for filter
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((g) => ({
label: g,
value: g,
})),
[groupsData]
)
// Fetch channels data
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
queryKey: channelsQueryKeys.list({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
}),
queryFn: async () => {
if (shouldSearch) {
return searchChannels({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
} else {
return getChannels({
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
...sortParams,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
}
},
placeholderData: (previousData) => previousData,
})
// Apply tag aggregation if tag mode is enabled
const channels = useMemo(() => {
const rawChannels = data?.data?.items || []
if (enableTagMode && rawChannels.length > 0) {
return aggregateChannelsByTag(rawChannels)
}
return rawChannels
}, [data, enableTagMode])
const totalCount = data?.data?.total || 0
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns({ enableSelection: batchMode })
// React Table instance
const { table } = useDataTable({
data: channels,
columns,
totalCount,
sorting,
initialColumnVisibility: {
models: false,
tag: false,
},
columnVisibilityStorageKey: CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY,
columnSizingStorageKey: isMobile
? false
: CHANNELS_COLUMN_SIZING_STORAGE_KEY,
columnFilters,
pagination,
globalFilter,
enableRowSelection: batchMode
? (row: Row<Channel>) => !isTagAggregateRow(row.original)
: false,
onSortingChange: handleSortingChange,
onColumnFiltersChange: handleColumnFiltersChange,
onPaginationChange,
onGlobalFilterChange,
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
withExpandedRowModel: true,
enableColumnResizing: !isMobile,
ensurePageInRange,
})
useEffect(() => {
if (!batchMode) {
table.resetRowSelection()
}
}, [batchMode, table])
// Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {}
const typeIds = Object.entries(counts)
.map(([type, count]) => ({
type: Number(type),
count: Number(count) || 0,
}))
.filter((item) => item.type > 0 && item.count > 0)
.sort((a, b) => {
const labelA = t(getChannelTypeLabel(a.type))
const labelB = t(getChannelTypeLabel(b.type))
return labelA.localeCompare(labelB)
})
const selectedType = typeFilter.find((value) => value !== 'all')
if (selectedType) {
const selectedTypeId = Number(selectedType)
const alreadyIncluded = typeIds.some(
(item) => item.type === selectedTypeId
)
if (selectedTypeId > 0 && !alreadyIncluded) {
typeIds.push({
type: selectedTypeId,
count: Number(counts[selectedType]) || 0,
})
}
}
const totalTypes = Object.values(counts).reduce(
(sum, count) => sum + (Number(count) || 0),
0
)
return [
{
label: 'All Types',
value: 'all',
count: totalTypes,
},
...typeIds.map((item) => {
const iconName = getChannelTypeIcon(item.type)
return {
label: getChannelTypeLabel(item.type),
value: String(item.type),
count: item.count,
iconNode: getLobeIcon(`${iconName}.Color`, 16),
}
}),
]
}, [t, typeCounts, typeFilter])
const groupFilterOptions = [
{ label: t('All Groups'), value: 'all' },
...groupOptions.map((option) => ({
...option,
label: sensitiveVisible ? option.label : '••••',
})),
]
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
emptyDescription={t(
'No channels available. Create your first channel to get started.'
)}
skeletonKeyPrefix='channel-skeleton'
enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row, { isSelected }) => (
<ChannelCard row={row} isSelected={isSelected} />
)}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
filters: [
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
],
preActions: (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
{sensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
),
}}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledChannelRow(row.original)) {
return undefined
}
if (isMobile) {
return DISABLED_ROW_MOBILE
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
/>
)
}
@@ -0,0 +1,291 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import {
handleBatchDelete,
handleBatchDisable,
handleBatchEnable,
handleBatchSetTag,
} from '../lib'
import type { Channel } from '../types'
interface DataTableBulkActionsProps<TData> {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showTagDialog, setShowTagDialog] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [tagValue, setTagValue] = useState('')
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
const id = (row.original as Channel).id
if (typeof id === 'number') {
ids.push(id)
}
return ids
}, [])
const handleClearSelection = () => {
table.resetRowSelection()
}
const handleEnableAll = () => {
handleBatchEnable(selectedIds, queryClient, handleClearSelection)
}
const handleDisableAll = () => {
handleBatchDisable(selectedIds, queryClient, handleClearSelection)
}
const handleDeleteAll = () => {
if (!canEditSensitive) return
handleBatchDelete(selectedIds, queryClient, () => {
setShowDeleteConfirm(false)
handleClearSelection()
})
}
const handleSetTag = () => {
handleBatchSetTag(selectedIds, tagValue || null, queryClient, () => {
setShowTagDialog(false)
setTagValue('')
handleClearSelection()
})
}
return (
<>
<BulkActionsToolbar table={table} entityName='channel'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='outline'
size='icon'
onClick={handleEnableAll}
className='size-8'
aria-label={t('Enable selected channels')}
title={t('Enable selected channels')}
/>
}
>
<Power />
<span className='sr-only'>{t('Enable selected channels')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Enable selected channels')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='outline'
size='icon'
onClick={handleDisableAll}
className='size-8'
aria-label={t('Disable selected channels')}
title={t('Disable selected channels')}
/>
}
>
<PowerOff />
<span className='sr-only'>{t('Disable selected channels')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Disable selected channels')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='outline'
size='icon'
onClick={() => setShowTagDialog(true)}
className='size-8'
aria-label={t('Set tag for selected channels')}
title={t('Set tag for selected channels')}
/>
}
>
<Tag />
<span className='sr-only'>
{t('Set tag for selected channels')}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Set tag for selected channels')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='destructive'
size='icon'
onClick={() => {
if (!canEditSensitive) return
setShowDeleteConfirm(true)
}}
aria-disabled={!canEditSensitive}
className={cn(
'size-8',
!canEditSensitive && 'cursor-not-allowed opacity-50'
)}
aria-label={t('Delete selected channels')}
title={
canEditSensitive
? t('Delete selected channels')
: t('No permission to perform this action')
}
/>
}
>
<Trash2 />
<span className='sr-only'>{t('Delete selected channels')}</span>
</TooltipTrigger>
<TooltipContent>
<p>
{canEditSensitive
? t('Delete selected channels')
: t('No permission to perform this action')}
</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog
open={showTagDialog}
onOpenChange={setShowTagDialog}
title={t('Set Tag')}
description={
<>
{t('Set a tag for')}
{selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => {
setShowTagDialog(false)
setTagValue('')
}}
>
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</>
}
>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Channels?')}
description={
<>
{t('Are you sure you want to delete')}
{selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</>
}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
>
{t('Cancel')}
</Button>
<Button
variant='destructive'
onClick={handleDeleteAll}
disabled={!canEditSensitive}
>
{t('Delete')}
</Button>
</>
}
>
{' '}
</Dialog>
</>
)
}
@@ -0,0 +1,403 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import type { Row } from '@tanstack/react-table'
import {
MoreHorizontal,
Boxes,
Pencil,
PlugZap,
Gauge,
DollarSign,
Download,
Copy,
Power,
PowerOff,
Key,
Trash2,
RefreshCw,
Loader2,
} from 'lucide-react'
import { useContext, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import { MODEL_FETCHABLE_TYPES } from '../constants'
import {
channelsQueryKeys,
handleDeleteChannel,
handleTestChannel,
handleToggleChannelStatus,
isChannelEnabled,
isMultiKeyChannel,
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
interface DataTableRowActionsProps {
row: Row<Channel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const layout = useContext(ChannelRowActionsLayoutContext)
const channel = row.original
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
const currentUser = useAuthStore((s) => s.auth.user)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const handleEdit = () => {
setCurrentRow(channel)
setOpen('update-channel')
}
const handleTest = () => {
setCurrentRow(channel)
setOpen('test-channel')
}
const handleDirectTest = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
setIsTesting(true)
try {
await handleTestChannel(channel.id, { channelName: channel.name }, () => {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
})
} finally {
setIsTesting(false)
}
}
const handleQueryBalance = () => {
setCurrentRow(channel)
setOpen('balance-query')
}
const handleFetchModels = () => {
setCurrentRow(channel)
setOpen('fetch-models')
}
const handleManageOllamaModels = () => {
setCurrentRow(channel)
setOpen('ollama-models')
}
const handleCopy = () => {
setCurrentRow(channel)
setOpen('copy-channel')
}
const handleManageKeys = () => {
setCurrentRow(channel)
setOpen('multi-key-manage')
}
const handleToggleStatus = async (
e?: React.MouseEvent<HTMLButtonElement>
) => {
e?.stopPropagation()
setIsTogglingStatus(true)
try {
await handleToggleChannelStatus(channel.id, channel.status, queryClient)
} finally {
setIsTogglingStatus(false)
}
}
let statusIcon = <Power className='size-4' />
if (isTogglingStatus) {
statusIcon = <Loader2 className='size-4 animate-spin' />
} else if (isEnabled) {
statusIcon = <PowerOff className='size-4' />
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
{layout === 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleTest()
}}
aria-label={t('Test Channel Connection')}
/>
}
>
<PlugZap className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{layout === 'card' && (
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
<DropdownMenuShortcut>
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
<DropdownMenuShortcut>
<DollarSign size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Fetch Models */}
<DropdownMenuItem onClick={handleFetchModels}>
{t('Fetch Models')}
<DropdownMenuShortcut>
<Download size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Detect Upstream Updates (only for fetchable channel types) */}
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
<DropdownMenuItem
onClick={() => {
const meta = parseUpstreamUpdateMeta(channel.settings)
if (
meta.pendingAddModels.length > 0 ||
meta.pendingRemoveModels.length > 0
) {
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
)
} else {
upstream.detectChannelUpdates(channel)
}
}}
>
{t('Upstream Updates')}
<DropdownMenuShortcut>
<RefreshCw size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Ollama Models (only for Ollama channels) */}
{channel.type === 4 && (
<DropdownMenuItem onClick={handleManageOllamaModels}>
{t('Manage Ollama Models')}
<DropdownMenuShortcut>
<Boxes size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem
disabled={!canEditSensitive}
onClick={canEditSensitive ? handleCopy : undefined}
>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{!canEditSensitive && (
<DropdownMenuItem disabled className='text-xs normal-case'>
{t('No permission to perform this action')}
</DropdownMenuItem>
)}
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
<DropdownMenuItem onClick={handleManageKeys}>
{t('Manage Keys')}
<DropdownMenuShortcut>
<Key size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
disabled={!canEditSensitive}
onSelect={(e) => {
e.preventDefault()
if (!canEditSensitive) return
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Channel')}
desc={t(
'Are you sure you want to delete channel "{{name}}"? This action cannot be undone.',
{ name: channel.name }
)}
confirmText={t('Delete')}
destructive
handleConfirm={() => {
if (!canEditSensitive) return
handleDeleteChannel(channel.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</div>
)
}
@@ -0,0 +1,118 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import type { Row } from '@tanstack/react-table'
import { Power, PowerOff, Pencil, Edit } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { handleEnableTagChannels, handleDisableTagChannels } from '../lib'
import type { Channel } from '../types'
import { useChannels } from './channels-provider'
interface DataTableTagRowActionsProps {
row: Row<Channel & { tag?: string }>
}
export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
const { t } = useTranslation()
const tag = row.original.tag
const { setOpen, setCurrentTag } = useChannels()
const queryClient = useQueryClient()
if (!tag) return null
const handleEnableAll = () => {
handleEnableTagChannels(tag, queryClient)
}
const handleDisableAll = () => {
handleDisableTagChannels(tag, queryClient)
}
const handleBatchEdit = () => {
setCurrentTag(tag)
setOpen('tag-batch-edit')
}
const handleEditTag = () => {
setCurrentTag(tag)
setOpen('edit-tag')
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleEditTag}
aria-label={t('Edit Tag')}
/>
}
>
<Edit />
</TooltipTrigger>
<TooltipContent>{t('Edit Tag')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
{/* Batch Edit */}
<DropdownMenuItem onClick={handleBatchEdit}>
{t('Batch Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Enable All */}
<DropdownMenuItem onClick={handleEnableAll}>
{t('Enable All')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Disable All */}
<DropdownMenuItem onClick={handleDisableAll}>
{t('Disable All')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, DollarSign } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
import {
CodexUsageDialog,
type CodexUsageDialogData,
} from './codex-usage-dialog'
type BalanceQueryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BalanceQueryDialog({
open,
onOpenChange,
}: BalanceQueryDialogProps) {
const { t } = useTranslation()
const { currentRow, setCurrentRow } = useChannels()
const queryClient = useQueryClient()
const [isQuerying, setIsQuerying] = useState(false)
const [balance, setBalance] = useState<number | null>(null)
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
null
)
const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null)
const isCodex = currentRow?.type === 57
const handleQueryCodexUsage = async () => {
const row = currentRow
if (!row) return
setIsQuerying(true)
try {
const res = await getCodexUsage(row.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch usage')
)
} finally {
setIsQuerying(false)
}
}
useEffect(() => {
if (!isCodex) return
if (!open) return
handleQueryCodexUsage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isCodex])
if (!currentRow) return null
const handleQueryBalance = async () => {
setIsQuerying(true)
try {
const response = await updateChannelBalance(currentRow.id)
if (response.success && response.balance !== undefined) {
const newBalance = response.balance
const now = Math.floor(Date.now() / 1000)
setBalance(newBalance)
setBalanceUpdatedTime(now)
toast.success(t('Balance updated successfully'))
// Update currentRow immediately with new balance and timestamp
setCurrentRow({
...currentRow,
balance: newBalance,
balance_updated_time: now,
})
// Invalidate queries to refresh the table
await queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
} else {
toast.error(response.message || t('Failed to query balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to query balance')
)
} finally {
setIsQuerying(false)
}
}
const handleClose = () => {
setBalance(null)
setBalanceUpdatedTime(null)
setCodexUsageResponse(null)
onOpenChange(false)
}
const formatBalance = (bal: number) =>
formatCurrencyFromUSD(bal, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
})
const formatDate = (timestamp: number) => {
if (!timestamp) return 'Never'
return formatTimestampToDate(timestamp)
}
if (isCodex) {
return (
<CodexUsageDialog
open={open}
onOpenChange={(v) => {
if (!v) handleClose()
}}
channelName={currentRow.name}
channelId={currentRow.id}
response={codexUsageResponse}
onRefresh={handleQueryCodexUsage}
isRefreshing={isQuerying}
/>
)
}
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<IconBadge tone='success' size='xs'>
<DollarSign />
</IconBadge>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
type CopyChannelDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CopyChannelDialog({
open,
onOpenChange,
}: CopyChannelDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const [suffix, setSuffix] = useState('_copy')
const [resetBalance, setResetBalance] = useState(true)
const [isCopying, setIsCopying] = useState(false)
if (!currentRow) return null
const handleCopy = async () => {
setIsCopying(true)
await handleCopyChannel(
currentRow.id,
{
suffix,
reset_balance: resetBalance,
},
queryClient,
() => {
onOpenChange(false)
setSuffix('_copy')
setResetBalance(true)
}
)
setIsCopying(false)
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCopying}
>
{t('Cancel')}
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,444 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import {
editTagChannels,
getTagModels,
getAllModels,
getGroups,
} from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
type EditTagDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
// Form state
const [newTag, setNewTag] = useState('')
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [customModel, setCustomModel] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
// Fetch tag models
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
queryKey: ['tag-models', currentTag],
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
enabled: open && !!currentTag,
})
// Fetch all available models
const { data: allModelsData } = useQuery({
queryKey: ['all-models'],
queryFn: getAllModels,
enabled: open,
})
// Fetch groups
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
enabled: open,
})
const availableModels =
allModelsData?.data?.map((m) => m.id).filter(Boolean) || []
const availableGroups = groupsData?.data || []
// Initialize form when tag changes
useEffect(() => {
if (open && currentTag) {
setNewTag(currentTag)
setModelMapping('')
setSelectedGroups([])
setCustomModel('')
// Load tag models
if (tagModelsData?.data) {
const models = tagModelsData.data.split(',').filter(Boolean)
setSelectedModels(models)
} else {
setSelectedModels([])
}
}
}, [open, currentTag, tagModelsData])
const handleAddCustomModel = () => {
if (!customModel.trim()) return
const modelsToAdd = customModel
.split(',')
.map((m) => m.trim())
.filter(Boolean)
.filter((m) => !selectedModels.includes(m))
if (modelsToAdd.length > 0) {
setSelectedModels([...selectedModels, ...modelsToAdd])
toast.success(
t('Added {{count}} model(s)', { count: modelsToAdd.length })
)
setCustomModel('')
} else {
toast.info(t('No new models to add'))
}
}
const handleRemoveModel = (model: string) => {
setSelectedModels(selectedModels.filter((m) => m !== model))
}
const handleToggleGroup = (group: string) => {
setSelectedGroups((prev) =>
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
)
}
const validateForm = () => {
// Validate model mapping if provided
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch {
toast.error(t('Model mapping must be valid JSON'))
return false
}
}
return true
}
const handleSubmit = async () => {
if (!currentTag) return
if (!validateForm()) return
// Check if anything changed
const hasChanges =
newTag !== currentTag ||
modelMapping.trim() ||
selectedModels.length > 0 ||
selectedGroups.length > 0
if (!hasChanges) {
toast.warning(t('No changes to save'))
return
}
setIsSubmitting(true)
try {
const params: Record<string, string | null> = { tag: currentTag }
if (newTag && newTag !== currentTag) {
params.new_tag = newTag || null
}
if (modelMapping.trim()) {
params.model_mapping = modelMapping
}
if (selectedModels.length > 0) {
params.models = selectedModels.join(',')
}
if (selectedGroups.length > 0) {
params.groups = selectedGroups.join(',')
}
const response = await editTagChannels(
params as unknown as TagOperationParams
)
if (response.success) {
toast.success(t('Tag updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
onOpenChange(false)
}
if (!currentTag) return null
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -0,0 +1,512 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
categorizeModelsWithRedirect,
normalizeModelName,
parseModelsString,
} from '../../lib'
import { useChannels } from '../channels-provider'
function normalizeModelNameList(models: readonly string[]): string[] {
return Array.from(
new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))
)
}
type FetchModelsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onModelsSelected?: (models: string[]) => void
redirectModels?: string[]
redirectSourceModels?: string[]
customFetcher?: () => Promise<string[]>
existingModelsOverride?: string[]
channelName?: string | null
}
export function FetchModelsDialog({
open,
onOpenChange,
onModelsSelected,
redirectModels = [],
redirectSourceModels = [],
customFetcher,
existingModelsOverride,
channelName,
}: FetchModelsDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const activeChannel = customFetcher ? null : currentRow
const queryClient = useQueryClient()
const [isFetching, setIsFetching] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [searchKeyword, setSearchKeyword] = useState('')
// Parse existing models
const existingModels = useMemo(
() =>
existingModelsOverride ?? parseModelsString(activeChannel?.models || ''),
[existingModelsOverride, activeChannel?.models]
)
// Categorize models with redirect models
const modelCategories = useMemo(
() => categorizeModelsWithRedirect(existingModels, redirectModels),
[existingModels, redirectModels]
)
const { classificationSet, redirectOnlySet } = modelCategories
const fetchedModelSet = useMemo(
() => new Set(normalizeModelNameList(fetchedModels)),
[fetchedModels]
)
// Source keys in model_mapping are aliases, not real upstream IDs, so we
// must skip them when computing "removed upstream" entries to avoid false
// positives.
const redirectSourceKeysSet = useMemo(
() => new Set(normalizeModelNameList(redirectSourceModels)),
[redirectSourceModels]
)
const removedModels = useMemo(() => {
const kw = searchKeyword.toLowerCase().trim()
return normalizeModelNameList(selectedModels).filter((model) => {
if (fetchedModelSet.has(model)) return false
if (redirectSourceKeysSet.has(model)) return false
if (!kw) return true
return model.toLowerCase().includes(kw)
})
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
useEffect(() => {
if (open && (activeChannel || customFetcher)) {
handleFetchModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeChannel?.id, customFetcher])
const handleFetchModels = async () => {
if (!activeChannel && !customFetcher) return
setIsFetching(true)
try {
if (customFetcher) {
const list = await customFetcher()
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
const response = await fetchUpstreamModels(activeChannel!.id)
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
toast.error(response.message || t('Failed to fetch models'))
setFetchedModels([])
}
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch models')
)
setFetchedModels([])
} finally {
setIsFetching(false)
}
}
const handleSave = async () => {
// If onModelsSelected callback is provided, use it (form filling mode)
if (onModelsSelected) {
onModelsSelected(selectedModels)
toast.success(t('Models filled to form'))
onOpenChange(false)
return
}
// Otherwise, directly save to API (standalone mode)
if (!activeChannel) return
setIsSaving(true)
try {
const modelsString = selectedModels.join(',')
const response = await updateChannel(activeChannel.id, {
models: modelsString,
})
if (response.success) {
toast.success(t('Models updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update models'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update models')
)
} finally {
setIsSaving(false)
}
}
const handleClose = () => {
setFetchedModels([])
setSelectedModels([])
setSearchKeyword('')
onOpenChange(false)
}
// Categorize models by common prefixes
const categorizeModels = (models: string[]) => {
const categories: Record<string, string[]> = {}
models.forEach((model) => {
let category = 'Other'
// Determine category based on model name
if (
model.toLowerCase().includes('gpt') ||
model.toLowerCase().includes('o1') ||
model.toLowerCase().includes('o3')
) {
category = 'OpenAI'
} else if (model.toLowerCase().includes('claude')) {
category = 'Anthropic'
} else if (model.toLowerCase().includes('gemini')) {
category = 'Gemini'
} else if (model.toLowerCase().includes('qwen')) {
category = 'Qwen'
} else if (model.toLowerCase().includes('deepseek')) {
category = 'DeepSeek'
} else if (model.toLowerCase().includes('glm')) {
category = 'Zhipu'
} else if (model.toLowerCase().includes('llama')) {
category = 'Meta'
} else if (model.toLowerCase().includes('mistral')) {
category = 'Mistral'
}
if (!categories[category]) {
categories[category] = []
}
categories[category].push(model)
})
return categories
}
// Filter models by search
const filteredModels = useMemo(() => {
if (!searchKeyword) return fetchedModels
return fetchedModels.filter((model) =>
model.toLowerCase().includes(searchKeyword.toLowerCase())
)
}, [fetchedModels, searchKeyword])
// Helper to check if a model is considered "existing" (in selected or redirect)
const isExistingModel = (model: string) =>
classificationSet.has(normalizeModelName(model))
// Separate new and existing models
const newModels = filteredModels.filter((m) => !isExistingModel(m))
const existingFilteredModels = filteredModels.filter((m) =>
isExistingModel(m)
)
const newModelsByCategory = categorizeModels(newModels)
const existingModelsByCategory = categorizeModels(existingFilteredModels)
// 厂商分类按 a-z 排序,Other 放最后,便于查找
const getSortedCategoryEntries = (
categories: Record<string, string[]>
): [string, string[]][] =>
Object.entries(categories).sort(([a], [b]) => {
if (a === 'Other') return 1
if (b === 'Other') return -1
return a.localeCompare(b, undefined, { sensitivity: 'base' })
})
const toggleModel = (model: string) => {
setSelectedModels((prev) =>
prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model]
)
}
const toggleCategory = (categoryModels: string[], isChecked: boolean) => {
setSelectedModels((prev) => {
if (isChecked) {
const newSelected = [...prev]
categoryModels.forEach((model) => {
if (!newSelected.includes(model)) {
newSelected.push(model)
}
})
return newSelected
} else {
return prev.filter((m) => !categoryModels.includes(m))
}
})
}
const isCategorySelected = (categoryModels: string[]) => {
return categoryModels.every((m) => selectedModels.includes(m))
}
const renderModelCategory = (
categoryName: string,
categoryModels: string[]
) => {
const allSelected = isCategorySelected(categoryModels)
return (
<Collapsible key={categoryName} defaultOpen>
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
<div className='flex items-center gap-2'>
<ChevronDown className='h-4 w-4' />
<span className='font-medium'>
{categoryName} ({categoryModels.length})
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
/ {categoryModels.length} selected
</span>
<Checkbox
checked={allSelected}
onCheckedChange={(checked) =>
toggleCategory(categoryModels, !!checked)
}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className='px-4 py-2'>
<div className='grid grid-cols-2 gap-2'>
{categoryModels.map((model) => (
<div key={model} className='flex items-center space-x-2'>
<Checkbox
id={model}
checked={selectedModels.includes(model)}
onCheckedChange={() => toggleModel(model)}
/>
<Label
htmlFor={model}
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip>
<TooltipTrigger
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
></TooltipTrigger>
<TooltipContent>
{t('From model redirect, not yet added to models list')}
</TooltipContent>
</Tooltip>
)}
</Label>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -0,0 +1,103 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
type MissingModelsConfirmationDialogProps = {
open: boolean
missingModels: string[]
onConfirm: (action: MissingModelsAction) => void
onOpenChange?: (open: boolean) => void
}
/**
* Confirmation dialog shown when models in model_mapping are missing from the models list
* Provides three options:
* 1. Cancel - Go back to edit
* 2. Submit - Submit anyway without adding missing models
* 3. Add - Automatically add missing models and submit
*/
export function MissingModelsConfirmationDialog({
open,
missingModels,
onConfirm,
onOpenChange,
}: MissingModelsConfirmationDialogProps) {
const { t } = useTranslation()
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
onConfirm('cancel')
}
onOpenChange?.(newOpen)
}
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Models not in list, may fail to invoke')}
</AlertDialogTitle>
<AlertDialogDescription
render={<div className='space-y-3 text-sm' />}
>
<div>
{t(
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
)}
</div>
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
{missingModels.join(', ')}
</div>
<div>
{t(
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex-col gap-2 sm:flex-row'>
<AlertDialogCancel onClick={() => onConfirm('cancel')}>
{t('Go back and edit')}
</AlertDialogCancel>
<Button variant='secondary' onClick={() => onConfirm('submit')}>
{t('Submit directly')}
</Button>
<AlertDialogAction onClick={() => onConfirm('add')}>
{t('Add and submit')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,485 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
getMultiKeyStatus,
enableMultiKey,
disableMultiKey,
deleteMultiKey,
enableAllMultiKeys,
disableAllMultiKeys,
deleteDisabledMultiKeys,
} from '../../api'
import { MULTI_KEY_FILTER_OPTIONS } from '../../constants'
import {
channelsQueryKeys,
formatTimestamp,
getMultiKeyStatusConfig,
getMultiKeyConfirmMessage,
isDestructiveAction,
} from '../../lib'
import type { KeyStatus, MultiKeyConfirmAction } from '../../types'
import { useChannels } from '../channels-provider'
import { StatisticsCard } from './multi-key-statistics-card'
import { MultiKeyTableRowActions } from './multi-key-table-row-actions'
type MultiKeyManageDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function MultiKeyManageDialog({
open,
onOpenChange,
}: MultiKeyManageDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
// Data state
const [isLoading, setIsLoading] = useState(false)
const [keys, setKeys] = useState<KeyStatus[]>([])
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [enabledCount, setEnabledCount] = useState(0)
const [manualDisabledCount, setManualDisabledCount] = useState(0)
const [autoDisabledCount, setAutoDisabledCount] = useState(0)
// UI state
const [statusFilter, setStatusFilter] = useState<number | null>(null)
const [confirmAction, setConfirmAction] =
useState<MultiKeyConfirmAction | null>(null)
const [isPerformingAction, setIsPerformingAction] = useState(false)
// Reset and load data when dialog opens
useEffect(() => {
if (open && currentRow) {
setCurrentPage(1)
setStatusFilter(null)
loadKeyStatus(1, pageSize, null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
const loadKeyStatus = async (
page: number = currentPage,
size: number = pageSize,
status: number | null = statusFilter
) => {
if (!currentRow) return
setIsLoading(true)
try {
const response = await getMultiKeyStatus(
currentRow.id,
page,
size,
status === null ? undefined : status
)
if (response.success && response.data) {
setKeys(response.data.keys || [])
setTotal(response.data.total || 0)
setCurrentPage(response.data.page || 1)
setPageSize(response.data.page_size || 10)
setTotalPages(response.data.total_pages || 0)
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
} else {
toast.error(response.message || t('Failed to load key status'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to load key status')
)
} finally {
setIsLoading(false)
}
}
const handleStatusFilterChange = (value: string) => {
const newFilter = value === 'all' ? null : parseInt(value)
setStatusFilter(newFilter)
setCurrentPage(1)
loadKeyStatus(1, pageSize, newFilter)
}
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage)
loadKeyStatus(newPage, pageSize)
}
const performAction = async () => {
if (!confirmAction || !currentRow) return
if (
!canEditSensitive &&
(confirmAction.type === 'delete' ||
confirmAction.type === 'delete-disabled')
) {
setConfirmAction(null)
return
}
setIsPerformingAction(true)
try {
const { type, keyIndex } = confirmAction
let response
// Execute the appropriate action
if (type === 'enable' && keyIndex !== undefined) {
response = await enableMultiKey(currentRow.id, keyIndex)
} else if (type === 'disable' && keyIndex !== undefined) {
response = await disableMultiKey(currentRow.id, keyIndex)
} else if (type === 'delete' && keyIndex !== undefined) {
response = await deleteMultiKey(currentRow.id, keyIndex)
} else if (type === 'enable-all') {
response = await enableAllMultiKeys(currentRow.id)
} else if (type === 'disable-all') {
response = await disableAllMultiKeys(currentRow.id)
} else if (type === 'delete-disabled') {
response = await deleteDisabledMultiKeys(currentRow.id)
}
if (response?.success) {
toast.success(response.message || t('Operation successful'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
// Reload data - reset to page 1 for bulk actions
const isBulkAction = type.includes('all') || type === 'delete-disabled'
if (isBulkAction) {
setCurrentPage(1)
loadKeyStatus(1, pageSize)
} else {
loadKeyStatus(currentPage, pageSize)
}
} else {
toast.error(response?.message || t('Operation failed'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Operation failed')
)
} finally {
setIsPerformingAction(false)
setConfirmAction(null)
}
}
const renderStatusBadge = (status: number) => {
const config = getMultiKeyStatusConfig(status)
return (
<StatusBadge
label={t(config.label)}
variant={config.variant}
showDot
copyable={false}
/>
)
}
const formatKeyTimestamp = (timestamp?: number) => {
if (!timestamp) return '-'
return formatTimestamp(timestamp)
}
if (!currentRow) return null
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<RefreshCw className='h-4 w-4' />
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => {
if (!canEditSensitive) return
setConfirmAction({ type: 'delete-disabled' })
}}
disabled={!canEditSensitive}
title={
canEditSensitive
? undefined
: t('No permission to perform this action')
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{!canEditSensitive && (
<p className='text-muted-foreground text-xs'>
{t('No permission to perform this action')}
</p>
)}
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<StaticDataTable
className='rounded-none border-0'
tableClassName='min-w-[800px]'
data={keys}
getRowKey={(key) => key.index}
columns={[
{
id: 'index',
header: t('Index'),
className: 'w-20',
cellClassName: 'font-mono text-sm',
cell: (key) => `#${key.index + 1}`,
},
{
id: 'status',
header: t('Status'),
className: 'w-32',
cell: (key) => renderStatusBadge(key.status),
},
{
id: 'reason',
header: t('Disabled Reason'),
className: 'min-w-[200px]',
cellClassName: 'max-w-xs truncate text-sm',
cell: (key) => key.reason || '-',
},
{
id: 'disabled-time',
header: t('Disabled Time'),
className: 'w-44',
cellClassName: 'text-muted-foreground text-sm',
cell: (key) => formatKeyTimestamp(key.disabled_time),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cell: (key) => (
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
canDelete={canEditSensitive}
onAction={setConfirmAction}
/>
),
},
]}
/>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirmation Dialog */}
<ConfirmDialog
open={confirmAction !== null}
onOpenChange={(open) => !open && setConfirmAction(null)}
title={t('Confirm Action')}
desc={t(getMultiKeyConfirmMessage(confirmAction))}
destructive={isDestructiveAction(confirmAction)}
isLoading={isPerformingAction}
handleConfirm={performAction}
/>
</>
)
}
@@ -0,0 +1,42 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
type StatisticsCardProps = {
label: string
count: number
total: number
}
export function StatisticsCard({ label, count, total }: StatisticsCardProps) {
const { t } = useTranslation()
return (
<div className='rounded-md border p-3'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{label}
</div>
<div className='flex items-baseline gap-2'>
<span className='text-foreground text-2xl font-semibold'>{count}</span>
<span className='text-muted-foreground text-sm'>
{t('of')} {total}
</span>
</div>
</div>
)
}
@@ -0,0 +1,76 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import type { MultiKeyConfirmAction } from '../../types'
type MultiKeyTableRowActionsProps = {
keyIndex: number
status: number
canDelete: boolean
onAction: (action: MultiKeyConfirmAction) => void
}
export function MultiKeyTableRowActions({
keyIndex,
status,
canDelete,
onAction,
}: MultiKeyTableRowActionsProps) {
const { t } = useTranslation()
const isEnabled = status === 1
return (
<div className='flex justify-end gap-2'>
{isEnabled ? (
<Button
variant='outline'
size='sm'
onClick={() => onAction({ type: 'disable', keyIndex })}
>
{t('Disable')}
</Button>
) : (
<Button
variant='outline'
size='sm'
onClick={() => onAction({ type: 'enable', keyIndex })}
>
{t('Enable')}
</Button>
)}
<Button
variant='destructive'
size='sm'
onClick={() => {
if (!canDelete) return
onAction({ type: 'delete', keyIndex })
}}
disabled={!canDelete}
title={
canDelete ? undefined : t('No permission to perform this action')
}
>
{t('Delete')}
</Button>
</div>
)
}
@@ -0,0 +1,610 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Download, Search } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { getFreshAuthHeaders } from '@/lib/api'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
fetchUpstreamModels,
updateChannel,
} from '../../api'
import { channelsQueryKeys, parseModelsString } from '../../lib'
import {
formatBytes,
normalizeOllamaModels,
resolveOllamaBaseUrl,
type OllamaModel,
type PullProgress,
} from '../../lib/ollama-utils'
import { useChannels } from '../channels-provider'
const CHANNEL_TYPE_OLLAMA = 4
export function OllamaModelsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { currentRow } = useChannels()
const isOllamaChannel = currentRow?.type === CHANNEL_TYPE_OLLAMA
const channelId = currentRow?.id
const [isFetching, setIsFetching] = useState(false)
const [models, setModels] = useState<OllamaModel[]>([])
const [selected, setSelected] = useState<string[]>([])
const [search, setSearch] = useState('')
const [pullName, setPullName] = useState('')
const [isPulling, setIsPulling] = useState(false)
const [pullProgress, setPullProgress] = useState<PullProgress | null>(null)
const pullAbortRef = useRef<AbortController | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const filteredModels = useMemo(() => {
if (!search.trim()) return models
const keyword = search.trim().toLowerCase()
return models.filter((m) => m.id.toLowerCase().includes(keyword))
}, [models, search])
const existingModels = useMemo(
() => parseModelsString(currentRow?.models ?? ''),
[currentRow?.models]
)
useEffect(() => {
if (!open) {
setModels([])
setSelected([])
setSearch('')
setPullName('')
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current?.abort()
pullAbortRef.current = null
return
}
if (open && isOllamaChannel && channelId) {
void fetchOllamaModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isOllamaChannel, channelId])
const fetchOllamaModels = useCallback(async () => {
if (!channelId) return
setIsFetching(true)
try {
let normalized: OllamaModel[] = []
let lastErr = ''
// 1) Prefer live fetch for Ollama if base_url is set (more accurate / supports unsaved changes)
const baseUrl = resolveOllamaBaseUrl(currentRow ?? null)
if (isOllamaChannel && baseUrl) {
try {
const payloadLive = await fetchModelsFromEndpoint({
base_url: baseUrl,
type: CHANNEL_TYPE_OLLAMA,
key: typeof currentRow?.key === 'string' ? currentRow.key : '',
})
if (payloadLive?.success) {
normalized = normalizeOllamaModels(payloadLive.data)
} else if (payloadLive?.message) {
lastErr = String(payloadLive.message)
}
} catch (err: unknown) {
lastErr = err instanceof Error ? err.message : ''
}
}
// 2) Fallback to server-side fetch by channelId
if (!normalized.length) {
const payload = await fetchUpstreamModels(Number(channelId))
if (payload?.success) {
normalized = normalizeOllamaModels(payload.data)
lastErr = ''
} else {
lastErr = String(payload?.message || '')
}
}
if (!normalized.length && lastErr) {
toast.error(lastErr || t('Failed to fetch models'))
}
setModels(normalized)
setSelected((prev) => {
if (!prev.length) return normalized.map((m) => m.id)
const stillAvailable = prev.filter((id) =>
normalized.some((m) => m.id === id)
)
return stillAvailable.length
? stillAvailable
: normalized.map((m) => m.id)
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to fetch models'))
setModels([])
} finally {
setIsFetching(false)
}
}, [channelId, currentRow, isOllamaChannel, t])
const toggleSelected = (modelId: string, checked: boolean) => {
setSelected((prev) => {
if (checked) return prev.includes(modelId) ? prev : [...prev, modelId]
return prev.filter((id) => id !== modelId)
})
}
const selectAllFiltered = () => {
setSelected((prev) => {
const next = new Set(prev)
filteredModels.forEach((m) => next.add(m.id))
return [...next]
})
}
const clearSelection = () => setSelected([])
const applySelection = async (mode: 'append' | 'replace') => {
if (!currentRow) return
if (!selected.length) {
toast.info(t('No models selected'))
return
}
const next =
mode === 'replace'
? [...new Set(selected)]
: [...new Set([...existingModels, ...selected])]
try {
const res = await updateChannel(currentRow.id, { models: next.join(',') })
if (res.success) {
toast.success(
mode === 'replace'
? t('Models updated successfully')
: t('Models appended successfully')
)
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} else {
toast.error(res.message || t('Failed to update models'))
}
} catch (err: unknown) {
toast.error(
err instanceof Error ? err.message : t('Failed to update models')
)
}
}
const pullModel = async () => {
if (!channelId) return
if (!pullName.trim()) {
toast.error(t('Please enter model name'))
return
}
if (!resolveOllamaBaseUrl(currentRow)) {
toast.error(t('Please set Ollama API Base URL first'))
return
}
pullAbortRef.current?.abort()
const controller = new AbortController()
pullAbortRef.current = controller
setIsPulling(true)
setPullProgress({ status: 'starting', completed: 0, total: 0 })
try {
const authHeaders = await getFreshAuthHeaders()
const response = await fetch('/api/channel/ollama/pull/stream', {
method: 'POST',
credentials: 'include',
headers: {
...authHeaders,
Accept: 'text/event-stream',
},
body: JSON.stringify({
channel_id: channelId,
model_name: pullName.trim(),
}),
signal: controller.signal,
})
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const eventData = line.slice(6)
if (!eventData) continue
if (eventData === '[DONE]') {
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
return
}
try {
const data = JSON.parse(eventData)
if (data?.status) {
setPullProgress(data)
} else if (data?.error) {
toast.error(String(data.error))
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
return
} else if (data?.message) {
toast.success(String(data.message))
setPullName('')
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
await fetchOllamaModels()
queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
return
}
} catch {
// ignore malformed events
}
}
}
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
await fetchOllamaModels()
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} catch (err: unknown) {
const isAbort =
typeof err === 'object' &&
err !== null &&
'name' in err &&
(err as { name?: unknown }).name === 'AbortError'
if (!isAbort) {
const msg = err instanceof Error ? err.message : ''
toast.error(t('Model pull failed: {{msg}}', { msg }))
}
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
}
}
const deleteModel = async (modelName: string) => {
if (!channelId) return
try {
setIsDeleting(true)
const payload = await deleteOllamaModel({
channel_id: Number(channelId),
model_name: modelName,
})
if (payload?.success) {
toast.success(t('Model deleted'))
await fetchOllamaModels()
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
setDeleteOpen(false)
setDeleteTarget(null)
} else {
toast.error(payload?.message || t('Failed to delete model'))
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to delete model'))
} finally {
setIsDeleting(false)
}
}
const close = () => {
pullAbortRef.current?.abort()
pullAbortRef.current = null
onOpenChange(false)
}
if (!open) return null
return (
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
<AlertDialog
open={deleteOpen}
onOpenChange={(v) => {
setDeleteOpen(v)
if (!v) setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('Delete model "{{name}}"? This cannot be undone.', {
name: deleteTarget || '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
disabled={isDeleting || !deleteTarget}
onClick={() => {
if (!deleteTarget) return
void deleteModel(deleteTarget)
}}
>
{isDeleting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertTriangle } from 'lucide-react'
import { lazy, Suspense, 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const Markdown = lazy(() =>
import('@/components/ui/markdown').then((module) => ({
default: module.Markdown,
}))
)
interface StatusCodeRiskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
detailItems: string[]
onConfirm: () => void
}
const CHECKLIST_KEYS = [
'High-risk status code retry risk check 1',
'High-risk status code retry risk check 2',
'High-risk status code retry risk check 3',
'High-risk status code retry risk check 4',
] as const
export function StatusCodeRiskDialog({
open,
onOpenChange,
detailItems,
onConfirm,
}: StatusCodeRiskDialogProps) {
const { t } = useTranslation()
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
const [confirmText, setConfirmText] = useState('')
const requiredText = t('High-risk status code retry confirmation text')
const allChecked = checkedItems.size === CHECKLIST_KEYS.length
const textMatches = confirmText.trim() === requiredText.trim()
const canConfirm = allChecked && textMatches
const handleConfirm = () => {
if (!canConfirm) return
setCheckedItems(new Set())
setConfirmText('')
onConfirm()
}
const handleCancel = () => {
setCheckedItems(new Set())
setConfirmText('')
onOpenChange(false)
}
const toggleCheck = (idx: number) => {
setCheckedItems((prev) => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx)
else next.add(idx)
return next
})
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button
variant='destructive'
disabled={!canConfirm}
onClick={handleConfirm}
>
{t('I confirm enabling high-risk retry')}
</Button>
</>
}
>
<div className='space-y-4'>
{open ? (
<div className='border-warning/40 bg-warning/5 rounded-lg border p-3 sm:p-4'>
<Suspense
fallback={
<div
aria-hidden='true'
className='bg-warning/10 h-32 animate-pulse rounded-md'
/>
}
>
<Markdown className='[&_h3]:text-warning text-sm [&_h3]:text-base'>
{t('High-risk status code retry risk disclaimer')}
</Markdown>
</Suspense>
</div>
) : null}
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,302 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, AlertCircle } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
getTagModels,
editTagChannels,
getAllModels,
getGroups,
} from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
import { ModelMappingEditor } from '../model-mapping-editor'
type TagBatchEditDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function TagBatchEditDialog({
open,
onOpenChange,
}: TagBatchEditDialogProps) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Form fields
const [newTag, setNewTag] = useState('')
const [models, setModels] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [groups, setGroups] = useState<string[]>([])
// Fetch available groups
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
const allGroups = new Set([...groupsData.data, ...groups])
return Array.from(allGroups).map((group) => ({
value: group,
label: group,
}))
}, [groupsData, groups])
useEffect(() => {
if (open && currentTag) {
loadTagData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentTag])
const loadTagData = async () => {
if (!currentTag) return
setIsLoading(true)
try {
// Fetch current tag models
const tagModelsResponse = await getTagModels(currentTag)
if (tagModelsResponse.success && tagModelsResponse.data) {
setModels(tagModelsResponse.data)
}
// Fetch all available models (for future use if needed)
const allModelsResponse = await getAllModels()
if (allModelsResponse.success && allModelsResponse.data) {
// Available models could be used for autocomplete in the future
}
// Initialize new tag with current tag name
setNewTag(currentTag)
} catch (_error: unknown) {
toast.error(
_error instanceof Error ? _error.message : t('Failed to load tag data')
)
} finally {
setIsLoading(false)
}
}
const handleSave = async () => {
if (!currentTag) return
// Validate model mapping JSON if provided
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch (_error) {
toast.error(t('Model mapping must be valid JSON'))
return
}
}
setIsSaving(true)
try {
const params: Record<string, string | undefined> = {
tag: currentTag,
}
if (newTag !== currentTag) {
params.new_tag = newTag || undefined
}
if (models.trim()) {
params.models = models
}
if (modelMapping.trim()) {
params.model_mapping = modelMapping
}
if (groups.length > 0) {
params.groups = groups.join(',')
}
// Check if there are any changes
if (Object.keys(params).length === 1) {
toast.warning(t('No changes made'))
return
}
const response = await editTagChannels(
params as unknown as TagOperationParams
)
if (response.success) {
toast.success(t('Tag updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
handleClose()
} else {
toast.error(response.message || t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
} finally {
setIsSaving(false)
}
}
const handleClose = () => {
setNewTag('')
setModels('')
setModelMapping('')
setGroups([])
onOpenChange(false)
}
if (!currentTag) return null
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -0,0 +1,292 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Search } from 'lucide-react'
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface UpstreamUpdateDialogProps {
open: boolean
addModels: string[]
removeModels: string[]
preferredTab: 'add' | 'remove'
confirmLoading: boolean
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
onCancel: () => void
}
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState(props.preferredTab)
const [searchAdd, setSearchAdd] = useState('')
const [searchRemove, setSearchRemove] = useState('')
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
() => new Set(props.addModels)
)
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
() => new Set(props.removeModels)
)
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
const filteredAdd = useMemo(
() =>
props.addModels.filter((m) =>
m.toLowerCase().includes(searchAdd.toLowerCase())
),
[props.addModels, searchAdd]
)
const filteredRemove = useMemo(
() =>
props.removeModels.filter((m) =>
m.toLowerCase().includes(searchRemove.toLowerCase())
),
[props.removeModels, searchRemove]
)
const toggleModel = (
model: string,
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const next = new Set(set)
if (next.has(model)) next.delete(model)
else next.add(model)
setter(next)
}
const toggleAllVisible = (
models: string[],
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const allSelected = models.every((m) => set.has(m))
const next = new Set(set)
if (allSelected) {
models.forEach((m) => next.delete(m))
} else {
models.forEach((m) => next.add(m))
}
setter(next)
}
const handleConfirm = () => {
const hasAdd = props.addModels.length > 0
const hasRemove = props.removeModels.length > 0
const selectedAddArr = Array.from(selectedAdd)
const selectedRemoveArr = Array.from(selectedRemove)
const anyAdd = selectedAddArr.length > 0
const anyRemove = selectedRemoveArr.length > 0
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
setPartialConfirmOpen(true)
return
}
props.onConfirm({
addModels: selectedAddArr,
removeModels: selectedRemoveArr,
})
}
return (
<>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={
props.confirmLoading ||
(props.addModels.length === 0 &&
props.removeModels.length === 0)
}
>
{t('Confirm')}
</Button>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</Dialog>
<ConfirmDialog
open={partialConfirmOpen}
onOpenChange={setPartialConfirmOpen}
title={t('Partial Submission')}
desc={t(
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
)}
handleConfirm={() => {
setPartialConfirmOpen(false)
props.onConfirm({
addModels: Array.from(selectedAdd),
removeModels: Array.from(selectedRemove),
})
}}
/>
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown, Settings } from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
type ChannelAdvancedSectionProps = {
children: ReactNode
open: boolean
onOpenChange: (open: boolean) => void
summary?: ReactNode
}
export function ChannelAdvancedSection(props: ChannelAdvancedSectionProps) {
const { t } = useTranslation()
return (
<Collapsible open={props.open} onOpenChange={props.onOpenChange}>
<CollapsibleTrigger
render={
<button
type='button'
className='hover:bg-muted/40 border-border/60 flex w-full items-center justify-between rounded-lg border px-3 py-3 text-left transition-colors'
aria-expanded={props.open}
/>
}
>
<div className='flex items-start gap-3'>
<span className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md'>
<Settings className='h-4 w-4' aria-hidden='true' />
</span>
<div className='flex flex-col gap-0.5'>
<div className='text-[13px] font-semibold'>
{t('Advanced Settings')}
</div>
<div className='text-muted-foreground text-xs'>
{props.summary ??
t(
'Request overrides, routing behavior, and upstream model automation'
)}
</div>
</div>
</div>
<ChevronDown
className={cn(
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
props.open && 'rotate-180'
)}
aria-hidden='true'
/>
</CollapsibleTrigger>
<CollapsibleContent className='mt-5 flex flex-col gap-5'>
{props.children}
</CollapsibleContent>
</Collapsible>
)
}
@@ -0,0 +1,46 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { KeyRound } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
} from '@/components/drawer-layout'
type ChannelApiAccessSectionProps = {
children: ReactNode
}
export function ChannelApiAccessSection(props: ChannelApiAccessSectionProps) {
const { t } = useTranslation()
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Credentials')}
description={t('Authentication')}
icon={<KeyRound className='h-4 w-4' aria-hidden='true' />}
iconTone='success'
/>
{props.children}
</SideDrawerSection>
)
}
@@ -0,0 +1,44 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { KeyRound } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
type ChannelAuthSectionProps = {
children: ReactNode
}
export function ChannelAuthSection(props: ChannelAuthSectionProps) {
const { t } = useTranslation()
return (
<div className='border-border/60 flex flex-col gap-3 border-t pt-4'>
<div className='flex items-center gap-2'>
<KeyRound
className='text-muted-foreground h-3.5 w-3.5'
aria-hidden='true'
/>
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
{t('Authentication')}
</h4>
</div>
{props.children}
</div>
)
}
@@ -0,0 +1,46 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Server } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
} from '@/components/drawer-layout'
type ChannelBasicSectionProps = {
children: ReactNode
}
export function ChannelBasicSection(props: ChannelBasicSectionProps) {
const { t } = useTranslation()
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Basic Information')}
description={t('Name, provider type, and availability.')}
icon={<Server className='h-4 w-4' aria-hidden='true' />}
iconTone='info'
/>
{props.children}
</SideDrawerSection>
)
}
@@ -0,0 +1,45 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
export function ChannelEditorLoadingState() {
const { t } = useTranslation()
return (
<div
className='border-border/60 flex flex-col gap-4 rounded-lg border p-4'
aria-live='polite'
>
<div>
<p className='text-sm font-medium'>{t('Loading channel details')}</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Please wait before editing to avoid overwriting saved values.')}
</p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
</div>
<Skeleton className='h-24 w-full' />
<Skeleton className='h-32 w-full' />
</div>
)
}
@@ -0,0 +1,46 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Boxes } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
} from '@/components/drawer-layout'
type ChannelModelsSectionProps = {
children: ReactNode
}
export function ChannelModelsSection(props: ChannelModelsSectionProps) {
const { t } = useTranslation()
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Models & Groups')}
description={t('Published models, groups, and model remapping rules.')}
icon={<Boxes className='h-4 w-4' aria-hidden='true' />}
iconTone='chart-4'
/>
{props.children}
</SideDrawerSection>
)
}
@@ -0,0 +1,24 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export * from './channel-advanced-section'
export * from './channel-api-access-section'
export * from './channel-auth-section'
export * from './channel-basic-section'
export * from './channel-editor-loading-state'
export * from './channel-models-section'
@@ -0,0 +1,361 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Code, Plus, Table, Trash2 } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
type ModelMappingEditorProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
sourceModelOptions?: string[]
targetModelOptions?: string[]
}
type MappingRow = {
id: string
from: string
to: string
}
const DUPLICATE_MAPPING_SENTINEL = '{ "duplicate_source_models": '
function getDuplicateSources(rows: MappingRow[]): string[] {
const seen = new Set<string>()
const duplicates = new Set<string>()
for (const row of rows) {
const source = row.from.trim()
if (!source) continue
if (seen.has(source)) {
duplicates.add(source)
} else {
seen.add(source)
}
}
return Array.from(duplicates)
}
export function ModelMappingEditor(props: ModelMappingEditorProps) {
const { t } = useTranslation()
const sourceListId = useId()
const targetListId = useId()
const [mode, setMode] = useState<'visual' | 'json'>('visual')
const [rows, setRows] = useState<MappingRow[]>([])
const [jsonValue, setJsonValue] = useState(props.value)
const [jsonError, setJsonError] = useState<string | null>(null)
const nextRowIdRef = useRef(0)
const duplicateSources = useMemo(() => getDuplicateSources(rows), [rows])
const createRowId = () => {
nextRowIdRef.current += 1
return `mapping-${nextRowIdRef.current}`
}
const parseJsonToRows = (json: string): boolean => {
try {
if (!json.trim()) {
setRows([])
setJsonError(null)
return true
}
const parsed = JSON.parse(json)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setJsonError(t('Model mapping must be a valid JSON object'))
return false
}
const entries = Object.entries(parsed)
const invalidValue = entries.find(([, to]) => typeof to !== 'string')
if (invalidValue) {
setJsonError(t('Model mapping values must be strings'))
return false
}
setRows((previousRows) => {
const remainingRows = [...previousRows]
return entries.map(([from, to], index) => {
const toString = String(to)
const existingIndex = remainingRows.findIndex(
(row) =>
row.from === from ||
(row.from === from && row.to === toString) ||
previousRows[index]?.id === row.id
)
if (existingIndex >= 0) {
const [existing] = remainingRows.splice(existingIndex, 1)
return {
id: existing.id,
from,
to: toString,
}
}
return {
id: createRowId(),
from,
to: toString,
}
})
})
setJsonError(null)
return true
} catch (_error) {
setJsonError(t('Model mapping must be valid JSON format'))
return false
}
}
// Parse JSON to rows when value changes externally
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setJsonValue(props.value)
parseJsonToRows(props.value)
}, [props.value])
const convertRowsToJson = (updatedRows: MappingRow[]): string => {
if (updatedRows.length === 0) {
return ''
}
const obj: Record<string, string> = {}
updatedRows.forEach((row) => {
if (row.from.trim()) {
obj[row.from.trim()] = row.to.trim()
}
})
return JSON.stringify(obj, null, 2)
}
const syncRows = (updatedRows: MappingRow[]) => {
setRows(updatedRows)
const duplicates = getDuplicateSources(updatedRows)
if (duplicates.length > 0) {
setJsonError(t('Duplicate source model mappings are not allowed'))
setJsonValue(DUPLICATE_MAPPING_SENTINEL)
props.onChange(DUPLICATE_MAPPING_SENTINEL)
return
}
const json = convertRowsToJson(updatedRows)
setJsonError(null)
setJsonValue(json)
props.onChange(json)
}
const handleAddRow = () => {
const newRow: MappingRow = {
id: createRowId(),
from: '',
to: '',
}
syncRows([...rows, newRow])
}
const handleDeleteRow = (id: string) => {
syncRows(rows.filter((row) => row.id !== id))
}
const handleRowChange = (
id: string,
field: 'from' | 'to',
newValue: string
) => {
const updatedRows = rows.map((row) =>
row.id === id ? { ...row, [field]: newValue } : row
)
syncRows(updatedRows)
}
const handleJsonChange = (newJson: string) => {
setJsonValue(newJson)
props.onChange(newJson)
parseJsonToRows(newJson)
}
const handleFillTemplate = () => {
const template = JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
setJsonValue(template)
props.onChange(template)
parseJsonToRows(template)
}
const handleModeChange = (nextMode: string) => {
if (nextMode !== 'visual' && nextMode !== 'json') return
if (nextMode === 'json') {
const duplicates = getDuplicateSources(rows)
if (duplicates.length === 0) {
const json = convertRowsToJson(rows)
setJsonValue(json)
props.onChange(json)
}
setMode('json')
return
}
parseJsonToRows(jsonValue)
setMode('visual')
}
return (
<div className='space-y-2'>
<Tabs value={mode} onValueChange={handleModeChange} className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<TabsList>
<TabsTrigger value='visual'>
<Table className='h-4 w-4' aria-hidden='true' />
{t('Visual')}
</TabsTrigger>
<TabsTrigger value='json'>
<Code className='h-4 w-4' aria-hidden='true' />
{t('JSON')}
</TabsTrigger>
</TabsList>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
onClick={handleFillTemplate}
disabled={props.disabled}
>
{t('Fill Template')}
</Button>
</div>
{jsonError && (
<Alert variant='destructive'>
<AlertDescription>{jsonError}</AlertDescription>
</Alert>
)}
{duplicateSources.length > 0 && (
<Alert>
<AlertDescription>
{t('Duplicate source model(s): {{models}}', {
models: duplicateSources.join(', '),
})}
</AlertDescription>
</Alert>
)}
<TabsContent value='visual' className='space-y-2'>
{rows.length > 0 ? (
<div className='space-y-2'>
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
<div>{t('Original Model')}</div>
<div>{t('Replacement Model')}</div>
<div className='w-10'></div>
</div>
{rows.map((row) => (
<div
key={row.id}
className='grid grid-cols-[1fr_1fr_auto] gap-2'
>
<Input
value={row.from}
onChange={(e) =>
handleRowChange(row.id, 'from', e.target.value)
}
placeholder='gpt-3.5-turbo'
disabled={props.disabled}
list={sourceListId}
/>
<Input
value={row.to}
onChange={(e) =>
handleRowChange(row.id, 'to', e.target.value)
}
placeholder='gpt-3.5-turbo-0125'
disabled={props.disabled}
list={targetListId}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => handleDeleteRow(row.id)}
disabled={props.disabled}
className='h-10 w-10'
aria-label={t('Delete mapping')}
>
<Trash2 className='h-4 w-4' aria-hidden='true' />
</Button>
</div>
))}
</div>
) : (
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
{t(
'No model mappings configured. Click "Add Mapping" to get started.'
)}
</div>
)}
<Button
type='button'
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={props.disabled}
className='w-full'
>
<Plus className='mr-2 h-4 w-4' />
{t('Add Mapping')}
</Button>
</TabsContent>
<TabsContent value='json'>
<Textarea
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={t('{"original-model": "replacement-model"}')}
disabled={props.disabled}
rows={8}
className={cn(
'font-mono text-sm',
jsonError && 'border-destructive'
)}
aria-invalid={Boolean(jsonError)}
/>
</TabsContent>
</Tabs>
{props.sourceModelOptions && props.sourceModelOptions.length > 0 && (
<datalist id={sourceListId}>
{props.sourceModelOptions.map((model) => (
<option key={model} value={model} />
))}
</datalist>
)}
{props.targetModelOptions && props.targetModelOptions.length > 0 && (
<datalist id={targetListId}>
{props.targetModelOptions.map((model) => (
<option key={model} value={model} />
))}
</datalist>
)}
</div>
)
}
@@ -0,0 +1,197 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Minus, Plus } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface NumericSpinnerInputProps {
value: number | null | undefined
onChange: (value: number) => void
min?: number
max?: number
step?: number
disabled?: boolean
className?: string
label?: string
}
export function NumericSpinnerInput({
value,
onChange,
min = 0,
max,
step = 1,
disabled = false,
className,
label,
}: NumericSpinnerInputProps) {
const [localValue, setLocalValue] = useState(String(value ?? 0))
const [editing, setEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editing) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLocalValue(String(value ?? 0))
}
}, [value, editing])
const clamp = (v: number) => {
let result = v
if (min !== undefined) result = Math.max(min, result)
if (max !== undefined) result = Math.min(max, result)
return result
}
const handleIncrement = (e: React.MouseEvent) => {
e.stopPropagation()
if (disabled) return
const next = clamp((Number(localValue) || 0) + step)
setLocalValue(String(next))
onChange(next)
}
const handleDecrement = (e: React.MouseEvent) => {
e.stopPropagation()
if (disabled) return
const next = clamp((Number(localValue) || 0) - step)
setLocalValue(String(next))
onChange(next)
}
const handleStartEdit = () => {
if (disabled) return
setEditing(true)
requestAnimationFrame(() => inputRef.current?.select())
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === '' || raw === '-') {
setLocalValue(raw)
return
}
if (!/^-?\d+$/.test(raw)) return
setLocalValue(raw)
}
const commitValue = () => {
setEditing(false)
const num = Number(localValue)
if (isNaN(num) || localValue === '' || localValue === '-') {
setLocalValue(String(value ?? 0))
return
}
const clamped = clamp(num)
setLocalValue(String(clamped))
if (clamped !== (value ?? 0)) {
onChange(clamped)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
commitValue()
} else if (e.key === 'Escape') {
setEditing(false)
setLocalValue(String(value ?? 0))
}
}
const atMin = min !== undefined && Number(localValue) <= min
const atMax = max !== undefined && Number(localValue) >= max
return (
<div className={cn('inline-flex items-center', className)}>
{label && (
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
)}
<div
className={cn(
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
!disabled && 'hover:bg-muted/60',
editing && 'bg-muted/60 ring-primary/30 ring-1'
)}
>
<button
type='button'
tabIndex={-1}
aria-label='Decrement'
onClick={handleDecrement}
disabled={disabled || atMin}
className={cn(
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-l-md transition-colors',
!disabled &&
!atMin &&
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
(disabled || atMin) && 'group-hover/spinner:opacity-30'
)}
>
<Minus className='size-3' />
</button>
{editing ? (
<input
ref={inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
onBlur={commitValue}
onKeyDown={handleKeyDown}
className='h-7 w-10 bg-transparent text-center font-mono text-sm outline-none'
autoFocus
/>
) : (
<button
type='button'
onClick={handleStartEdit}
disabled={disabled}
title={localValue}
className={cn(
'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center font-mono text-sm tabular-nums',
disabled && 'cursor-default opacity-50'
)}
>
{localValue}
</button>
)}
<button
type='button'
tabIndex={-1}
aria-label='Increment'
onClick={handleIncrement}
disabled={disabled || atMax}
className={cn(
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-r-md transition-colors',
!disabled &&
!atMax &&
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
(disabled || atMax) && 'group-hover/spinner:opacity-30'
)}
>
<Plus className='size-3' />
</button>
</div>
</div>
)
}
+398
View File
@@ -0,0 +1,398 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// ============================================================================
// Channel Types (from constant/channel.go)
// All label/name values are i18n keys; use t(value) when displaying.
// ============================================================================
export const CHANNEL_TYPES = {
0: 'Unknown',
1: 'OpenAI',
2: 'MjProxy',
3: 'Azure',
4: 'Ollama',
5: 'MjProxyPlus',
// 6: 'OpenAIMax',
7: 'OhMyGPT',
8: 'Custom',
// 9: 'AILS',
// 10: 'AI Proxy',
// 11: 'PaLM',
// 12: 'API2GPT',
// 13: 'AIGC2D',
14: 'Anthropic',
15: 'Baidu',
16: 'Zhipu',
17: 'Ali',
18: 'Xunfei',
19: '360',
20: 'OpenRouter',
// 21: 'AI Proxy Library',
22: 'FastGPT',
23: 'Tencent',
24: 'Gemini',
25: 'Moonshot',
26: 'Zhipu V4',
27: 'Perplexity',
31: 'LingYiWanWu',
33: 'AWS',
34: 'Cohere',
35: 'MiniMax',
36: 'SunoAPI',
37: 'Dify',
38: 'Jina',
39: 'Cloudflare',
40: 'SiliconFlow',
41: 'Vertex AI',
42: 'Mistral',
43: 'DeepSeek',
44: 'MokaAI',
45: 'VolcEngine',
46: 'Baidu V2',
47: 'Xinference',
48: 'xAI',
49: 'Coze',
50: 'Kling',
51: 'Jimeng',
52: 'Vidu',
53: 'Submodel',
54: 'DoubaoVideo',
55: 'Sora',
56: 'Replicate',
57: 'ChatGPT Subscription (Codex)',
58: 'Advanced Custom',
} as const
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46,
23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36,
50, 51, 52, 53, 54, 55, 56,
]
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
const ordered: { value: number; label: string }[] = []
const seen = new Set<number>()
for (const id of CHANNEL_TYPE_DISPLAY_ORDER) {
const label = CHANNEL_TYPES[id as keyof typeof CHANNEL_TYPES]
if (label) {
ordered.push({ value: id, label })
seen.add(id)
}
}
for (const [key, label] of Object.entries(CHANNEL_TYPES)) {
const id = Number(key)
if (id !== 0 && !seen.has(id)) {
ordered.push({ value: id, label })
}
}
return ordered
})()
// ============================================================================
// Channel Status (label values are i18n keys; use t(config.label) in components)
// ============================================================================
export const CHANNEL_STATUS = {
UNKNOWN: 0,
ENABLED: 1,
MANUAL_DISABLED: 2,
AUTO_DISABLED: 3,
} as const
export const CHANNEL_STATUS_LABELS = {
[CHANNEL_STATUS.UNKNOWN]: 'Unknown',
[CHANNEL_STATUS.ENABLED]: 'Enabled',
[CHANNEL_STATUS.MANUAL_DISABLED]: 'Disabled',
[CHANNEL_STATUS.AUTO_DISABLED]: 'Auto Disabled',
} as const
export const CHANNEL_STATUS_OPTIONS = [
{ value: 'all', label: 'All Status' },
{ value: 'enabled', label: 'Enabled' },
{ value: 'disabled', label: 'Disabled' },
] as const
export const CHANNEL_STATUS_CONFIG = {
[CHANNEL_STATUS.UNKNOWN]: {
variant: 'neutral' as const,
label: 'Unknown',
},
[CHANNEL_STATUS.ENABLED]: {
variant: 'success' as const,
label: 'Enabled',
},
[CHANNEL_STATUS.MANUAL_DISABLED]: {
variant: 'danger' as const,
label: 'Disabled',
},
[CHANNEL_STATUS.AUTO_DISABLED]: {
variant: 'warning' as const,
label: 'Auto Disabled',
},
}
// ============================================================================
// Multi-Key Status
// ============================================================================
export const MULTI_KEY_STATUS = {
ENABLED: 1,
MANUAL_DISABLED: 2,
AUTO_DISABLED: 3,
} as const
export const MULTI_KEY_STATUS_LABELS = {
[MULTI_KEY_STATUS.ENABLED]: 'Enabled',
[MULTI_KEY_STATUS.MANUAL_DISABLED]: 'Manual Disabled',
[MULTI_KEY_STATUS.AUTO_DISABLED]: 'Auto Disabled',
} as const
export const MULTI_KEY_STATUS_CONFIG = {
[MULTI_KEY_STATUS.ENABLED]: {
variant: 'success' as const,
label: 'Enabled',
},
[MULTI_KEY_STATUS.MANUAL_DISABLED]: {
variant: 'neutral' as const,
label: 'Manual Disabled',
},
[MULTI_KEY_STATUS.AUTO_DISABLED]: {
variant: 'danger' as const,
label: 'Auto Disabled',
},
}
// ============================================================================
// Multi-Key Modes
// ============================================================================
export const MULTI_KEY_MODES = [
{ value: 'random', label: 'Random' },
{ value: 'polling', label: 'Polling' },
] as const
export const ADD_MODE_OPTIONS = [
{ value: 'single', label: 'Single Key' },
{ value: 'batch', label: 'Batch Add (one key per line)' },
{
value: 'multi_to_single',
label: 'Multi-Key Mode (multiple keys, one channel)',
},
] as const
// ============================================================================
// Multi-Key Management
// ============================================================================
export const MULTI_KEY_FILTER_OPTIONS = [
{ value: 'all', label: 'All Status' },
{ value: '1', label: 'Enabled' },
{ value: '2', label: 'Manual Disabled' },
{ value: '3', label: 'Auto Disabled' },
] as const
export const MULTI_KEY_CONFIRM_MESSAGES = {
DELETE:
'Are you sure you want to delete this key? This action cannot be undone.',
ENABLE: 'Enable this key?',
DISABLE: 'Disable this key?',
ENABLE_ALL: 'Are you sure you want to enable all keys?',
DISABLE_ALL: 'Are you sure you want to disable all enabled keys?',
DELETE_DISABLED:
'Are you sure you want to delete all auto-disabled keys? This action cannot be undone.',
} as const
// ============================================================================
// Auto Ban Options
// ============================================================================
export const AUTO_BAN_OPTIONS = [
{ value: 1, label: 'Enabled' },
{ value: 0, label: 'Disabled' },
] as const
// ============================================================================
// Error / Success Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying)
// ============================================================================
export const ERROR_MESSAGES = {
REQUIRED_NAME: 'Channel name is required',
REQUIRED_TYPE: 'Channel type is required',
REQUIRED_KEY: 'API key is required',
REQUIRED_MODELS: 'Models are required',
REQUIRED_GROUP: 'Group is required',
INVALID_JSON: 'Invalid JSON format',
INVALID_MODEL_MAPPING: 'Invalid model mapping format',
CREATE_FAILED: 'Failed to create channel',
UPDATE_FAILED: 'Failed to update channel',
DELETE_FAILED: 'Failed to delete channel',
TEST_FAILED: 'Failed to test channel',
BALANCE_QUERY_FAILED: 'Failed to query balance',
FETCH_MODELS_FAILED: 'Failed to fetch models',
} as const
export const SUCCESS_MESSAGES = {
CREATED: 'Channel created successfully',
UPDATED: 'Channel updated successfully',
DELETED: 'Channel deleted successfully',
ENABLED: 'Channel enabled successfully',
DISABLED: 'Channel disabled successfully',
TESTED: 'Channel test completed',
BALANCE_QUERIED: 'Balance queried successfully',
MODELS_FETCHED: 'Models fetched successfully',
COPIED: 'Channel copied successfully',
TAG_SET: 'Tag set successfully',
BATCH_DELETED: 'Channels deleted successfully',
} as const
// ============================================================================
// Default Values
// ============================================================================
export const DEFAULT_PAGE_SIZE = 20
export const DEFAULT_CHANNEL_VALUES = {
name: '',
type: 0,
base_url: '',
key: '',
models: '',
group: 'default',
status: CHANNEL_STATUS.ENABLED,
priority: 0,
weight: 0,
auto_ban: 1,
remark: '',
} as const
// ============================================================================
// Table Configuration
// ============================================================================
export const CHANNELS_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
// ============================================================================
// Sort Options (label values are i18n keys)
// ============================================================================
export const SORT_OPTIONS = [
{ value: 'priority', label: 'Priority (Default)' },
{ value: 'id', label: 'ID' },
{ value: 'name', label: 'Name' },
{ value: 'balance', label: 'Balance' },
{ value: 'response_time', label: 'Response Time' },
] as const
// ============================================================================
// Balance Display
// ============================================================================
export const BALANCE_THRESHOLDS = {
LOW: 1,
MEDIUM: 10,
HIGH: 100,
} as const
// ============================================================================
// Response Time Thresholds (in ms)
// ============================================================================
export const RESPONSE_TIME_THRESHOLDS = {
EXCELLENT: 500,
GOOD: 1000,
FAIR: 2000,
POOR: 5000,
} as const
export const RESPONSE_TIME_CONFIG = {
EXCELLENT: { variant: 'success' as const, label: 'Excellent' },
GOOD: { variant: 'success' as const, label: 'Good' },
FAIR: { variant: 'warning' as const, label: 'Fair' },
POOR: { variant: 'danger' as const, label: 'Poor' },
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
} as const
// ============================================================================
// Field Hints and Placeholders (i18n keys; use t() when displaying)
// ============================================================================
export const FIELD_PLACEHOLDERS = {
NAME: 'e.g., OpenAI GPT-4 Production',
BASE_URL: 'Leave empty to use default',
KEY: 'API Key (one per line for batch mode)',
MODELS: 'Comma-separated model names, e.g., gpt-4,gpt-3.5-turbo',
GROUP: 'Please Select user groups that can access this channel.',
MODEL_MAPPING: '{"request_model": "actual_model"}',
TEST_MODEL: 'Model to use for testing',
TAG: 'Optional tag for grouping channels',
REMARK: 'Optional notes about this channel',
PARAM_OVERRIDE: '{"temperature": 0.7}',
HEADER_OVERRIDE: '{"X-Custom-Header": "value"}',
STATUS_CODE_MAPPING: '{"400": "500"}',
} as const
export const FIELD_DESCRIPTIONS = {
NAME: 'Friendly name to identify this channel',
TYPE: 'Provider type (OpenAI, Anthropic, etc.)',
BASE_URL: 'Custom API base URL. Leave empty to use provider default.',
KEY: 'API key from the provider',
MODELS:
'List of models supported by this channel. Use comma to separate multiple models.',
GROUP: 'User groups that can access this channel. ',
MODEL_MAPPING:
'Map request model names to actual provider model names (JSON format)',
PRIORITY: 'Higher priority channels are selected first',
WEIGHT: 'Used for load balancing. Higher weight = more requests',
TEST_MODEL: 'Model to use when testing channel connectivity',
AUTO_BAN: 'Automatically disable channel on repeated failures',
STATUS_CODE_MAPPING: 'Map response status codes (JSON format)',
TAG: 'Group channels by tag for batch operations',
REMARK: 'Internal notes (not shown to users)',
SETTING: 'Channel-specific settings (JSON format)',
PARAM_OVERRIDE: 'Override request parameters (JSON format)',
HEADER_OVERRIDE: 'Override request headers (JSON format)',
MULTI_KEY_MODE: 'How to select keys: random or sequential polling',
BATCH_ADD: 'Create multiple channels from multiple keys',
OPENAI_ORG: 'OpenAI Organization ID (optional)',
} as const
// ============================================================================
// Channel Type Specific Configurations
// ============================================================================
export const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58,
])
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
15: 'Format: APIKey|SecretKey',
18: 'Format: APPID|APISecret|APIKey',
22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041',
23: 'Format: AppId|SecretId|SecretKey',
33: 'Format: Ak|Sk|Region',
50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)',
51: 'Format: Access Key ID|Secret Access Key',
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
}
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
3: 'For channels added after May 10, 2025, no need to remove "." from model names during deployment',
8: 'If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing',
37: 'Dify channels only support chatflow and agent, and agent does not support images',
}
@@ -0,0 +1,143 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import { createChannel, updateChannel } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
transformFormDataToCreatePayload,
transformFormDataToUpdatePayload,
type ChannelFormValues,
} from '../lib'
import type { Channel } from '../types'
type UseChannelMutateFormParams = {
currentRow?: Channel | null
isEditing: boolean
isMultiKeyChannel: boolean
onSuccess: () => void
}
const SENSITIVE_UPDATE_FIELDS = [
'type',
'key',
'base_url',
'openai_organization',
'param_override',
'header_override',
'setting',
'settings',
'other',
] satisfies (keyof Channel)[]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
if (!isRecord(error)) return undefined
const response = error.response
if (isRecord(response)) {
const data = response.data
if (isRecord(data)) {
const message = data.message
if (typeof message === 'string') return message
}
}
const message = error.message
if (typeof message === 'string') return message
return undefined
}
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const { t } = useTranslation()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
return useMutation({
mutationFn: async (data: ChannelFormValues): Promise<string> => {
if (props.isEditing && props.currentRow) {
const payload = transformFormDataToUpdatePayload(
data,
props.currentRow.id
)
if (!data.key?.trim()) {
delete payload.key
}
if (!canEditSensitive) {
for (const field of SENSITIVE_UPDATE_FIELDS) {
delete payload[field]
}
}
const payloadWithKeyMode =
canEditSensitive &&
props.isMultiKeyChannel &&
data.key?.trim() &&
data.key_mode
? {
...payload,
key_mode: data.key_mode,
}
: payload
const response = await updateChannel(
props.currentRow.id,
payloadWithKeyMode
)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
}
return SUCCESS_MESSAGES.UPDATED
}
const payload = transformFormDataToCreatePayload(data)
const response = await createChannel(payload)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
}
return SUCCESS_MESSAGES.CREATED
},
onSuccess: (messageKey) => {
toast.success(t(messageKey))
props.onSuccess()
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
},
})
}
@@ -0,0 +1,321 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useRef, useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api, type ApiRequestConfig } from '@/lib/api'
import { normalizeModelList } from '../lib/upstream-update-utils'
const upstreamUpdateRequestConfig = {
skipBusinessError: true,
skipErrorHandler: true,
} satisfies ApiRequestConfig
function getManualIgnoredModelCount(settings: unknown): number {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object')
parsed = settings as Record<string, unknown>
else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings)
} catch {
parsed = null
}
}
if (!parsed) return 0
return normalizeModelList(
(parsed.upstream_model_update_ignored_models as unknown[]) || []
).length
}
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const [channel, setChannel] = useState<{
id: number
[key: string]: unknown
} | null>(null)
const [addModels, setAddModels] = useState<string[]>([])
const [removeModels, setRemoveModels] = useState<string[]>([])
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
const [applyLoading, setApplyLoading] = useState(false)
const [detectAllLoading, setDetectAllLoading] = useState(false)
const [applyAllLoading, setApplyAllLoading] = useState(false)
const applyRef = useRef(false)
const detectRef = useRef(false)
const detectAllRef = useRef(false)
const applyAllRef = useRef(false)
const openModal = useCallback(
(
record: { id: number; [key: string]: unknown } | null,
pendingAdd: string[] = [],
pendingRemove: string[] = [],
tab: 'add' | 'remove' = 'add'
) => {
const normAdd = normalizeModelList(pendingAdd)
const normRemove = normalizeModelList(pendingRemove)
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
toast.info(t('No processable upstream model updates for this channel'))
return
}
setChannel(record)
setAddModels(normAdd)
setRemoveModels(normRemove)
setPreferredTab(tab)
setShowModal(true)
},
[t]
)
const closeModal = useCallback(() => {
setShowModal(false)
setChannel(null)
setAddModels([])
setRemoveModels([])
setPreferredTab('add')
}, [])
const applyUpdates = useCallback(
async ({
addModels: selectedAdd = [],
removeModels: selectedRemove = [],
}: {
addModels?: string[]
removeModels?: string[]
} = {}) => {
if (applyRef.current) return
if (!channel?.id) {
closeModal()
return
}
applyRef.current = true
setApplyLoading(true)
try {
const normSelectedAdd = normalizeModelList(selectedAdd)
const selectedAddSet = new Set(normSelectedAdd)
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
const res = await api.post(
'/api/channel/upstream_updates/apply',
{
id: channel.id,
add_models: normSelectedAdd,
ignore_models: ignoreModels,
remove_models: normalizeModelList(selectedRemove),
},
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Operation failed'))
return
}
toast.success(
t(
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
{
added: data?.added_models?.length || 0,
removed: data?.removed_models?.length || 0,
ignored: normalizeModelList(ignoreModels).length,
totalIgnored: getManualIgnoredModelCount(data?.settings),
}
)
)
closeModal()
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message || err?.message || t('Operation failed')
)
} finally {
applyRef.current = false
setApplyLoading(false)
}
},
[channel, addModels, closeModal, refresh, t]
)
const applyAllUpdates = useCallback(async () => {
if (applyAllRef.current) return
applyAllRef.current = true
setApplyAllLoading(true)
try {
const res = await api.post(
'/api/channel/upstream_updates/apply_all',
{},
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Batch processing failed'))
return
}
toast.success(
t(
'Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed',
{
channels: data?.processed_channels || 0,
added: data?.added_models || 0,
removed: data?.removed_models || 0,
fails: (data?.failed_channel_ids || []).length,
}
)
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message ||
err?.message ||
t('Batch processing failed')
)
} finally {
applyAllRef.current = false
setApplyAllLoading(false)
}
}, [refresh, t])
const detectChannelUpdates = useCallback(
async (ch: { id: number; [key: string]: unknown } | null) => {
if (detectRef.current || !ch?.id) return
detectRef.current = true
try {
const res = await api.post(
'/api/channel/upstream_updates/detect',
{ id: ch.id },
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Detection failed'))
return
}
toast.success(
t('Detection complete: {{add}} to add, {{remove}} to remove', {
add: data?.add_models?.length || 0,
remove: data?.remove_models?.length || 0,
})
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message || err?.message || t('Detection failed')
)
} finally {
detectRef.current = false
}
},
[refresh, t]
)
const detectAllUpdates = useCallback(async () => {
if (detectAllRef.current) return
detectAllRef.current = true
setDetectAllLoading(true)
try {
const res = await api.post(
'/api/channel/upstream_updates/detect_all',
{},
upstreamUpdateRequestConfig
)
const { success, message } = res.data || {}
if (!success) {
toast.error(message || t('Batch detection failed'))
return
}
toast.success(
t(
'Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.'
)
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message ||
err?.message ||
t('Batch detection failed')
)
} finally {
detectAllRef.current = false
setDetectAllLoading(false)
}
}, [refresh, t])
// Memoized so consumers (and the channels context value built from this) get
// a stable reference unless an actual field changes. Callbacks above are all
// useCallback-stable, so this only changes when relevant state changes.
return useMemo(
() => ({
showModal,
channel,
addModels,
removeModels,
preferredTab,
applyLoading,
detectAllLoading,
applyAllLoading,
openModal,
closeModal,
applyUpdates,
applyAllUpdates,
detectChannelUpdates,
detectAllUpdates,
}),
[
showModal,
channel,
addModels,
removeModels,
preferredTab,
applyLoading,
detectAllLoading,
applyAllLoading,
openModal,
closeModal,
applyUpdates,
applyAllUpdates,
detectChannelUpdates,
detectAllUpdates,
]
)
}
+107
View File
@@ -0,0 +1,107 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { Badge } from '@/components/ui/badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import { getChannelOps } from './api'
import { ChannelsDialogs } from './components/channels-dialogs'
import { ChannelsPrimaryButtons } from './components/channels-primary-buttons'
import { ChannelsProvider } from './components/channels-provider'
import { ChannelsTable } from './components/channels-table'
export function Channels() {
const { t } = useTranslation()
const isRoot = useAuthStore(
(state) => state.auth.user?.role === ROLE.SUPER_ADMIN
)
const channelOpsQuery = useQuery({
queryKey: ['channel-ops'],
queryFn: getChannelOps,
retry: false,
staleTime: 5 * 60 * 1000,
})
const retryTimes = channelOpsQuery.data?.data?.retry_times
const retryLabel =
typeof retryTimes === 'number' ? `${t('Max Retries')}: ${retryTimes}` : null
let retryBadge = null
if (retryLabel) {
retryBadge = isRoot ? (
<Tooltip>
<TooltipTrigger
render={
<Badge
variant='outline'
className='shrink-0 cursor-pointer'
aria-label={t('Retry Settings')}
render={
<Link
to='/system-settings/models/$section'
params={{ section: 'routing-reliability' }}
/>
}
/>
}
>
<span>{retryLabel}</span>
<Settings2 data-icon='inline-end' />
</TooltipTrigger>
<TooltipContent>
<p>{t('Retry Settings')}</p>
</TooltipContent>
</Tooltip>
) : (
<Badge variant='outline' className='shrink-0'>
{retryLabel}
</Badge>
)
}
return (
<ChannelsProvider>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>
<span className='flex min-w-0 items-center gap-2'>
<span className='truncate'>{t('Channels')}</span>
{retryBadge}
</span>
</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<ChannelsPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<ChannelsTable />
</SectionPageLayout.Content>
</SectionPageLayout>
<ChannelsDialogs />
</ChannelsProvider>
)
}
+874
View File
@@ -0,0 +1,874 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type {
AdvancedCustomAuthType,
AdvancedCustomConfig,
AdvancedCustomConverter,
AdvancedCustomRoute,
AdvancedCustomRouteAuth,
} from '../types'
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
export const ADVANCED_CUSTOM_MODEL_LIST_PATH = '/v1/models'
export const ADVANCED_CUSTOM_MODEL_LIST_LABEL = 'OpenAI Models'
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
value: AdvancedCustomConverter
label: string
triggerLabel: string
}> = [
{
value: 'none',
label: 'Native forwarding',
triggerLabel: 'Native forwarding',
},
{
value: 'anthropic_messages_to_openai_chat_completions',
label: 'Anthropic Messages to OpenAI Chat',
triggerLabel: 'To OpenAI Chat',
},
{
value: 'openai_chat_completions_to_anthropic_messages',
label: 'OpenAI Chat to Anthropic Messages',
triggerLabel: 'To Anthropic Messages',
},
{
value: 'openai_chat_completions_to_openai_responses',
label: 'OpenAI Chat to OpenAI Responses',
triggerLabel: 'To OpenAI Responses',
},
{
value: 'openai_responses_to_openai_chat_completions',
label: 'OpenAI Responses to OpenAI Chat',
triggerLabel: 'To OpenAI Chat',
},
{
value: 'openai_responses_to_gemini_generate_content',
label: 'OpenAI Responses to Gemini Generate Content',
triggerLabel: 'To Gemini Generate Content',
},
{
value: 'gemini_generate_content_to_openai_chat_completions',
label: 'Gemini Generate Content to OpenAI Chat',
triggerLabel: 'To OpenAI Chat',
},
{
value: 'openai_chat_completions_to_gemini_generate_content',
label: 'OpenAI Chat to Gemini Generate Content',
triggerLabel: 'To Gemini Generate Content',
},
]
export type AdvancedCustomAuthMode = 'default' | AdvancedCustomAuthType
export const ADVANCED_CUSTOM_AUTH_MODE_OPTIONS: Array<{
value: AdvancedCustomAuthMode
label: string
}> = [
{ value: 'default', label: 'Default Bearer' },
{ value: 'none', label: 'No Auth' },
{ value: 'header', label: 'Header' },
{ value: 'query', label: 'Query' },
]
export type AdvancedCustomIncomingPathOption = {
value: string
/** Official API route name. Render verbatim instead of passing it to i18n. */
label: string
}
export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOption[] =
[
{
value: '/v1/chat/completions',
label: 'OpenAI Chat',
},
{
value: '/v1/responses',
label: 'OpenAI Responses',
},
{
value: '/v1/responses/compact',
label: 'OpenAI Responses Compact',
},
{
value: ADVANCED_CUSTOM_MODEL_LIST_PATH,
label: ADVANCED_CUSTOM_MODEL_LIST_LABEL,
},
{
value: '/v1/embeddings',
label: 'OpenAI Embeddings',
},
{
value: '/v1/images/generations',
label: 'OpenAI Image Generations',
},
{
value: '/v1/images/edits',
label: 'OpenAI Image Edits',
},
{
value: '/v1/completions',
label: 'OpenAI Completions',
},
{
value: '/v1/audio/speech',
label: 'OpenAI Audio Speech',
},
{
value: '/v1/audio/transcriptions',
label: 'OpenAI Audio Transcriptions',
},
{
value: '/v1/audio/translations',
label: 'OpenAI Audio Translations',
},
{
value: '/v1/rerank',
label: 'OpenAI Rerank',
},
{
value: '/v1/realtime',
label: 'OpenAI Realtime',
},
{
value: '/v1/messages',
label: 'Claude Messages',
},
{
value: '/v1beta/models/{model}:generateContent',
label: 'Gemini Generate Content',
},
{
value: '/v1beta/models/{model}:embedContent',
label: 'Gemini Embed Content',
},
{
value: '/v1beta/models/{model}:batchEmbedContents',
label: 'Gemini Batch Embed Contents',
},
]
const ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS: Record<string, string> = {
'/v1/chat/completions': 'OpenAI Chat',
[ADVANCED_CUSTOM_MODEL_LIST_PATH]: ADVANCED_CUSTOM_MODEL_LIST_LABEL,
}
export type AdvancedCustomValidationError = {
message: string
routeIndex?: number
}
export type AdvancedCustomTemplateOption = {
value: string
label: string
config: AdvancedCustomConfig
}
export type AdvancedCustomConverterDefaults = {
upstream_path: string
auth?: AdvancedCustomRouteAuth
}
export const ADVANCED_CUSTOM_MODEL_REGEX_PREFIX = 're:'
export type AdvancedCustomModelRuleKind = 'exact' | 'regex'
const openAIChatPath = '/v1/chat/completions'
const openAIResponsesPath = '/v1/responses'
const claudeMessagesPath = '/v1/messages'
const geminiGenerateContentPath = '/v1beta/models/{model}:generateContent'
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
type: 'header',
name: 'Authorization',
value: 'Bearer {api_key}',
})
const apiKeyHeaderAuth = (): AdvancedCustomRouteAuth => ({
type: 'header',
name: 'x-api-key',
value: '{api_key}',
})
const geminiQueryAuth = (): AdvancedCustomRouteAuth => ({
type: 'query',
name: 'key',
value: '{api_key}',
})
export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
[
{
value: 'official_openai_chat',
label: 'Official OpenAI Chat',
config: {
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path: '/v1/chat/completions',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
},
},
{
value: 'official_openai_responses',
label: 'Official OpenAI Responses',
config: {
advanced_routes: [
{
incoming_path: '/v1/responses',
upstream_path: '/v1/responses',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
},
},
{
value: 'official_openai_embeddings',
label: 'Official OpenAI Embeddings',
config: {
advanced_routes: [
{
incoming_path: '/v1/embeddings',
upstream_path: '/v1/embeddings',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
},
},
{
value: 'official_openai_images',
label: 'Official OpenAI Images',
config: {
advanced_routes: [
{
incoming_path: '/v1/images/generations',
upstream_path: '/v1/images/generations',
converter: 'none',
auth: bearerHeaderAuth(),
},
{
incoming_path: '/v1/images/edits',
upstream_path: '/v1/images/edits',
converter: 'none',
auth: bearerHeaderAuth(),
},
],
},
},
{
value: 'official_claude_messages',
label: 'Official Claude Messages',
config: {
advanced_routes: [
{
incoming_path: '/v1/messages',
upstream_path: '/v1/messages',
converter: 'none',
auth: apiKeyHeaderAuth(),
},
],
},
},
{
value: 'official_gemini_native',
label: 'Official Gemini Native',
config: {
advanced_routes: [
{
incoming_path: '/v1beta/models/{model}:generateContent',
upstream_path: '/v1beta/models/{model}:generateContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:embedContent',
upstream_path: '/v1beta/models/{model}:embedContent',
converter: 'none',
auth: geminiQueryAuth(),
},
{
incoming_path: '/v1beta/models/{model}:batchEmbedContents',
upstream_path: '/v1beta/models/{model}:batchEmbedContents',
converter: 'none',
auth: geminiQueryAuth(),
},
],
},
},
{
value: 'official_gemini_from_openai_chat',
label: 'Official Gemini from OpenAI Chat',
config: {
advanced_routes: [
{
incoming_path: '/v1/chat/completions',
upstream_path: '/v1beta/models/{model}:generateContent',
converter: 'openai_chat_completions_to_gemini_generate_content',
auth: geminiQueryAuth(),
},
],
},
},
]
export function cloneAdvancedCustomConfig(
config: AdvancedCustomConfig
): AdvancedCustomConfig {
return JSON.parse(JSON.stringify(config)) as AdvancedCustomConfig
}
export function getAdvancedCustomTemplateConfig(
templateKey: string
): AdvancedCustomConfig {
const template =
ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find(
(option) => option.value === templateKey
) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]
return cloneAdvancedCustomConfig(template.config)
}
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
return {
incoming_path: openAIChatPath,
upstream_path: openAIChatPath,
converter: 'none',
}
}
export function createAdvancedCustomConfig(): AdvancedCustomConfig {
return {
advanced_routes: [createAdvancedCustomRoute()],
}
}
export function getAdvancedCustomUpstreamPathPlaceholder(
converter: AdvancedCustomConverter,
incomingPath = getDefaultAdvancedCustomIncomingPath(converter)
): string {
return getAdvancedCustomConverterDefaults(converter, incomingPath)
.upstream_path
}
export function getAdvancedCustomConverterDefaults(
converter: AdvancedCustomConverter,
incomingPath: string
): AdvancedCustomConverterDefaults {
const normalizedIncomingPath =
incomingPath.trim() || getDefaultAdvancedCustomIncomingPath(converter)
if (converter === 'none') {
return {
upstream_path: normalizedIncomingPath,
auth: getAdvancedCustomNativeAuth(normalizedIncomingPath),
}
}
if (
converter === 'anthropic_messages_to_openai_chat_completions' ||
converter === 'gemini_generate_content_to_openai_chat_completions' ||
converter === 'openai_responses_to_openai_chat_completions'
) {
return { upstream_path: openAIChatPath, auth: bearerHeaderAuth() }
}
if (converter === 'openai_chat_completions_to_openai_responses') {
return { upstream_path: openAIResponsesPath, auth: bearerHeaderAuth() }
}
if (converter === 'openai_chat_completions_to_anthropic_messages') {
return { upstream_path: claudeMessagesPath, auth: apiKeyHeaderAuth() }
}
if (
converter === 'openai_chat_completions_to_gemini_generate_content' ||
converter === 'openai_responses_to_gemini_generate_content'
) {
return { upstream_path: geminiGenerateContentPath, auth: geminiQueryAuth() }
}
return {
upstream_path: normalizedIncomingPath || openAIChatPath,
auth: getAdvancedCustomNativeAuth(normalizedIncomingPath),
}
}
function getAdvancedCustomNativeAuth(
incomingPath: string
): AdvancedCustomRouteAuth {
if (incomingPath === claudeMessagesPath) {
return apiKeyHeaderAuth()
}
if (
incomingPath.includes(':generateContent') ||
incomingPath.includes(':streamGenerateContent') ||
incomingPath.includes(':embedContent') ||
incomingPath.includes(':batchEmbedContents')
) {
return geminiQueryAuth()
}
return bearerHeaderAuth()
}
export function getAdvancedCustomIncomingPathOptions(
converter: AdvancedCustomConverter
): AdvancedCustomIncomingPathOption[] {
return ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter((option) =>
isConverterPathAllowed(option.value, converter)
)
}
export function getDefaultAdvancedCustomIncomingPath(
converter: AdvancedCustomConverter
): string {
return (
getAdvancedCustomIncomingPathOptions(converter)[0]?.value ||
'/v1/chat/completions'
)
}
export function isAdvancedCustomIncomingPathAllowed(
incomingPath: string,
converter: AdvancedCustomConverter
): boolean {
return isConverterPathAllowed(incomingPath, converter)
}
export function getAdvancedCustomConverterOptions(
incomingPath: string
): typeof ADVANCED_CUSTOM_CONVERTER_OPTIONS {
const normalizedIncomingPath = incomingPath.trim()
return ADVANCED_CUSTOM_CONVERTER_OPTIONS.filter(
(option) =>
option.value === 'none' ||
isConverterPathAllowed(normalizedIncomingPath, option.value)
)
}
export function getAdvancedCustomIncomingPathLabel(value: string): string {
return (
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.find(
(option) => option.value === value
)?.label || value
)
}
export function parseAdvancedCustomConfig(
value: string | undefined
): AdvancedCustomConfig | null {
if (!value?.trim()) return null
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
return normalizeAdvancedCustomConfig(parsed as AdvancedCustomConfig)
} catch {
return null
}
}
export function stringifyAdvancedCustomConfig(
config: AdvancedCustomConfig
): string {
return JSON.stringify(normalizeAdvancedCustomConfig(config), null, 2)
}
export function normalizeAdvancedCustomConfig(
config: AdvancedCustomConfig
): AdvancedCustomConfig {
const routes = Array.isArray(config.advanced_routes)
? config.advanced_routes.map(normalizeAdvancedCustomRoute)
: []
return {
advanced_routes: routes,
}
}
export function parseAdvancedCustomRouteModels(value: string): string[] {
return [
...new Set(
value
.split(',')
.map((model) => model.trim())
.filter(Boolean)
),
]
}
export function getAdvancedCustomModelRuleKind(
modelRule: string
): AdvancedCustomModelRuleKind {
return modelRule.startsWith(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX)
? 'regex'
: 'exact'
}
export function getAdvancedCustomRegexModelPattern(modelRule: string): string {
return modelRule.slice(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX.length)
}
export function validateAdvancedCustomConfig(
config: AdvancedCustomConfig | null
): AdvancedCustomValidationError | null {
if (!config) {
return { message: 'Advanced custom configuration is required' }
}
const normalized = normalizeAdvancedCustomConfig(config)
const routes = normalized.advanced_routes || []
if (routes.length === 0) {
return {
message: 'Advanced custom configuration requires at least one route',
}
}
const routeModelsByPath = new Map<
string,
{ catchAllIndex: number | null; models: Map<string, number> }
>()
let modelListRouteIndex: number | null = null
for (let index = 0; index < routes.length; index += 1) {
const route = routes[index]
const incomingPath = route.incoming_path?.trim() || ''
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
const converter = route.converter || 'none'
const routeModels = normalizeAdvancedCustomRouteModels(route.models)
if (!incomingPath) {
return { routeIndex: index, message: 'Incoming path is required' }
}
if (!incomingPath.startsWith('/')) {
return { routeIndex: index, message: 'Incoming path must start with /' }
}
if (incomingPath.includes('?')) {
return {
routeIndex: index,
message: 'Incoming path must not include query',
}
}
if (incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) {
if (modelListRouteIndex !== null) {
return {
routeIndex: index,
message: 'Only one OpenAI Models route is allowed',
}
}
modelListRouteIndex = index
if (routeModels.length > 0) {
return {
routeIndex: index,
message: 'OpenAI Models route does not support client model rules',
}
}
if (converter !== 'none') {
return {
routeIndex: index,
message: 'OpenAI Models route must use native forwarding',
}
}
if (upstreamPath.includes('{model}')) {
return {
routeIndex: index,
message: 'OpenAI Models upstream path must not contain {model}',
}
}
}
const routeModelsError = validateAdvancedCustomRouteModels(
index,
incomingPath,
routeModels,
routeModelsByPath
)
if (routeModelsError) {
return routeModelsError
}
if (!upstreamPath) {
return { routeIndex: index, message: 'Upstream path is required' }
}
if (!isFullHttpURLOrAbsolutePath(upstreamPath)) {
return {
routeIndex: index,
message: 'Upstream path must be a full URL or a path starting with /',
}
}
if (!isAdvancedCustomConverter(converter)) {
return { routeIndex: index, message: 'Converter is not registered' }
}
if (!isConverterPathAllowed(incomingPath, converter)) {
return {
routeIndex: index,
message: 'Converter does not match incoming path',
}
}
const authError = validateRouteAuth(route.auth)
if (authError) {
return { routeIndex: index, message: authError }
}
}
return null
}
export function hasValidAdvancedCustomModelListRoute(
config: AdvancedCustomConfig | null
): boolean {
if (!config || validateAdvancedCustomConfig(config)) return false
const normalized = normalizeAdvancedCustomConfig(config)
return (normalized.advanced_routes || []).some(
(route) => route.incoming_path?.trim() === ADVANCED_CUSTOM_MODEL_LIST_PATH
)
}
export function advancedCustomConfigUsesRelativeUpstreamPath(
config: AdvancedCustomConfig | null
): boolean {
if (!config) return false
const normalized = normalizeAdvancedCustomConfig(config)
return (normalized.advanced_routes || []).some((route) =>
getAdvancedCustomRouteUpstreamPath(route).startsWith('/')
)
}
export function getAdvancedCustomStats(value: string | undefined): {
routeCount: number
valid: boolean
routeTypeLabels: string[]
} {
const config = parseAdvancedCustomConfig(value)
if (!config) {
return { routeCount: 0, valid: false, routeTypeLabels: [] }
}
const normalized = normalizeAdvancedCustomConfig(config)
const routes = normalized.advanced_routes || []
const routeTypeLabels: string[] = []
const seenRouteTypeLabels = new Set<string>()
for (const route of routes) {
const label = getAdvancedCustomRouteSummaryLabel(route)
if (!label || seenRouteTypeLabels.has(label)) continue
routeTypeLabels.push(label)
seenRouteTypeLabels.add(label)
}
return {
routeCount: routes.length,
valid: validateAdvancedCustomConfig(normalized) === null,
routeTypeLabels,
}
}
export function getAdvancedCustomAuthMode(
route: AdvancedCustomRoute
): AdvancedCustomAuthMode {
return route.auth?.type || 'default'
}
export function buildAdvancedCustomAuth(
mode: AdvancedCustomAuthMode,
previousAuth: AdvancedCustomRouteAuth | undefined
): AdvancedCustomRouteAuth | undefined {
if (mode === 'default') return undefined
if (mode === 'none') return { type: 'none' }
if (mode === 'header') {
return {
type: 'header',
name: previousAuth?.name || 'Authorization',
value: previousAuth?.value || 'Bearer {api_key}',
}
}
return {
type: 'query',
name: previousAuth?.name || 'api_key',
value: previousAuth?.value || '{api_key}',
}
}
function normalizeAdvancedCustomRoute(
route: AdvancedCustomRoute
): AdvancedCustomRoute {
const nextRoute: AdvancedCustomRoute = {
incoming_path: route.incoming_path || '',
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
converter: route.converter || 'none',
}
const models = normalizeAdvancedCustomRouteModels(route.models)
if (models.length > 0) {
nextRoute.models = models
}
if (route.auth) {
nextRoute.auth = {
type: route.auth.type,
name: route.auth.name || '',
value: route.auth.value || '',
}
}
return nextRoute
}
function normalizeAdvancedCustomRouteModels(
models: string[] | undefined
): string[] {
if (!Array.isArray(models)) return []
return models.map((model) => model.trim()).filter(Boolean)
}
function validateAdvancedCustomRouteModels(
routeIndex: number,
incomingPath: string,
models: string[],
routeModelsByPath: Map<
string,
{ catchAllIndex: number | null; models: Map<string, number> }
>
): AdvancedCustomValidationError | null {
let state = routeModelsByPath.get(incomingPath)
if (!state) {
state = { catchAllIndex: null, models: new Map<string, number>() }
routeModelsByPath.set(incomingPath, state)
}
if (models.length === 0) {
if (state.catchAllIndex !== null) {
return {
routeIndex,
message:
'Only one catch-all route is allowed for the same incoming path',
}
}
state.catchAllIndex = routeIndex
return null
}
if (state.catchAllIndex !== null) {
return {
routeIndex,
message: 'Catch-all route must be last for the same incoming path',
}
}
const seenInRoute = new Set<string>()
for (const model of models) {
if (
getAdvancedCustomModelRuleKind(model) === 'regex' &&
getAdvancedCustomRegexModelPattern(model) === ''
) {
return { routeIndex, message: 'Model regex cannot be empty' }
}
if (seenInRoute.has(model)) {
return { routeIndex, message: 'Duplicate model in route models' }
}
seenInRoute.add(model)
if (state.models.has(model)) {
return {
routeIndex,
message: 'Route models must be unique for the same incoming path',
}
}
state.models.set(model, routeIndex)
}
return null
}
function getAdvancedCustomRouteUpstreamPath(
route: AdvancedCustomRoute
): string {
return (route.upstream_path || '').trim()
}
function getAdvancedCustomRouteSummaryLabel(
route: AdvancedCustomRoute
): string | null {
const incomingPath = route.incoming_path?.trim() || ''
if (!incomingPath) return null
return (
ADVANCED_CUSTOM_ROUTE_SUMMARY_LABELS[incomingPath] ||
getAdvancedCustomIncomingPathLabel(incomingPath)
)
}
function isFullHttpURLOrAbsolutePath(value: string): boolean {
if (value.startsWith('/')) return !value.startsWith('//')
try {
const parsed = new URL(value)
return (
Boolean(parsed.host) &&
(parsed.protocol === 'http:' || parsed.protocol === 'https:')
)
} catch {
return false
}
}
function isAdvancedCustomConverter(
value: string
): value is AdvancedCustomConverter {
return ADVANCED_CUSTOM_CONVERTER_OPTIONS.some(
(option) => option.value === value
)
}
function isConverterPathAllowed(
incomingPath: string,
converter: AdvancedCustomConverter
): boolean {
if (converter === 'none') return true
if (converter === 'anthropic_messages_to_openai_chat_completions') {
return incomingPath === '/v1/messages'
}
if (
converter === 'openai_chat_completions_to_anthropic_messages' ||
converter === 'openai_chat_completions_to_openai_responses' ||
converter === 'openai_chat_completions_to_gemini_generate_content'
) {
return incomingPath === '/v1/chat/completions'
}
if (converter === 'openai_responses_to_openai_chat_completions') {
return incomingPath === '/v1/responses'
}
if (converter === 'openai_responses_to_gemini_generate_content') {
return incomingPath === '/v1/responses'
}
return (
incomingPath.includes(':generateContent') ||
incomingPath.includes(':streamGenerateContent')
)
}
function validateRouteAuth(
auth: AdvancedCustomRouteAuth | undefined
): string | null {
if (!auth) return null
if (auth.type === 'none') return null
if (auth.type !== 'header' && auth.type !== 'query') {
return 'Auth type is invalid'
}
if (!auth.name?.trim()) {
return 'Auth name is required'
}
if (!auth.value?.trim()) {
return 'Auth value is required'
}
return null
}
+714
View File
@@ -0,0 +1,714 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import {
copyChannel,
deleteChannel,
testChannel,
updateChannel,
updateChannelStatus,
batchUpdateChannelStatus,
batchDeleteChannels,
batchSetChannelTag,
enableTagChannels,
disableTagChannels,
deleteDisabledChannels,
fixChannelAbilities,
editTagChannels,
testAllChannels,
updateAllChannelsBalance,
updateChannelBalance,
} from '../api'
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import type { ChannelTestResponse, CopyChannelParams } from '../types'
// ============================================================================
// Query Keys
// ============================================================================
export const channelsQueryKeys = {
all: ['channels'] as const,
lists: () => [...channelsQueryKeys.all, 'list'] as const,
list: (params: Record<string, unknown>) =>
[...channelsQueryKeys.lists(), params] as const,
details: () => [...channelsQueryKeys.all, 'detail'] as const,
detail: (id: number) => [...channelsQueryKeys.details(), id] as const,
}
function getChannelTestResponseTime(
response: ChannelTestResponse
): number | undefined {
const responseTime = response.data?.response_time
if (typeof responseTime === 'number' && Number.isFinite(responseTime)) {
return responseTime
}
if (
typeof response.time === 'number' &&
Number.isFinite(response.time) &&
response.time > 0
) {
return Math.round(response.time * 1000)
}
return undefined
}
function formatChannelTestDuration(responseTime?: number): string | undefined {
if (responseTime === undefined) return undefined
if (responseTime >= 1000) {
return `${(responseTime / 1000).toFixed(2)} s`
}
return `${Math.max(1, Math.round(responseTime))} ms`
}
function getChannelTestLabel(options?: {
channelName?: string
testModel?: string
}): string {
const channelName = options?.channelName?.trim()
const testModel = options?.testModel?.trim()
if (channelName && testModel) {
return i18next.t('Channel {{name}} model {{model}}', {
name: channelName,
model: testModel,
})
}
if (channelName) {
return i18next.t('Channel {{name}}', { name: channelName })
}
if (testModel) {
return i18next.t('Model {{model}}', { model: testModel })
}
return i18next.t('Channel')
}
// ============================================================================
// Single Channel Actions
// ============================================================================
/**
* Enable a channel
*/
export async function handleEnableChannel(
id: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannelStatus(id, CHANNEL_STATUS.ENABLED)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Disable a channel
*/
export async function handleDisableChannel(
id: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannelStatus(
id,
CHANNEL_STATUS.MANUAL_DISABLED
)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Toggle channel status (enable/disable)
*/
export async function handleToggleChannelStatus(
id: number,
currentStatus: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (currentStatus === CHANNEL_STATUS.ENABLED) {
await handleDisableChannel(id, queryClient, onSuccess)
} else {
await handleEnableChannel(id, queryClient, onSuccess)
}
}
/**
* Delete a channel
*/
export async function handleDeleteChannel(
id: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await deleteChannel(id)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.DELETED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
/**
* Update a specific channel field (e.g., priority, weight)
*/
export async function handleUpdateChannelField(
id: number,
fieldName: string,
value: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannel(id, { [fieldName]: value })
if (response.success) {
// Show success toast with field name
const fieldLabel =
fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase()
toast.success(
i18next.t('{{field}} updated to {{value}}', {
field: fieldLabel,
value,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Update a specific field for all channels with a tag
*/
export async function handleUpdateTagField(
tag: string,
fieldName: 'priority' | 'weight',
value: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const params = { tag, [fieldName]: value }
const response = await editTagChannels(params)
if (response.success) {
// Show success toast with field name
const fieldLabel =
fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase()
toast.success(
i18next.t('{{field}} updated to {{value}} for tag: {{tag}}', {
field: fieldLabel,
value,
tag,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Test channel connectivity
*/
export async function handleTestChannel(
id: number,
options?: {
channelName?: string
testModel?: string
endpointType?: string
stream?: boolean
silent?: boolean
},
onTestComplete?: (
success: boolean,
responseTime?: number,
error?: string,
errorCode?: string
) => void
): Promise<void> {
const payload =
options && (options.testModel || options.endpointType || options.stream)
? {
...(options.testModel ? { model: options.testModel } : {}),
...(options.endpointType
? { endpoint_type: options.endpointType }
: {}),
...(options.stream ? { stream: true } : {}),
}
: undefined
try {
const response = await testChannel(id, payload)
const responseTime = getChannelTestResponseTime(response)
const duration = formatChannelTestDuration(responseTime)
const target = getChannelTestLabel(options)
if (response.success) {
if (!options?.silent) {
toast.success(
i18next.t('{{target}} test succeeded', { target }),
duration
? {
description: i18next.t('Response time: {{duration}}', {
duration,
}),
}
: undefined
)
}
onTestComplete?.(true, responseTime)
} else {
const errorMsg = response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
if (!options?.silent) {
toast.error(i18next.t('{{target}} test failed', { target }), {
description: response.error_code
? `${errorMsg} (${response.error_code})`
: errorMsg,
})
}
onTestComplete?.(false, responseTime, errorMsg, response.error_code)
}
} catch (_error: unknown) {
const err = _error as { response?: { data?: { message?: string } } }
const errorMsg =
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
const target = getChannelTestLabel(options)
if (!options?.silent) {
toast.error(i18next.t('{{target}} test failed', { target }), {
description: errorMsg,
})
}
onTestComplete?.(false, undefined, errorMsg)
}
}
/**
* Copy a channel
*/
export async function handleCopyChannel(
id: number,
params: CopyChannelParams,
queryClient?: QueryClient,
onSuccess?: (newId: number) => void
): Promise<void> {
try {
const response = await copyChannel(id, params)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.COPIED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data?.id ?? 0)
} else {
toast.error(response.message || i18next.t('Failed to copy channel'))
}
} catch {
toast.error(i18next.t('Failed to copy channel'))
}
}
/**
* Update channel balance
*/
export async function handleUpdateChannelBalance(
id: number,
queryClient?: QueryClient,
onSuccess?: (balance: number) => void
): Promise<void> {
try {
const response = await updateChannelBalance(id)
if (response.success && response.balance !== undefined) {
const balance = response.balance
toast.success(
i18next.t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(balance)
} else {
toast.error(response.message || i18next.t('Failed to update balance'))
}
} catch (_error: unknown) {
toast.error(
_error instanceof Error
? _error.message
: i18next.t('Failed to update balance')
)
}
}
// ============================================================================
// Batch Actions
// ============================================================================
/**
* Batch delete channels
*/
export async function handleBatchDelete(
ids: number[],
queryClient?: QueryClient,
onSuccess?: (deletedCount: number) => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
const response = await batchDeleteChannels({ ids })
if (response.success) {
toast.success(
i18next.t('{{count}} channel(s) deleted', {
count: response.data || ids.length,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || ids.length)
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
/**
* Batch enable channels
*/
export async function handleBatchEnable(
ids: number[],
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
const response = await batchUpdateChannelStatus(ids, CHANNEL_STATUS.ENABLED)
const successCount = response.success ? response.data || 0 : 0
const failCount = ids.length - successCount
if (successCount > 0) {
toast.success(
i18next.t('{{count}} channel(s) enabled', { count: successCount })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
if (!response.success) {
toast.error(response.message || i18next.t('Failed to enable channels'))
} else if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
)
}
} catch {
toast.error(i18next.t('Failed to enable channels'))
}
}
/**
* Batch disable channels
*/
export async function handleBatchDisable(
ids: number[],
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
const response = await batchUpdateChannelStatus(
ids,
CHANNEL_STATUS.MANUAL_DISABLED
)
const successCount = response.success ? response.data || 0 : 0
const failCount = ids.length - successCount
if (successCount > 0) {
toast.success(
i18next.t('{{count}} channel(s) disabled', { count: successCount })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
if (!response.success) {
toast.error(response.message || i18next.t('Failed to disable channels'))
} else if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to disable', {
count: failCount,
})
)
}
} catch {
toast.error(i18next.t('Failed to disable channels'))
}
}
/**
* Batch set tag
*/
export async function handleBatchSetTag(
ids: number[],
tag: string | null,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
const response = await batchSetChannelTag({ ids, tag })
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.TAG_SET))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t('Failed to set tag'))
}
} catch {
toast.error(i18next.t('Failed to set tag'))
}
}
// ============================================================================
// Tag-Based Actions
// ============================================================================
/**
* Enable all channels with a tag
*/
export async function handleEnableTagChannels(
tag: string,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await enableTagChannels(tag)
if (response.success) {
toast.success(
i18next.t('Enabled all channels with tag: {{tag}}', { tag })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to enable tag channels')
)
}
} catch {
toast.error(i18next.t('Failed to enable tag channels'))
}
}
/**
* Disable all channels with a tag
*/
export async function handleDisableTagChannels(
tag: string,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await disableTagChannels(tag)
if (response.success) {
toast.success(
i18next.t('Disabled all channels with tag: {{tag}}', { tag })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to disable tag channels')
)
}
} catch {
toast.error(i18next.t('Failed to disable tag channels'))
}
}
// ============================================================================
// System Actions
// ============================================================================
/**
* Delete all disabled channels
*/
export async function handleDeleteAllDisabled(
queryClient?: QueryClient,
onSuccess?: (deletedCount: number) => void
): Promise<void> {
try {
const response = await deleteDisabledChannels()
if (response.success) {
toast.success(
i18next.t('{{count}} disabled channel(s) deleted', {
count: response.data || 0,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || 0)
} else {
toast.error(
response.message || i18next.t('Failed to delete disabled channels')
)
}
} catch {
toast.error(i18next.t('Failed to delete disabled channels'))
}
}
/**
* Repair channel consistency
*/
export async function handleFixAbilities(
queryClient?: QueryClient,
onSuccess?: (result: { success: number; fails: number }) => void
): Promise<void> {
try {
const response = await fixChannelAbilities()
if (response.success && response.data) {
toast.success(
i18next.t(
'Channel consistency repaired: {{success}} succeeded, {{fails}} failed',
{
success: response.data.success,
fails: response.data.fails,
}
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
} else {
toast.error(
response.message || i18next.t('Failed to repair channel consistency')
)
}
} catch {
toast.error(i18next.t('Failed to repair channel consistency'))
}
}
/**
* Test all enabled channels
*/
export async function handleTestAllChannels(
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await testAllChannels()
if (response.success) {
toast.success(
i18next.t(
'Testing all enabled channels started. Please refresh to see results.'
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to start testing all channels')
)
}
} catch {
toast.error(i18next.t('Failed to test all channels'))
}
}
/**
* Update balance for all enabled channels
*/
export async function handleUpdateAllBalances(
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateAllChannelsBalance()
if (response.success) {
toast.success(
i18next.t(
'Updating all channel balances. This may take a while. Please refresh to see results.'
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to update all balances')
)
}
} catch {
toast.error(i18next.t('Failed to update all balances'))
}
}
+69
View File
@@ -0,0 +1,69 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { FieldPath } from 'react-hook-form'
import type { ChannelFormValues } from './channel-form'
type ChannelFormErrorMap = Partial<
Record<FieldPath<ChannelFormValues>, unknown>
>
const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'priority',
'weight',
'test_model',
'auto_ban',
'tag',
'remark',
'param_override',
'header_override',
'status_code_mapping',
'advanced_custom',
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
'proxy',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
'disable_store',
'allow_safety_identifier',
'allow_include_obfuscation',
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
'disable_task_polling_sleep',
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
'upstream_model_update_ignored_models',
])
export function isAdvancedSettingsField(
fieldName: string
): fieldName is FieldPath<ChannelFormValues> {
return ADVANCED_SETTINGS_FIELDS.has(fieldName as FieldPath<ChannelFormValues>)
}
export function hasAdvancedSettingsErrors(
errors: ChannelFormErrorMap
): boolean {
return Object.keys(errors).some((fieldName) =>
isAdvancedSettingsField(fieldName)
)
}
+821
View File
@@ -0,0 +1,821 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import {
CHANNEL_STATUS,
ERROR_MESSAGES,
MODEL_FETCHABLE_TYPES,
} from '../constants'
import type { Channel } from '../types'
import {
CHANNEL_TYPE_ADVANCED_CUSTOM,
advancedCustomConfigUsesRelativeUpstreamPath,
hasValidAdvancedCustomModelListRoute,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
} from './advanced-custom'
// ============================================================================
// Form Validation Schema
// ============================================================================
function parseOptionalJson(value: string | undefined): unknown {
if (!value?.trim()) return undefined
return JSON.parse(value)
}
function isJsonObjectValue(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isOptionalJsonObject(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
return parsed === undefined || isJsonObjectValue(parsed)
} catch {
return false
}
}
function isOptionalModelMapping(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
if (!isJsonObjectValue(parsed)) return false
return Object.values(parsed).every((item) => typeof item === 'string')
} catch {
return false
}
}
function isOptionalStatusCodeMapping(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
if (!isJsonObjectValue(parsed)) return false
return Object.entries(parsed).every(([from, to]) => {
const fromCode = Number(from)
const toCode = Number(to)
return (
Number.isInteger(fromCode) &&
Number.isInteger(toCode) &&
fromCode >= 100 &&
fromCode <= 599 &&
toCode >= 100 &&
toCode <= 599
)
})
} catch {
return false
}
}
function isCodexCredential(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
return (
isJsonObjectValue(parsed) &&
typeof parsed.access_token === 'string' &&
parsed.access_token.trim().length > 0 &&
typeof parsed.account_id === 'string' &&
parsed.account_id.trim().length > 0
)
} catch {
return false
}
}
function isVertexJsonKey(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
if (Array.isArray(parsed)) {
return parsed.every((item) => isJsonObjectValue(item))
}
return isJsonObjectValue(parsed)
} catch {
return false
}
}
function addRequiredIssue(
ctx: z.RefinementCtx,
path: string,
message: string
): void {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [path],
message,
})
}
export const channelFormSchema = z
.object({
name: z.string().min(1, ERROR_MESSAGES.REQUIRED_NAME),
type: z.number().min(0, ERROR_MESSAGES.REQUIRED_TYPE),
base_url: z.string().optional(),
key: z.string(),
openai_organization: z.string().optional(),
models: z.string().min(1, ERROR_MESSAGES.REQUIRED_MODELS),
group: z.array(z.string()).min(1, ERROR_MESSAGES.REQUIRED_GROUP),
model_mapping: z
.string()
.optional()
.refine(
isOptionalModelMapping,
'Model mapping must be a JSON object with string values'
),
priority: z.number().optional(),
weight: z.number().optional(),
test_model: z.string().optional(),
auto_ban: z.number().optional(),
status: z.number(),
status_code_mapping: z
.string()
.optional()
.refine(
isOptionalStatusCodeMapping,
'Status code mapping must use valid HTTP status codes'
),
tag: z.string().optional(),
remark: z
.string()
.max(255, 'Remark must be less than 255 characters')
.optional(),
setting: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
param_override: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
header_override: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
settings: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
advanced_custom: z.string().optional(),
other: z.string().optional(),
// Multi-key options (not sent to backend directly)
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
multi_key_type: z.enum(['random', 'polling']).optional(),
batch_add_set_key_prefix_2_name: z.boolean().optional(),
key_mode: z.enum(['append', 'replace']).optional(), // For editing multi-key channels
// Channel extra settings (stored in setting JSON, not sent directly)
force_format: z.boolean().optional(),
thinking_to_content: z.boolean().optional(),
proxy: z.string().optional(),
pass_through_body_enabled: z.boolean().optional(),
system_prompt: z.string().optional(),
system_prompt_override: z.boolean().optional(),
// Type-specific settings (stored in settings JSON)
is_enterprise_account: z.boolean().optional(), // OpenRouter specific
vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific
aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific
azure_responses_version: z.string().optional(), // Azure specific
// Field passthrough controls (stored in settings JSON)
allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic
disable_store: z.boolean().optional(), // OpenAI only
allow_safety_identifier: z.boolean().optional(), // OpenAI only
allow_include_obfuscation: z.boolean().optional(), // OpenAI: include usage obfuscation
allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography
allow_speed: z.boolean().optional(), // Anthropic: speed mode control
claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough
disable_task_polling_sleep: z.boolean().optional(),
// Upstream model update settings (stored in settings JSON)
upstream_model_update_check_enabled: z.boolean().optional(),
upstream_model_update_auto_sync_enabled: z.boolean().optional(),
upstream_model_update_ignored_models: z.string().optional(),
})
.superRefine((data, ctx) => {
if ([3, 8, 36, 45].includes(data.type) && !data.base_url?.trim()) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required for this channel type'
)
}
if (data.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
data.advanced_custom
)
const advancedCustomError =
validateAdvancedCustomConfig(advancedCustomConfig)
if (advancedCustomError) {
addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message)
}
if (
advancedCustomConfigUsesRelativeUpstreamPath(advancedCustomConfig) &&
!data.base_url?.trim()
) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required when an advanced route uses an upstream path'
)
}
if (
data.upstream_model_update_check_enabled === true &&
!hasValidAdvancedCustomModelListRoute(advancedCustomConfig)
) {
addRequiredIssue(
ctx,
'upstream_model_update_check_enabled',
'OpenAI Models route is required to enable upstream model checks'
)
}
}
if ([3, 18, 21, 39, 41, 49].includes(data.type) && !data.other?.trim()) {
addRequiredIssue(
ctx,
'other',
'This channel type requires additional configuration'
)
}
if (data.type === 57) {
if (data.multi_key_mode && data.multi_key_mode !== 'single') {
addRequiredIssue(
ctx,
'multi_key_mode',
'Codex channels do not support batch creation'
)
}
if (data.key?.trim() && !isCodexCredential(data.key)) {
addRequiredIssue(
ctx,
'key',
'Codex credential must be a JSON object with access_token and account_id'
)
}
}
if (
data.type === 41 &&
data.vertex_key_type === 'json' &&
data.key?.trim() &&
!isVertexJsonKey(data.key)
) {
addRequiredIssue(
ctx,
'key',
'Vertex AI service account key must be valid JSON'
)
}
if (
data.type === 41 &&
data.vertex_key_type === 'api_key' &&
data.multi_key_mode &&
data.multi_key_mode !== 'single'
) {
addRequiredIssue(
ctx,
'multi_key_mode',
'Vertex AI API Key mode does not support batch creation'
)
}
})
export type ChannelFormValues = z.infer<typeof channelFormSchema>
// ============================================================================
// Default Form Values
// ============================================================================
export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
name: '',
type: 1,
base_url: '',
key: '',
openai_organization: '',
models: '',
group: ['default'],
model_mapping: '',
priority: 0,
weight: 0,
test_model: '',
auto_ban: 1,
status: CHANNEL_STATUS.ENABLED,
status_code_mapping: '',
tag: '',
remark: '',
setting: '',
param_override: '',
header_override: '',
settings: '{}',
other: '',
multi_key_mode: 'single',
multi_key_type: 'random',
batch_add_set_key_prefix_2_name: false,
key_mode: 'append',
// Channel extra settings
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
system_prompt_override: false,
// Type-specific settings
is_enterprise_account: false,
vertex_key_type: 'json',
aws_key_type: 'ak_sk',
azure_responses_version: '',
// Field passthrough controls
allow_service_tier: false,
disable_store: false,
allow_safety_identifier: false,
allow_include_obfuscation: false,
allow_inference_geo: false,
allow_speed: false,
claude_beta_query: false,
disable_task_polling_sleep: false,
upstream_model_update_check_enabled: false,
upstream_model_update_auto_sync_enabled: false,
upstream_model_update_ignored_models: '',
advanced_custom: '',
}
// ============================================================================
// Transform Functions
// ============================================================================
/**
* Transform Channel from API to Form default values
*/
export function transformChannelToFormDefaults(
channel: Channel
): ChannelFormValues {
// Parse channel extra settings from setting field
let extraSettings = {
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
system_prompt_override: false,
}
if (channel.setting) {
try {
const parsed = JSON.parse(channel.setting)
extraSettings = {
force_format: parsed.force_format || false,
thinking_to_content: parsed.thinking_to_content || false,
proxy: parsed.proxy || '',
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
system_prompt: parsed.system_prompt || '',
system_prompt_override: parsed.system_prompt_override || false,
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel setting:', error)
}
}
// Parse type-specific settings from settings field
let vertexKeyType: 'json' | 'api_key' = 'json'
let azureResponsesVersion = ''
let isEnterpriseAccount = false
let awsKeyType: 'ak_sk' | 'api_key' = 'ak_sk'
let allowServiceTier = false
let disableStore = false
let allowSafetyIdentifier = false
let allowIncludeObfuscation = false
let allowInferenceGeo = false
let allowSpeed = false
let claudeBetaQuery = false
let disableTaskPollingSleep = false
let upstreamModelUpdateCheckEnabled = false
let upstreamModelUpdateAutoSyncEnabled = false
let upstreamModelUpdateIgnoredModels = ''
let advancedCustom = ''
if (channel.settings) {
try {
const parsed = JSON.parse(channel.settings)
vertexKeyType = parsed.vertex_key_type || 'json'
azureResponsesVersion = parsed.azure_responses_version || ''
isEnterpriseAccount = parsed.openrouter_enterprise === true
awsKeyType = parsed.aws_key_type || 'ak_sk'
allowServiceTier = parsed.allow_service_tier === true
disableStore = parsed.disable_store === true
allowSafetyIdentifier = parsed.allow_safety_identifier === true
allowIncludeObfuscation = parsed.allow_include_obfuscation === true
allowInferenceGeo = parsed.allow_inference_geo === true
allowSpeed = parsed.allow_speed === true
claudeBetaQuery = parsed.claude_beta_query === true
disableTaskPollingSleep = parsed.disable_task_polling_sleep === true
upstreamModelUpdateCheckEnabled =
parsed.upstream_model_update_check_enabled === true
upstreamModelUpdateAutoSyncEnabled =
parsed.upstream_model_update_auto_sync_enabled === true
upstreamModelUpdateIgnoredModels = Array.isArray(
parsed.upstream_model_update_ignored_models
)
? parsed.upstream_model_update_ignored_models.join(',')
: ''
if (parsed.advanced_custom) {
advancedCustom = stringifyAdvancedCustomConfig(parsed.advanced_custom)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel settings:', error)
}
}
return {
name: channel.name || '',
type: channel.type,
base_url: channel.base_url || '',
key: '', // Never populate key from backend for security
openai_organization: channel.openai_organization || '',
models: channel.models || '',
group: parseGroups(channel.group || 'default'),
model_mapping: channel.model_mapping || '',
priority: channel.priority || 0,
weight: channel.weight || 0,
test_model: channel.test_model || '',
auto_ban: channel.auto_ban ?? 1,
status: channel.status,
status_code_mapping: channel.status_code_mapping || '',
tag: channel.tag || '',
remark: channel.remark || '',
setting: channel.setting || '',
param_override: channel.param_override || '',
header_override: channel.header_override || '',
settings: channel.settings || '{}',
other: channel.other || '',
multi_key_mode: 'single',
multi_key_type: channel.channel_info.multi_key_mode || 'random',
batch_add_set_key_prefix_2_name: false,
key_mode: 'append', // Default to append mode for editing multi-key channels
// Channel extra settings
...extraSettings,
// Type-specific settings
is_enterprise_account: isEnterpriseAccount,
vertex_key_type: vertexKeyType,
azure_responses_version: azureResponsesVersion,
aws_key_type: awsKeyType,
allow_service_tier: allowServiceTier,
disable_store: disableStore,
allow_include_obfuscation: allowIncludeObfuscation,
allow_inference_geo: allowInferenceGeo,
allow_speed: allowSpeed,
claude_beta_query: claudeBetaQuery,
disable_task_polling_sleep: disableTaskPollingSleep,
allow_safety_identifier: allowSafetyIdentifier,
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
upstream_model_update_ignored_models: upstreamModelUpdateIgnoredModels,
advanced_custom: advancedCustom,
}
}
/**
* Build the setting JSON string from form extra settings
*/
function buildSettingJSON(formData: ChannelFormValues): string {
const settingObj = {
force_format: formData.force_format || false,
thinking_to_content: formData.thinking_to_content || false,
proxy: formData.proxy || '',
pass_through_body_enabled: formData.pass_through_body_enabled || false,
system_prompt: formData.system_prompt || '',
system_prompt_override: formData.system_prompt_override || false,
}
return JSON.stringify(settingObj)
}
/**
* Build the settings JSON string (for type-specific config like vertex_key_type)
*/
function buildSettingsJSON(formData: ChannelFormValues): string {
let settingsObj: Record<string, unknown> = {}
// Try to parse existing settings first
if (formData.settings && formData.settings !== '{}') {
try {
settingsObj = JSON.parse(formData.settings)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse existing settings:', error)
}
}
// Add vertex_key_type for Vertex AI channels (type 41)
if (formData.type === 41) {
settingsObj.vertex_key_type = formData.vertex_key_type || 'json'
} else if ('vertex_key_type' in settingsObj) {
delete settingsObj.vertex_key_type
}
// Add azure_responses_version for Azure channels (type 3)
if (formData.type === 3 && formData.azure_responses_version) {
settingsObj.azure_responses_version = formData.azure_responses_version
} else if ('azure_responses_version' in settingsObj) {
delete settingsObj.azure_responses_version
}
// Add enterprise account setting for OpenRouter (type 20)
if (formData.type === 20) {
settingsObj.openrouter_enterprise = formData.is_enterprise_account === true
} else if ('openrouter_enterprise' in settingsObj) {
delete settingsObj.openrouter_enterprise
}
// Add aws_key_type for AWS channels (type 33)
if (formData.type === 33) {
settingsObj.aws_key_type = formData.aws_key_type || 'ak_sk'
} else if ('aws_key_type' in settingsObj) {
delete settingsObj.aws_key_type
}
// Field passthrough controls:
// - OpenAI (type 1) and Anthropic (type 14): allow_service_tier
// - OpenAI only: disable_store, allow_safety_identifier
if (formData.type === 1 || formData.type === 14 || formData.type === 57) {
settingsObj.allow_service_tier = formData.allow_service_tier === true
} else if ('allow_service_tier' in settingsObj) {
delete settingsObj.allow_service_tier
}
if (formData.type === 1 || formData.type === 57) {
settingsObj.disable_store = formData.disable_store === true
settingsObj.allow_safety_identifier =
formData.allow_safety_identifier === true
settingsObj.allow_include_obfuscation =
formData.allow_include_obfuscation === true
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
} else {
if ('disable_store' in settingsObj) {
delete settingsObj.disable_store
}
if ('allow_safety_identifier' in settingsObj) {
delete settingsObj.allow_safety_identifier
}
if ('allow_include_obfuscation' in settingsObj) {
delete settingsObj.allow_include_obfuscation
}
if (formData.type !== 14 && 'allow_inference_geo' in settingsObj) {
delete settingsObj.allow_inference_geo
}
}
// Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed
if (formData.type === 14) {
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
settingsObj.allow_speed = formData.allow_speed === true
settingsObj.claude_beta_query = formData.claude_beta_query === true
} else {
if ('allow_speed' in settingsObj) {
delete settingsObj.allow_speed
}
if ('claude_beta_query' in settingsObj) {
delete settingsObj.claude_beta_query
}
}
settingsObj.disable_task_polling_sleep =
formData.disable_task_polling_sleep === true
// Upstream model update settings (for model-fetchable channel types)
if (MODEL_FETCHABLE_TYPES.has(formData.type)) {
settingsObj.upstream_model_update_check_enabled =
formData.upstream_model_update_check_enabled === true
settingsObj.upstream_model_update_auto_sync_enabled =
settingsObj.upstream_model_update_check_enabled === true &&
formData.upstream_model_update_auto_sync_enabled === true
settingsObj.upstream_model_update_ignored_models = [
...new Set(
String(formData.upstream_model_update_ignored_models || '')
.split(',')
.map((model) => model.trim())
.filter(Boolean)
),
]
if (
!Array.isArray(settingsObj.upstream_model_update_last_detected_models) ||
settingsObj.upstream_model_update_check_enabled !== true
) {
settingsObj.upstream_model_update_last_detected_models = []
}
if (typeof settingsObj.upstream_model_update_last_check_time !== 'number') {
settingsObj.upstream_model_update_last_check_time = 0
}
}
if (formData.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
formData.advanced_custom
)
if (advancedCustomConfig) {
settingsObj.advanced_custom = advancedCustomConfig
}
} else if ('advanced_custom' in settingsObj) {
delete settingsObj.advanced_custom
}
return JSON.stringify(settingsObj)
}
function normalizeBaseUrl(value: string | undefined): string {
return String(value || '')
.trim()
.replace(/\/+$/, '')
}
/**
* Transform form data to API payload for creating channel
*/
export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
mode: 'single' | 'batch' | 'multi_to_single'
multi_key_mode?: 'random' | 'polling'
batch_add_set_key_prefix_2_name?: boolean
channel: Partial<Channel>
} {
const mode = formData.multi_key_mode || 'single'
const channel: Partial<Channel> = {
name: formData.name,
type: formData.type,
base_url: normalizeBaseUrl(formData.base_url) || null,
key: formData.key,
openai_organization: formData.openai_organization || null,
models: formData.models,
group: formatGroups(formData.group),
model_mapping: formData.model_mapping || null,
priority: formData.priority || null,
weight: formData.weight || null,
test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1,
status: formData.status,
status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null,
remark: formData.remark || '',
setting: buildSettingJSON(formData),
param_override: formData.param_override || null,
header_override: formData.header_override || null,
settings: buildSettingsJSON(formData),
other: formData.other || '',
}
// Clean up empty strings to null for optional fields
Object.keys(channel).forEach((key) => {
if (channel[key as keyof typeof channel] === '') {
;(channel as Record<string, unknown>)[key] = null
}
})
return {
mode,
multi_key_mode:
mode === 'multi_to_single' ? formData.multi_key_type : undefined,
batch_add_set_key_prefix_2_name:
mode === 'batch' ? formData.batch_add_set_key_prefix_2_name : undefined,
channel,
}
}
/**
* Transform form data to API payload for updating channel
*/
export function transformFormDataToUpdatePayload(
formData: ChannelFormValues,
channelId: number
): Partial<Channel> {
const payload: Partial<Channel> = {
id: channelId,
name: formData.name,
type: formData.type,
base_url: normalizeBaseUrl(formData.base_url) || null,
openai_organization: formData.openai_organization || null,
models: formData.models,
group: formatGroups(formData.group),
model_mapping: formData.model_mapping || null,
priority: formData.priority ?? 0,
weight: formData.weight ?? 0,
test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1,
status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null,
remark: formData.remark || '',
setting: buildSettingJSON(formData),
param_override: formData.param_override || null,
header_override: formData.header_override || null,
settings: buildSettingsJSON(formData),
other: formData.other || '',
}
// Only include key if it was changed (not empty)
if (formData.key && formData.key.trim()) {
payload.key = formData.key
}
// Clean up empty strings to null for optional fields
Object.keys(payload).forEach((key) => {
if (payload[key as keyof typeof payload] === '') {
;(payload as Record<string, unknown>)[key] = null
}
})
// Send explicit empty strings for nullable fields so GORM updates can clear them.
payload.base_url = normalizeBaseUrl(formData.base_url) || ''
payload.openai_organization = formData.openai_organization || ''
payload.test_model = formData.test_model || ''
payload.tag = formData.tag || ''
payload.remark = formData.remark || ''
payload.model_mapping = formData.model_mapping || ''
payload.status_code_mapping = formData.status_code_mapping || ''
payload.param_override = formData.param_override || ''
payload.header_override = formData.header_override || ''
return payload
}
// ============================================================================
// Validation Helpers
// ============================================================================
/**
* Validate JSON string
*/
export function validateJSON(value: string): boolean {
if (!value || value.trim() === '') return true
try {
JSON.parse(value)
return true
} catch {
return false
}
}
/**
* Validate model mapping format
*/
export function validateModelMapping(value: string): boolean {
if (!value || value.trim() === '') return true
return validateJSON(value)
}
/**
* Parse models string to array
*/
export function parseModels(models: string): string[] {
if (!models) return []
return models
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0)
}
/**
* Parse groups string to array
*/
export function parseGroups(groups: string): string[] {
if (!groups) return []
return groups
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0)
}
/**
* Format models array to string
*/
export function formatModels(models: string[]): string {
return models.join(',')
}
/**
* Format groups array to string
*/
export function formatGroups(groups: string[]): string {
return groups.join(',')
}
+208
View File
@@ -0,0 +1,208 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { CHANNEL_TYPES } from '../constants'
// ============================================================================
// Channel Type Configuration
// ============================================================================
export interface ChannelTypeConfig {
id: number
name: string
icon: string
defaultBaseUrl?: string
requiresOrganization?: boolean
requiresRegion?: boolean
supportedModels?: string[]
hints?: {
baseUrl?: string
key?: string
models?: string
other?: string
}
validation?: {
keyFormat?: RegExp
keyMinLength?: number
}
}
/**
* Configuration for each channel type
*/
export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
1: {
id: 1,
name: CHANNEL_TYPES[1],
icon: 'openai',
defaultBaseUrl: 'https://api.openai.com',
requiresOrganization: true,
hints: {
baseUrl: 'Default: https://api.openai.com',
key: 'Format: sk-...',
models: 'gpt-4,gpt-4-turbo,gpt-3.5-turbo',
},
validation: {
keyFormat: /^sk-/,
keyMinLength: 20,
},
},
3: {
id: 3,
name: CHANNEL_TYPES[3],
icon: 'azure',
requiresRegion: true,
hints: {
baseUrl: 'Azure OpenAI Endpoint',
key: 'Azure API Key',
models: 'Deployment names',
},
},
14: {
id: 14,
name: CHANNEL_TYPES[14],
icon: 'anthropic',
defaultBaseUrl: 'https://api.anthropic.com',
hints: {
key: 'Format: sk-ant-...',
models: 'claude-3-opus,claude-3-sonnet,claude-3-haiku',
},
},
24: {
id: 24,
name: CHANNEL_TYPES[24],
icon: 'google',
hints: {
key: 'Google API Key',
models: 'gemini-pro,gemini-pro-vision',
},
},
41: {
id: 41,
name: CHANNEL_TYPES[41],
icon: 'google',
requiresRegion: true,
hints: {
key: 'Service account JSON or API key',
models: 'gemini-pro,gemini-1.5-pro',
other: 'Region config: {"default": "us-central1"}',
},
},
43: {
id: 43,
name: CHANNEL_TYPES[43],
icon: 'deepseek',
defaultBaseUrl: 'https://api.deepseek.com',
hints: {
key: 'DeepSeek API Key',
models: 'deepseek-chat,deepseek-coder',
},
},
20: {
id: 20,
name: CHANNEL_TYPES[20],
icon: 'openrouter',
defaultBaseUrl: 'https://openrouter.ai/api',
hints: {
key: 'OpenRouter API Key',
models: 'Use model IDs from OpenRouter',
},
},
56: {
id: 56,
name: CHANNEL_TYPES[56],
icon: 'replicate',
defaultBaseUrl: 'https://api.replicate.com',
hints: {
key: 'Replicate API Token',
models: 'Replicate model IDs',
baseUrl: 'Default: https://api.replicate.com',
},
},
58: {
id: 58,
name: CHANNEL_TYPES[58],
icon: 'newapi',
hints: {
baseUrl: 'Fallback base URL',
key: 'Used by route auth templates',
models: 'Models exposed by this channel',
},
},
}
/**
* Get configuration for a channel type
*/
export function getChannelTypeConfig(type: number): ChannelTypeConfig {
return (
CHANNEL_TYPE_CONFIGS[type] || {
id: type,
name: CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown',
icon: 'openai',
}
)
}
/**
* Check if channel type requires organization field
*/
export function requiresOrganization(type: number): boolean {
return CHANNEL_TYPE_CONFIGS[type]?.requiresOrganization || false
}
/**
* Check if channel type requires region configuration
*/
export function requiresRegion(type: number): boolean {
return CHANNEL_TYPE_CONFIGS[type]?.requiresRegion || false
}
/**
* Get default base URL for channel type
*/
export function getDefaultBaseUrl(type: number): string {
return CHANNEL_TYPE_CONFIGS[type]?.defaultBaseUrl || ''
}
/**
* Get hints for channel type
*/
export function getChannelTypeHints(type: number) {
return CHANNEL_TYPE_CONFIGS[type]?.hints || {}
}
/**
* Validate API key format for channel type
*/
export function validateKeyFormat(type: number, key: string): boolean {
const config = CHANNEL_TYPE_CONFIGS[type]
if (!config?.validation) return true
const { keyFormat, keyMinLength } = config.validation
if (keyMinLength && key.length < keyMinLength) {
return false
}
if (keyFormat && !keyFormat.test(key)) {
return false
}
return true
}
+769
View File
@@ -0,0 +1,769 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import {
CHANNEL_STATUS_CONFIG,
CHANNEL_TYPES,
MULTI_KEY_STATUS_CONFIG,
RESPONSE_TIME_CONFIG,
RESPONSE_TIME_THRESHOLDS,
TYPE_TO_KEY_PROMPT,
} from '../constants'
import type { Channel, ChannelSettings, ChannelOtherSettings } from '../types'
// ============================================================================
// Channel Type Utilities
// ============================================================================
/**
* Get human-readable channel type label
*/
export function getChannelTypeLabel(type: number): string {
return CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown'
}
/**
* Get channel type icon name for getLobeIcon
* Maps channel types to Lobe icon names using type number (language-independent)
*/
export function getChannelTypeIcon(type: number): string {
const TYPE_TO_ICON: Record<number, string> = {
// OpenAI family
1: 'OpenAI', // OpenAI
6: 'OpenAI', // OpenAIMax
7: 'OpenAI', // OhMyGPT
8: 'OpenAI', // Custom
58: 'NewAPI', // Advanced Custom
3: 'Azure', // Azure
// Anthropic
14: 'Claude', // Anthropic
// Google family
24: 'Gemini', // Gemini
11: 'Google', // PaLM
41: 'Gemini', // Vertex AI
// Cloud providers
33: 'Aws', // AWS
39: 'Cloudflare', // Cloudflare
// Chinese providers
15: 'Baidu', // Baidu
46: 'Baidu', // Baidu V2
16: 'Zhipu', // Zhipu
26: 'Zhipu', // Zhipu V4
17: 'Qwen', // Ali
18: 'Spark', // Xunfei
23: 'Hunyuan', // Tencent
19: 'Ai360', // 360
25: 'Moonshot', // Moonshot
31: 'Yi', // LingYiWanWu
35: 'Minimax', // MiniMax
45: 'Volcengine', // VolcEngine
// Other AI providers
4: 'Ollama', // Ollama
27: 'Perplexity', // Perplexity
34: 'Cohere', // Cohere
42: 'Mistral', // Mistral
43: 'DeepSeek', // DeepSeek
48: 'XAI', // xAI
49: 'Coze', // Coze
40: 'SiliconCloud', // SiliconFlow
44: 'OpenAI', // MokaAI
20: 'OpenRouter', // OpenRouter
// Image/Video generation
2: 'Midjourney', // MjProxy
5: 'Midjourney', // MjProxyPlus
50: 'Kling', // Kling
51: 'Jimeng', // Jimeng
52: 'Vidu', // Vidu
36: 'Suno', // SunoAPI
55: 'OpenAI', // Sora
54: 'Doubao', // DoubaoVideo
56: 'Replicate', // Replicate
// Tools & Platforms
37: 'Dify', // Dify
38: 'Jina', // Jina
22: 'FastGPT', // FastGPT
47: 'Xinference', // Xinference
53: 'OpenAI', // Submodel
// AI Proxy services
10: 'OpenAI', // AI Proxy
21: 'OpenAI', // AI Proxy Library
12: 'OpenAI', // API2GPT
13: 'OpenAI', // AIGC2D
9: 'OpenAI', // AILS
}
return TYPE_TO_ICON[type] || 'OpenAI'
}
// ============================================================================
// Status Utilities
// ============================================================================
/**
* Get status badge configuration
*/
export function getChannelStatusBadge(status: number) {
return (
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
CHANNEL_STATUS_CONFIG[0]
)
}
/**
* Get multi-key status badge configuration
*/
export function getMultiKeyStatusBadge(status: number) {
return (
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] ||
MULTI_KEY_STATUS_CONFIG[1]
)
}
/**
* Check if channel is enabled
*/
export function isChannelEnabled(channel: Channel): boolean {
return channel.status === 1
}
/**
* Check if channel is multi-key
*/
export function isMultiKeyChannel(channel: Channel): boolean {
return channel.channel_info?.is_multi_key || false
}
// ============================================================================
// Key Formatting
// ============================================================================
/**
* Format channel key for display
* Masks the key for security, showing only first and last few characters
*/
export function formatChannelKey(
key: string,
isMultiKey: boolean = false
): string {
if (!key) {
return ''
}
if (isMultiKey) {
const keys = key.split('\n').filter((k) => k.trim())
return `${keys.length} keys`
}
if (key.length <= 16) {
// For short keys, mask middle part
return `${key.slice(0, 4)}...${key.slice(-4)}`
}
// For longer keys, show more context
return `${key.slice(0, 8)}...${key.slice(-8)}`
}
/**
* Format key preview for multi-key display
*/
export function formatKeyPreview(key: string, maxLength: number = 10): string {
if (!key) {
return ''
}
if (key.length <= maxLength) {
return key
}
return `${key.slice(0, maxLength)}...`
}
/**
* Count keys in multi-key string
*/
export function countKeys(key: string): number {
if (!key) {
return 0
}
return key.split('\n').filter((k) => k.trim()).length
}
// ============================================================================
// Model & Group Parsing
// ============================================================================
/**
* Parse comma-separated models list
*/
export function parseModelsList(models: string): string[] {
if (!models) {
return []
}
return models
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0)
}
/**
* Parse comma-separated groups list.
* Sorts with 'default' group first, then locale-sorted alphabetically.
*/
export function parseGroupsList(groups: string): string[] {
if (!groups) {
return []
}
const list = groups
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0)
return list.sort((a, b) => {
if (a === 'default') {
return -1
}
if (b === 'default') {
return 1
}
return a.localeCompare(b)
})
}
/**
* Format models array back to string
*/
export function formatModelsString(models: string[]): string {
return models.join(',')
}
/**
* Format groups array back to string
*/
export function formatGroupsString(groups: string[]): string {
return groups.join(',')
}
// ============================================================================
// Settings Parsing
// ============================================================================
/**
* Parse channel settings JSON
*/
export function parseChannelSettings(
settingStr: string | null | undefined
): ChannelSettings {
if (!settingStr) {
return {}
}
try {
return JSON.parse(settingStr) as ChannelSettings
} catch {
return {}
}
}
/**
* Parse channel other settings JSON
*/
export function parseChannelOtherSettings(
settingsStr: string | null | undefined
): ChannelOtherSettings {
if (!settingsStr || settingsStr === '{}') {
return {}
}
try {
return JSON.parse(settingsStr) as ChannelOtherSettings
} catch {
return {}
}
}
/**
* Validate JSON string
*/
export function validateChannelSettings(settings: string): boolean {
if (!settings || settings.trim() === '') {
return true
}
try {
JSON.parse(settings)
return true
} catch {
return false
}
}
// ============================================================================
// Balance Formatting
// ============================================================================
/**
* Format balance with currency symbol
*/
export function formatBalance(balance: number | null | undefined): string {
if (balance == null || Number.isNaN(balance)) {
return '-'
}
return formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
})
}
/**
* Get balance status color
*/
export function getBalanceVariant(
balance: number
): 'success' | 'warning' | 'danger' | 'neutral' {
if (balance === 0) {
return 'neutral'
}
if (balance < 1) {
return 'danger'
}
if (balance < 10) {
return 'warning'
}
return 'success'
}
// ============================================================================
// Response Time Utilities
// ============================================================================
/** Optional i18n: (key, options) => string, e.g. useTranslation().t */
type TFunction = (key: string, options?: { value?: number | string }) => string
/**
* Format response time in milliseconds to human-readable.
* Pass `t` from useTranslation() for i18n (e.g. "Not tested", "{{value}}ms", "{{value}}s").
*/
export function formatResponseTime(timeMs: number, t?: TFunction): string {
if (timeMs === 0) {
return t ? t('Not tested') : 'Not tested'
}
if (timeMs < 1000) {
return t ? t('{{value}}ms', { value: timeMs }) : `${timeMs}ms`
}
return t
? t('{{value}}s', { value: (timeMs / 1000).toFixed(2) })
: `${(timeMs / 1000).toFixed(2)}s`
}
/**
* Get response time performance rating
*/
export function getResponseTimeConfig(timeMs: number) {
if (timeMs === 0) {
return RESPONSE_TIME_CONFIG.UNKNOWN
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.EXCELLENT) {
return RESPONSE_TIME_CONFIG.EXCELLENT
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.GOOD) {
return RESPONSE_TIME_CONFIG.GOOD
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.FAIR) {
return RESPONSE_TIME_CONFIG.FAIR
}
if (timeMs <= RESPONSE_TIME_THRESHOLDS.POOR) {
return RESPONSE_TIME_CONFIG.POOR
}
return RESPONSE_TIME_CONFIG.POOR
}
// ============================================================================
// Time Formatting
// ============================================================================
/**
* Format a Unix timestamp (seconds) as a compact, locale-aware relative time.
* Uses `Intl.RelativeTimeFormat` with the `narrow` style so the label stays
* short inside table cells, e.g. "4h ago" / "42m ago" (en) or "4 小时前" (zh),
* instead of the verbose "4 hours ago".
*/
export function formatRelativeTime(
timestamp: number,
locale?: Intl.LocalesArgument
): string {
if (!timestamp || timestamp === 0) {
return 'Never'
}
try {
const diffSec = timestamp - Date.now() / 1000
const absSec = Math.abs(diffSec)
const rtf = new Intl.RelativeTimeFormat(locale, {
numeric: 'always',
style: 'narrow',
})
const MINUTE = 60
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const MONTH = 30 * DAY
const YEAR = 365 * DAY
let value: number
let unit: Intl.RelativeTimeFormatUnit
if (absSec < MINUTE) {
value = Math.round(diffSec)
unit = 'second'
} else if (absSec < HOUR) {
value = Math.round(diffSec / MINUTE)
unit = 'minute'
} else if (absSec < DAY) {
value = Math.round(diffSec / HOUR)
unit = 'hour'
} else if (absSec < MONTH) {
value = Math.round(diffSec / DAY)
unit = 'day'
} else if (absSec < YEAR) {
value = Math.round(diffSec / MONTH)
unit = 'month'
} else {
value = Math.round(diffSec / YEAR)
unit = 'year'
}
const formatted = rtf.format(value, unit)
const primaryLocale = Array.isArray(locale) ? locale[0] : locale
const language = primaryLocale?.toString()
if (language?.startsWith('zh')) {
return formatted.replaceAll(/(\d)([\u4e00-\u9fff])/g, '$1 $2')
}
return formatted
} catch {
return 'Unknown'
}
}
/**
* Format Unix timestamp to date string
*/
export function formatTimestamp(timestamp: number): string {
if (!timestamp || timestamp === 0) {
return 'N/A'
}
try {
return formatTimestampToDate(timestamp)
} catch {
return 'Invalid date'
}
}
// ============================================================================
// Quota Formatting
// ============================================================================
/** Format quota units using the global currency display configuration. */
export function formatQuota(quota: number): string {
return formatQuotaWithCurrency(quota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
})
}
// ============================================================================
// Priority & Weight Utilities
// ============================================================================
/**
* Get priority display value
*/
export function getPriorityDisplay(
priority: number | null | undefined
): string {
if (priority === null || priority === undefined) {
return '0'
}
return String(priority)
}
/**
* Get weight display value
*/
export function getWeightDisplay(weight: number | null | undefined): string {
if (weight === null || weight === undefined) {
return '0'
}
return String(weight)
}
// ============================================================================
// Validation Utilities
// ============================================================================
/**
* Validate channel name
*/
export function validateChannelName(name: string): boolean {
return name.trim().length > 0
}
/**
* Validate API key format
*/
export function validateApiKey(key: string): boolean {
return key.trim().length > 0
}
/**
* Validate models list
*/
export function validateModels(models: string): boolean {
return parseModelsList(models).length > 0
}
/**
* Validate groups list
*/
export function validateGroups(groups: string): boolean {
return parseGroupsList(groups).length > 0
}
/**
* Check if channel needs attention (low balance, auto-disabled, etc.)
*/
export function channelNeedsAttention(channel: Channel): boolean {
// Auto-disabled
if (channel.status === 3) {
return true
}
// Low balance (less than $1)
if (channel.balance > 0 && channel.balance < 1) {
return true
}
// Multi-key channel with all keys disabled
if (
channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list &&
Object.keys(channel.channel_info.multi_key_status_list).length >=
channel.channel_info.multi_key_size
) {
return true
}
return false
}
/**
* Get attention reason for channel
*/
export function getAttentionReason(channel: Channel): string | null {
if (channel.status === 3) {
return 'Auto-disabled'
}
if (channel.balance > 0 && channel.balance < 1) {
return 'Low balance'
}
if (
channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list &&
Object.keys(channel.channel_info.multi_key_status_list).length >=
channel.channel_info.multi_key_size
) {
return 'All keys disabled'
}
return null
}
// ============================================================================
// Tag Aggregation Utilities
// ============================================================================
/**
* Tag row type (extends Channel with children)
*/
export type TagRow = Channel & {
children: Channel[]
}
/**
* Type guard to check whether a row is a tag aggregate row
*/
export function isTagAggregateRow(row: Channel | TagRow): row is TagRow {
return Array.isArray((row as TagRow).children)
}
/**
* Aggregate channels by tag for tag mode display
* Converts flat array into tree structure grouped by tag
*/
export function aggregateChannelsByTag(
channels: Channel[]
): (Channel | TagRow)[] {
const tagMap = new Map<string, TagRow>()
const result: (Channel | TagRow)[] = []
for (const channel of channels) {
const tag = channel.tag || ''
if (!tagMap.has(tag)) {
// Create tag aggregate row
const tagRow = {
...channel,
key: tag,
id: tag as unknown as number,
tag,
name: tag,
type: 0,
status: undefined as unknown as number,
group: '',
used_quota: 0,
response_time: 0,
priority: -1 as unknown as number | null,
weight: -1 as unknown as number | null,
balance: 0,
test_time: 0,
created_time: 0,
balance_updated_time: 0,
models: '',
children: [],
} as TagRow
tagMap.set(tag, tagRow)
result.push(tagRow)
}
const tagRow = tagMap.get(tag)
if (!tagRow) {
continue
}
// Add to children
tagRow.children.push(channel)
const childCount = tagRow.children.length
// Aggregate used_quota (sum)
tagRow.used_quota += channel.used_quota
// Aggregate response_time (average)
tagRow.response_time =
(tagRow.response_time * (childCount - 1) + channel.response_time) /
childCount
// Aggregate priority (same value or null if different)
if (tagRow.priority === -1) {
tagRow.priority = channel.priority
} else if (tagRow.priority !== channel.priority) {
tagRow.priority = null
}
// Aggregate weight (same value or null if different)
if (tagRow.weight === -1) {
tagRow.weight = channel.weight
} else if (tagRow.weight !== channel.weight) {
tagRow.weight = null
}
// Aggregate group (concatenate and deduplicate)
if (tagRow.group === '') {
tagRow.group = channel.group
} else {
const existingGroups = new Set(tagRow.group.split(',').filter(Boolean))
const newGroups = channel.group.split(',').filter(Boolean)
newGroups.forEach((g) => {
if (!existingGroups.has(g)) {
tagRow.group += `,${g}`
}
})
}
// Aggregate status (enabled if any child is enabled)
if (channel.status === 1) {
tagRow.status = 1
} else if (tagRow.status === undefined) {
tagRow.status = channel.status
}
}
return result
}
// ============================================================================
// Key Management Utilities
// ============================================================================
/**
* Deduplicate keys from a multiline string
* @param keysText - Text with one key per line
* @returns Object with deduplicated keys and statistics
*/
export function deduplicateKeys(keysText: string): {
deduplicatedText: string
beforeCount: number
afterCount: number
removedCount: number
} {
if (!keysText || keysText.trim() === '') {
return {
deduplicatedText: '',
beforeCount: 0,
afterCount: 0,
removedCount: 0,
}
}
// Split by lines
const keyLines = keysText.split('\n')
const beforeCount = keyLines.length
// Use Set for deduplication, maintaining order
const keySet = new Set<string>()
const deduplicatedKeys: string[] = []
keyLines.forEach((line) => {
const trimmedLine = line.trim()
if (trimmedLine && !keySet.has(trimmedLine)) {
keySet.add(trimmedLine)
deduplicatedKeys.push(trimmedLine)
}
})
const afterCount = deduplicatedKeys.length
const deduplicatedText = deduplicatedKeys.join('\n')
return {
deduplicatedText,
beforeCount,
afterCount,
removedCount: beforeCount - afterCount,
}
}
/**
* Get key prompt based on channel type
*/
export function getKeyPromptForType(type: number): string {
return TYPE_TO_KEY_PROMPT[type] || 'Enter API key for this channel'
}
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
export * from './advanced-custom'
export * from './channel-form-errors'
export * from './channel-form'
export * from './channel-type-config'
export * from './channel-utils'
export * from './multi-key-utils'
export * from './model-mapping-validation'
@@ -0,0 +1,253 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// ============================================================================
// Model Mapping Validation Utilities
// ============================================================================
/**
* Parse models string to array
*/
export function parseModelsString(modelsStr: string): string[] {
return modelsStr
? modelsStr
.split(',')
.map((m) => m.trim())
.filter(Boolean)
: []
}
/**
* Format models array to string
*/
export function formatModelsArray(models: string[]): string {
return Array.from(new Set(models)).join(',')
}
/**
* Normalize model name
*/
export function normalizeModelName(model: string): string {
return typeof model === 'string' ? model.trim() : ''
}
/**
* Extract source keys from model_mapping JSON
* (the keys of the mapping object — models being remapped FROM)
*/
export function extractMappingSourceModels(modelMapping: string): string[] {
if (typeof modelMapping !== 'string') return []
const trimmed = modelMapping.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
const keys = Object.keys(parsed)
.map((key) => key.trim())
.filter(Boolean)
return Array.from(new Set(keys))
} catch {
return []
}
}
/**
* Extract redirect models from model_mapping JSON
*/
export function extractRedirectModels(modelMapping: string): string[] {
const mapping = modelMapping
if (typeof mapping !== 'string') return []
const trimmed = mapping.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
const values = Object.values(parsed)
.map((value) => (typeof value === 'string' ? value.trim() : undefined))
.filter((value): value is string => Boolean(value))
return Array.from(new Set(values))
} catch {
return []
}
}
/**
* Check if model configuration has changed
*/
export function hasModelConfigChanged(
currentModels: string[],
currentModelMapping: string,
initialModels: string[],
initialModelMapping: string
): boolean {
// Always return true if not editing (new channel)
if (initialModels.length === 0 && !initialModelMapping) {
return true
}
// Check if models array changed
if (currentModels.length !== initialModels.length) {
return true
}
for (let i = 0; i < currentModels.length; i++) {
if (currentModels[i] !== initialModels[i]) {
return true
}
}
// Check if model_mapping changed
const normalizedCurrent = (currentModelMapping || '').trim()
const normalizedInitial = (initialModelMapping || '').trim()
return normalizedCurrent !== normalizedInitial
}
/**
* Find models in model_mapping that are missing from the models list
*/
export function findMissingModelsInMapping(
modelMapping: string,
currentModels: string[]
): string[] {
if (!modelMapping || modelMapping.trim() === '') {
return []
}
let parsedMapping: Record<string, unknown>
try {
parsedMapping = JSON.parse(modelMapping)
if (
!parsedMapping ||
typeof parsedMapping !== 'object' ||
Array.isArray(parsedMapping)
) {
return []
}
} catch {
return []
}
const modelSet = new Set(currentModels.map((m) => normalizeModelName(m)))
const missingModels = Object.keys(parsedMapping)
.map((key) => normalizeModelName(key))
.filter((key) => key && !modelSet.has(key))
return Array.from(new Set(missingModels))
}
/**
* Validate model mapping JSON format
*/
export function validateModelMappingJson(modelMapping: string): {
valid: boolean
error?: string
} {
if (!modelMapping || modelMapping.trim() === '') {
return { valid: true }
}
try {
const parsed = JSON.parse(modelMapping)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
valid: false,
error: 'Model mapping must be a valid JSON object',
}
}
if (Object.values(parsed).some((value) => typeof value !== 'string')) {
return {
valid: false,
error: 'Model mapping values must be strings',
}
}
return { valid: true }
} catch {
return {
valid: false,
error: 'Model mapping must be valid JSON format',
}
}
}
/**
* Get redirect models that are also in the models list
* (These should be removed from models list to keep /v1/models clean)
*/
export function findExposedTargetModels(
modelMapping: string,
currentModels: string[]
): string[] {
const redirectModels = extractRedirectModels(modelMapping)
if (redirectModels.length === 0) return []
const normalizedModels = currentModels.map((m) => normalizeModelName(m))
const modelSet = new Set(normalizedModels)
return redirectModels.filter((model) =>
modelSet.has(normalizeModelName(model))
)
}
/**
* Categorize models into different sets for UI display
*/
export function categorizeModelsWithRedirect(
currentModels: string[],
redirectModels: string[]
): {
normalizedCurrentModels: Set<string>
normalizedRedirectModels: Set<string>
classificationSet: Set<string>
redirectOnlySet: Set<string>
} {
const normalizedCurrentModels = new Set(
currentModels.map((m) => normalizeModelName(m)).filter(Boolean)
)
const normalizedRedirectModels = new Set(
redirectModels.map((m) => normalizeModelName(m)).filter(Boolean)
)
const classificationSet = new Set([
...normalizedCurrentModels,
...normalizedRedirectModels,
])
const redirectOnlySet = new Set(
Array.from(normalizedRedirectModels).filter(
(m) => !normalizedCurrentModels.has(m)
)
)
return {
normalizedCurrentModels,
normalizedRedirectModels,
classificationSet,
redirectOnlySet,
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
MULTI_KEY_STATUS_CONFIG,
MULTI_KEY_CONFIRM_MESSAGES,
} from '../constants'
import type { MultiKeyConfirmAction } from '../types'
/**
* Get status badge configuration for multi-key status
*/
export function getMultiKeyStatusConfig(status: number) {
return (
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] || {
variant: 'neutral' as const,
label: 'Unknown',
}
)
}
/**
* Get confirmation message for multi-key action
*/
export function getMultiKeyConfirmMessage(
action: MultiKeyConfirmAction | null
): string {
if (!action) return ''
switch (action.type) {
case 'delete':
return MULTI_KEY_CONFIRM_MESSAGES.DELETE
case 'enable':
return MULTI_KEY_CONFIRM_MESSAGES.ENABLE
case 'disable':
return MULTI_KEY_CONFIRM_MESSAGES.DISABLE
case 'enable-all':
return MULTI_KEY_CONFIRM_MESSAGES.ENABLE_ALL
case 'disable-all':
return MULTI_KEY_CONFIRM_MESSAGES.DISABLE_ALL
case 'delete-disabled':
return MULTI_KEY_CONFIRM_MESSAGES.DELETE_DISABLED
default:
return ''
}
}
/**
* Check if action is destructive
*/
export function isDestructiveAction(
action: MultiKeyConfirmAction | null
): boolean {
if (!action) return false
return (
action.type === 'delete' ||
action.type === 'delete-disabled' ||
action.type === 'disable-all'
)
}
+161
View File
@@ -0,0 +1,161 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Channel } from '../types'
export type PullProgress = {
status?: string
completed?: number
total?: number
// backend may include extra fields
[k: string]: unknown
}
export type OllamaModel = {
id: string
owned_by?: string
size?: number
digest?: string
modified_at?: string
details?: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function getNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function parseMaybeJSON(value: unknown) {
if (!value) return null
if (typeof value === 'object') return value
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return null
}
}
return null
}
/**
* Resolve Ollama base URL from channel fields (supports legacy/alternate fields).
*/
export function resolveOllamaBaseUrl(channel: Channel | null) {
if (!channel) return ''
const direct =
typeof channel.base_url === 'string' ? channel.base_url.trim() : ''
if (direct) return direct
const alt =
typeof (channel as unknown as { ollama_base_url?: unknown })
?.ollama_base_url === 'string'
? String(
(channel as unknown as { ollama_base_url?: string }).ollama_base_url
).trim()
: ''
if (alt) return alt
const parsed = parseMaybeJSON(channel.other_info)
if (isRecord(parsed)) {
const baseUrl = getString(parsed.base_url)?.trim()
if (baseUrl) return baseUrl
const publicUrl = getString(parsed.public_url)?.trim()
if (publicUrl) return publicUrl
const apiUrl = getString(parsed.api_url)?.trim()
if (apiUrl) return apiUrl
}
return ''
}
export function normalizeOllamaModels(items: unknown): OllamaModel[] {
if (!Array.isArray(items)) return []
return items
.map((item) => {
if (!item) return null
if (typeof item === 'string') {
return { id: item, owned_by: 'ollama' } satisfies OllamaModel
}
if (isRecord(item)) {
const candidateId =
getString(item.id) ||
getString(item.ID) ||
getString(item.name) ||
getString(item.model) ||
getString(item.Model)
if (!candidateId) return null
const metadata = item.metadata ?? item.Metadata
const normalized: OllamaModel = {
...item,
id: candidateId,
owned_by:
getString(item.owned_by) || getString(item.ownedBy) || 'ollama',
}
const itemSize = getNumber(item.size)
if (typeof itemSize === 'number' && !normalized.size) {
normalized.size = itemSize
}
if (isRecord(metadata)) {
const metaSize = getNumber(metadata.size)
if (typeof metaSize === 'number' && !normalized.size) {
normalized.size = metaSize
}
const metaDigest = getString(metadata.digest)
if (!normalized.digest && metaDigest) {
normalized.digest = metaDigest
}
const metaModifiedAt = getString(metadata.modified_at)
if (!normalized.modified_at && metaModifiedAt) {
normalized.modified_at = metaModifiedAt
}
if (metadata.details && !normalized.details) {
normalized.details = metadata.details
}
}
return normalized
}
return null
})
.filter(Boolean) as OllamaModel[]
}
export function formatBytes(bytes?: number) {
if (typeof bytes !== 'number' || Number.isNaN(bytes)) return '-'
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
if (mb < 1024) return `${mb.toFixed(1)} MB`
const gb = mb / 1024
return `${gb.toFixed(2)} GB`
}
+103
View File
@@ -0,0 +1,103 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524])
function parseStatusCodeKey(rawKey: string): number | null {
const normalized = rawKey.trim()
if (!/^[1-5]\d{2}$/.test(normalized)) return null
return Number.parseInt(normalized, 10)
}
function parseStatusCodeMappingTarget(rawValue: unknown): number | null {
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
return rawValue >= 100 && rawValue <= 599 ? rawValue : null
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim()
if (!/^[1-5]\d{2}$/.test(normalized)) return null
const code = Number.parseInt(normalized, 10)
return code >= 100 && code <= 599 ? code : null
}
return null
}
export function collectInvalidStatusCodeEntries(
statusCodeMappingStr: string
): string[] {
if (!statusCodeMappingStr?.trim()) return []
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(statusCodeMappingStr)
} catch {
return []
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
const invalid: string[] = []
for (const [rawKey, rawValue] of Object.entries(parsed)) {
const fromCode = parseStatusCodeKey(rawKey)
const toCode = parseStatusCodeMappingTarget(rawValue)
if (fromCode === null || toCode === null) {
invalid.push(`${rawKey}${rawValue}`)
}
}
return invalid
}
export function collectDisallowedStatusCodeRedirects(
statusCodeMappingStr: string
): string[] {
if (!statusCodeMappingStr?.trim()) return []
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(statusCodeMappingStr)
} catch {
return []
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
const riskyMappings: string[] = []
for (const [rawFrom, rawTo] of Object.entries(parsed)) {
const fromCode = parseStatusCodeKey(rawFrom)
const toCode = parseStatusCodeMappingTarget(rawTo)
if (fromCode === null || toCode === null) continue
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) continue
if (fromCode === toCode) continue
riskyMappings.push(`${fromCode} -> ${toCode}`)
}
return [...new Set(riskyMappings)].sort()
}
export function collectNewDisallowedStatusCodeRedirects(
originalStr: string,
currentStr: string
): string[] {
const currentRisky = collectDisallowedStatusCodeRedirects(currentStr)
if (currentRisky.length === 0) return []
const originalRiskySet = new Set(
collectDisallowedStatusCodeRedirects(originalStr)
)
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping))
}
+56
View File
@@ -0,0 +1,56 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export function normalizeModelList(models: unknown[] = []): string[] {
return Array.from(
new Set(
(models || []).map((model) => String(model || '').trim()).filter(Boolean)
)
)
}
export function parseUpstreamUpdateMeta(settings: unknown): {
enabled: boolean
pendingAddModels: string[]
pendingRemoveModels: string[]
} {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object' && !Array.isArray(settings)) {
parsed = settings as Record<string, unknown>
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings)
} catch {
parsed = null
}
}
if (!parsed || typeof parsed !== 'object') {
return { enabled: false, pendingAddModels: [], pendingRemoveModels: [] }
}
return {
enabled: parsed.upstream_model_update_check_enabled === true,
pendingAddModels: normalizeModelList(
(parsed.upstream_model_update_last_detected_models as unknown[]) || []
),
pendingRemoveModels: normalizeModelList(
(parsed.upstream_model_update_last_removed_models as unknown[]) || []
),
}
}
+377
View File
@@ -0,0 +1,377 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
// ============================================================================
// Channel Schema & Types
// ============================================================================
export const channelInfoSchema = z.object({
is_multi_key: z.boolean().default(false),
multi_key_size: z.number().default(0),
multi_key_status_list: z.record(z.string(), z.number()).optional(),
multi_key_disabled_reason: z.record(z.string(), z.string()).optional(),
multi_key_disabled_time: z.record(z.string(), z.number()).optional(),
multi_key_polling_index: z.number().default(0),
multi_key_mode: z.enum(['random', 'polling']).default('random'),
})
export type ChannelInfo = z.infer<typeof channelInfoSchema>
export const channelSchema = z.object({
id: z.number(),
type: z.number(),
key: z.string(),
openai_organization: z.string().nullish(),
test_model: z.string().nullish(),
status: z.number(), // 1: enabled, 0: manual disabled, 2: auto disabled
name: z.string(),
weight: z.number().nullish(),
created_time: z.number(),
test_time: z.number(),
response_time: z.number(), // in milliseconds
base_url: z.string().nullish(),
other: z.string().default(''),
balance: z.number().default(0), // in USD
balance_updated_time: z.number(),
models: z.string().default(''),
group: z.string().default('default'),
used_quota: z.number().default(0),
model_mapping: z.string().nullish(),
status_code_mapping: z.string().nullish(),
priority: z.number().nullish(),
auto_ban: z.number().nullish(),
other_info: z.string().default(''),
tag: z.string().nullish(),
setting: z.string().nullish(),
param_override: z.string().nullish(),
header_override: z.string().nullish(),
remark: z.string().default(''),
max_input_tokens: z.number().default(0),
channel_info: channelInfoSchema.default({
is_multi_key: false,
multi_key_size: 0,
multi_key_polling_index: 0,
multi_key_mode: 'random',
}),
settings: z.string().default('{}'), // other_settings JSON
})
export type Channel = z.infer<typeof channelSchema>
// ============================================================================
// Channel Settings Types
// ============================================================================
export interface ChannelSettings {
force_format?: boolean
thinking_to_content?: boolean
proxy?: string
pass_through_body_enabled?: boolean
system_prompt?: string
system_prompt_override?: boolean
}
export interface ChannelOtherSettings {
azure_responses_version?: string
vertex_key_type?: 'json' | 'api_key'
openrouter_enterprise?: boolean
aws_key_type?: 'ak_sk' | 'api_key'
allow_service_tier?: boolean
disable_store?: boolean
allow_safety_identifier?: boolean
allow_include_obfuscation?: boolean
allow_inference_geo?: boolean
allow_speed?: boolean
claude_beta_query?: boolean
disable_task_polling_sleep?: boolean
upstream_model_update_check_enabled?: boolean
upstream_model_update_auto_sync_enabled?: boolean
upstream_model_update_ignored_models?: string[]
upstream_model_update_last_check_time?: number
upstream_model_update_last_detected_models?: string[]
advanced_custom?: AdvancedCustomConfig
}
export interface AdvancedCustomConfig {
advanced_routes?: AdvancedCustomRoute[]
}
export interface AdvancedCustomRoute {
incoming_path?: string
upstream_path?: string
converter?: AdvancedCustomConverter
models?: string[]
auth?: AdvancedCustomRouteAuth
}
export interface AdvancedCustomRouteAuth {
type?: AdvancedCustomAuthType
name?: string
value?: string
}
export type AdvancedCustomConverter =
| 'none'
| 'anthropic_messages_to_openai_chat_completions'
| 'openai_chat_completions_to_anthropic_messages'
| 'openai_chat_completions_to_openai_responses'
| 'openai_responses_to_openai_chat_completions'
| 'openai_responses_to_gemini_generate_content'
| 'gemini_generate_content_to_openai_chat_completions'
| 'openai_chat_completions_to_gemini_generate_content'
export type AdvancedCustomAuthType = 'none' | 'header' | 'query'
// ============================================================================
// API Response Types
// ============================================================================
export interface GetChannelsResponse {
success: boolean
message?: string
data?: {
items: Channel[]
total: number
page: number
page_size: number
type_counts?: Record<string, number>
}
}
export interface SearchChannelsResponse {
success: boolean
message?: string
data?: {
items: Channel[]
total: number
type_counts?: Record<string, number>
}
}
export interface GetChannelResponse {
success: boolean
message?: string
data?: Channel
}
export interface ChannelOpsResponse {
success: boolean
message?: string
data?: {
retry_times: number
}
}
export interface ChannelTestResponse {
success: boolean
message?: string
error_code?: string
time?: number
data?: {
response_time?: number
error?: string
}
}
export interface ChannelBalanceResponse {
success: boolean
message?: string
balance?: number
currency?: string
}
export interface FetchModelsResponse {
success: boolean
message?: string
data?: string[]
}
export interface CopyChannelResponse {
success: boolean
message?: string
data?: {
id: number
}
}
// ============================================================================
// Multi-Key Management Types
// ============================================================================
export interface KeyStatus {
index: number
status: number // 1: enabled, 2: manual disabled, 3: auto disabled
disabled_time?: number
reason?: string
key_preview?: string
}
export type MultiKeyConfirmAction = {
type:
| 'enable'
| 'disable'
| 'delete'
| 'enable-all'
| 'disable-all'
| 'delete-disabled'
keyIndex?: number
}
export interface MultiKeyStatusResponse {
success: boolean
message?: string
data?: {
keys: KeyStatus[]
total: number
page: number
page_size: number
total_pages: number
enabled_count: number
manual_disabled_count: number
auto_disabled_count: number
}
}
// ============================================================================
// API Request Parameters
// ============================================================================
export type ChannelSortBy =
| 'id'
| 'name'
| 'priority'
| 'balance'
| 'response_time'
| 'test_time'
export type ChannelSortOrder = 'asc' | 'desc'
export interface GetChannelsParams {
p?: number
page_size?: number
status?: string // 'enabled', 'disabled', or empty for all
type?: number
group?: string
id_sort?: boolean
tag_mode?: boolean
sort_by?: ChannelSortBy
sort_order?: ChannelSortOrder
}
export interface SearchChannelsParams {
keyword?: string
group?: string
model?: string
status?: string
type?: number
id_sort?: boolean
tag_mode?: boolean
sort_by?: ChannelSortBy
sort_order?: ChannelSortOrder
p?: number
page_size?: number
}
export interface ChannelTestParams {
test_model?: string
}
export interface CopyChannelParams {
suffix?: string
reset_balance?: boolean
}
export interface MultiKeyManageParams {
channel_id: number
action:
| 'get_key_status'
| 'disable_key'
| 'enable_key'
| 'enable_all_keys'
| 'disable_all_keys'
| 'delete_key'
| 'delete_disabled_keys'
key_index?: number
page?: number
page_size?: number
status?: number // 1=enabled, 2=manual_disabled, 3=auto_disabled
}
export interface BatchDeleteParams {
ids: number[]
}
export interface BatchSetTagParams {
ids: number[]
tag: string | null
}
export interface TagOperationParams {
tag: string
new_tag?: string
priority?: number
weight?: number
model_mapping?: string
models?: string
groups?: string
}
// ============================================================================
// Form Data Types
// ============================================================================
export interface ChannelFormData {
name: string
type: number
base_url: string
key: string
openai_organization?: string
models: string
group: string
model_mapping?: string
priority?: number
weight?: number
test_model?: string
auto_ban?: number
status: number
status_code_mapping?: string
tag?: string
remark?: string
setting?: string
param_override?: string
header_override?: string
settings?: string
other?: string
// Multi-key specific
multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
multi_key_type?: 'random' | 'polling'
batch_add_set_key_prefix_2_name?: boolean
}
// ============================================================================
// Add Channel Request (special structure)
// ============================================================================
export interface AddChannelRequest {
mode: 'single' | 'batch' | 'multi_to_single'
multi_key_mode?: 'random' | 'polling'
batch_add_set_key_prefix_2_name?: boolean
channel: Partial<Channel>
}