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:
Vendored
+648
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+1449
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>
|
||||
)
|
||||
}
|
||||
+103
@@ -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>
|
||||
)
|
||||
}
|
||||
+3343
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
+99
@@ -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>
|
||||
)
|
||||
}
|
||||
+46
@@ -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>
|
||||
)
|
||||
}
|
||||
+44
@@ -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>
|
||||
)
|
||||
}
|
||||
+46
@@ -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>
|
||||
)
|
||||
}
|
||||
+45
@@ -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>
|
||||
)
|
||||
}
|
||||
+46
@@ -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
@@ -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,
|
||||
]
|
||||
)
|
||||
}
|
||||
Vendored
+107
@@ -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
@@ -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
@@ -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'))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(',')
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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[]) || []
|
||||
),
|
||||
}
|
||||
}
|
||||
Vendored
+377
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user