/* 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 . For commercial licensing, please contact support@quantumnous.com */ import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react' import { useCallback, useEffect, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { StaticDataTable } from '@/components/data-table' import { Dialog } from '@/components/dialog' import { JsonCodeEditor } from '@/components/json-code-editor' import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator' import { SettingsSwitchField } from '../../components/settings-form-layout' import { SettingsPageActionsPortal } from '../../components/settings-page-context' import { SettingsSection } from '../../components/settings-section' import { useUpdateOption } from '../../hooks/use-update-option' import { getCacheStats, clearAllCache, clearRuleCache } from './api' import { RULE_TEMPLATES, cloneTemplate, makeUniqueName } from './constants' import { RuleEditorDialog } from './rule-editor-dialog' import type { AffinityRule, CacheStats, ChannelAffinitySettings } from './types' function parseRules(jsonStr: string): AffinityRule[] { try { const arr = JSON.parse(jsonStr || '[]') if (!Array.isArray(arr)) return [] return arr.map( (r: Record, i: number) => ({ id: i, ...r }) as AffinityRule ) } catch { return [] } } function RuleBadgeList(props: { items: string[] }) { return ( item} renderItem={(item) => ( )} /> ) } function ChannelAffinityConfirmDialog(props: { open: boolean onOpenChange: (open: boolean) => void title: ReactNode desc: ReactNode handleConfirm: () => void destructive?: boolean }) { const { t } = useTranslation() return ( } >
{props.desc}
) } function serializeRules(rules: AffinityRule[]): string { return JSON.stringify(rules.map(({ id: _, ...rest }) => rest)) } interface Props { defaultValues: ChannelAffinitySettings } export function ChannelAffinitySection(props: Props) { const { t } = useTranslation() const updateOption = useUpdateOption() const [enabled, setEnabled] = useState( props.defaultValues['channel_affinity_setting.enabled'] ) const [switchOnSuccess, setSwitchOnSuccess] = useState( props.defaultValues['channel_affinity_setting.switch_on_success'] ) const [keepOnChannelDisabled, setKeepOnChannelDisabled] = useState( props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'] ) const [maxEntries, setMaxEntries] = useState( props.defaultValues['channel_affinity_setting.max_entries'] ) const [defaultTtl, setDefaultTtl] = useState( props.defaultValues['channel_affinity_setting.default_ttl_seconds'] ) const [rules, setRules] = useState(() => parseRules(props.defaultValues['channel_affinity_setting.rules']) ) const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') const [jsonText, setJsonText] = useState(() => JSON.stringify( parseRules(props.defaultValues['channel_affinity_setting.rules']).map( ({ id: _, ...r }) => r ), null, 2 ) ) const [cacheStats, setCacheStats] = useState(null) const [cacheLoading, setCacheLoading] = useState(false) const [saving, setSaving] = useState(false) const [ruleEditorOpen, setRuleEditorOpen] = useState(false) const [editingRule, setEditingRule] = useState(null) const [ruleTemplateKey, setRuleTemplateKey] = useState(null) const [clearAllDialogOpen, setClearAllDialogOpen] = useState(false) const [clearRuleName, setClearRuleName] = useState(null) const [fillTemplateDialogOpen, setFillTemplateDialogOpen] = useState(false) useEffect(() => { setEnabled(props.defaultValues['channel_affinity_setting.enabled']) setSwitchOnSuccess( props.defaultValues['channel_affinity_setting.switch_on_success'] ) setKeepOnChannelDisabled( props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'] ) setMaxEntries(props.defaultValues['channel_affinity_setting.max_entries']) setDefaultTtl( props.defaultValues['channel_affinity_setting.default_ttl_seconds'] ) const parsed = parseRules( props.defaultValues['channel_affinity_setting.rules'] ) setRules(parsed) setJsonText( JSON.stringify( parsed.map(({ id: _, ...r }) => r), null, 2 ) ) }, [props.defaultValues]) const refreshCache = useCallback(async () => { setCacheLoading(true) try { const res = await getCacheStats() if (res.success) setCacheStats(res.data || null) } catch { toast.error(t('Failed to refresh cache stats')) } finally { setCacheLoading(false) } }, [t]) useEffect(() => { refreshCache() }, [refreshCache]) const appendCliTemplates = () => { const existingNames = new Set( rules.map((r) => (r.name || '').trim()).filter((x) => x.length > 0) ) const templates = Object.values(RULE_TEMPLATES).map((tpl) => { const base = cloneTemplate(tpl) const name = makeUniqueName(existingNames, tpl.name) existingNames.add(name) return { ...base, name } }) setRules((prev) => [...prev, ...templates].map((r, idx) => ({ ...r, id: idx })) ) toast.success(t('Templates appended')) setFillTemplateDialogOpen(false) } const handleFillTemplates = () => { if (rules.length === 0) { appendCliTemplates() } else { setFillTemplateDialogOpen(true) } } const handleSave = async () => { let rulesJson: string if (editMode === 'json') { try { const parsed = JSON.parse(jsonText) if (!Array.isArray(parsed)) { toast.error(t('Rules JSON must be an array')) return } rulesJson = JSON.stringify(parsed) } catch { toast.error(t('Invalid rules JSON format')) return } } else { rulesJson = serializeRules(rules) } setSaving(true) try { const updates: { key: string; value: string }[] = [] if (enabled !== props.defaultValues['channel_affinity_setting.enabled']) { updates.push({ key: 'channel_affinity_setting.enabled', value: String(enabled), }) } if ( switchOnSuccess !== props.defaultValues['channel_affinity_setting.switch_on_success'] ) { updates.push({ key: 'channel_affinity_setting.switch_on_success', value: String(switchOnSuccess), }) } if ( keepOnChannelDisabled !== props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'] ) { updates.push({ key: 'channel_affinity_setting.keep_on_channel_disabled', value: String(keepOnChannelDisabled), }) } if ( maxEntries !== props.defaultValues['channel_affinity_setting.max_entries'] ) { updates.push({ key: 'channel_affinity_setting.max_entries', value: String(maxEntries), }) } if ( defaultTtl !== props.defaultValues['channel_affinity_setting.default_ttl_seconds'] ) { updates.push({ key: 'channel_affinity_setting.default_ttl_seconds', value: String(defaultTtl), }) } const origRules = props.defaultValues['channel_affinity_setting.rules'] const origSerialized = (() => { try { return JSON.stringify(JSON.parse(origRules || '[]')) } catch { return '[]' } })() if (rulesJson !== origSerialized) { updates.push({ key: 'channel_affinity_setting.rules', value: rulesJson, }) } if (updates.length === 0) { toast.info(t('No changes')) return } for (const u of updates) { await updateOption.mutateAsync(u) } toast.success(t('Saved successfully')) } catch { toast.error(t('Failed to save')) } finally { setSaving(false) } } const handleRuleSave = (rule: AffinityRule) => { setRules((prev) => { const existIdx = prev.findIndex( (r) => r.id === rule.id || (rule.name && r.name === editingRule?.name) ) if (existIdx >= 0) { const next = [...prev] next[existIdx] = { ...rule, id: existIdx } return next } return [...prev, { ...rule, id: prev.length }] }) setEditingRule(null) } const handleDeleteRule = (idx: number) => { setRules((prev) => prev.filter((_, i) => i !== idx).map((r, i) => ({ ...r, id: i })) ) toast.success(t('Deleted successfully')) } const handleClearAll = async () => { const res = await clearAllCache() if (res.success) { toast.success(t('Cleared')) refreshCache() } setClearAllDialogOpen(false) } const handleClearRule = async () => { if (!clearRuleName) return const res = await clearRuleCache(clearRuleName) if (res.success) { toast.success(t('Cleared')) refreshCache() } setClearRuleName(null) } const switchToJsonMode = () => { setJsonText( JSON.stringify( rules.map(({ id: _, ...r }) => r), null, 2 ) ) setEditMode('json') } const switchToVisualMode = () => { try { const parsed = JSON.parse(jsonText) if (!Array.isArray(parsed)) { toast.error(t('Rules JSON must be an array')) return } setRules( parsed.map( (r: Record, i: number) => ({ id: i, ...r }) as AffinityRule ) ) setEditMode('visual') } catch { toast.error(t('Invalid rules JSON format')) } } return ( <> {t( 'Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.' )} {/* Basic Settings */}
setMaxEntries(Number(e.target.value))} />
setDefaultTtl(Number(e.target.value))} />
} > {t('Add Rule')} { setEditingRule(null) setRuleTemplateKey(null) setRuleEditorOpen(true) }} > {t('Blank Rule')} { setEditingRule(null) setRuleTemplateKey('codexCli') setRuleEditorOpen(true) }} > Codex CLI { setEditingRule(null) setRuleTemplateKey('claudeCli') setRuleEditorOpen(true) }} > Claude CLI {cacheStats && ( {t('Cache Entries')}: {cacheStats.total} /{' '} {cacheStats.cache_capacity} )} {/* Rules Table or JSON Editor */} {editMode === 'visual' ? ( rule.name || '-', }, { id: 'model-regex', header: t('Model Regex'), cell: (rule) => ( ), }, { id: 'key-sources', header: t('Key Sources'), cell: (rule) => ( `${src.type}:${src.type === 'gjson' ? src.path : src.key}` )} /> ), }, { id: 'ttl', header: t('TTL'), cell: (rule) => rule.ttl_seconds || '-', }, { id: 'retry', header: t('Retry'), cell: (rule) => ( ), }, { id: 'scope', header: t('Scope'), cell: (rule) => { const scopeItems = [ rule.include_using_group && t('Group'), rule.include_model_name && t('Model'), rule.include_rule_name && t('Rule'), ].filter(Boolean) as string[] if (scopeItems.length === 0) return '-' return }, }, { id: 'cache', header: t('Cache'), cell: (rule) => rule.include_rule_name && cacheStats?.by_rule_name ? cacheStats.by_rule_name[rule.name] || 0 : 'N/A', }, { id: 'actions', header: t('Actions'), className: 'text-right', cellClassName: 'text-right', cell: (rule, idx) => (
{rule.include_rule_name && ( )}
), }, ]} /> ) : (
)}
{clearRuleName !== null && ( !v && setClearRuleName(null)} title={t('Confirm clearing cache for this rule')} desc={`${t('Rule')}: ${clearRuleName}`} handleConfirm={handleClearRule} destructive /> )} ) }