Files
new-api/web/default/src/features/channels/lib/channel-utils.ts
T

770 lines
19 KiB
TypeScript
Vendored

/*
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' | 'destructive' | 'neutral' {
if (balance === 0) {
return 'neutral'
}
if (balance < 1) {
return 'destructive'
}
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'
}