/* 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 { useCallback, useEffect, useState } from 'react' import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge, StatusBadgeList } from '@/components/status-badge' 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 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 [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'] ) 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 ( 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 */} {t('Max Entries')} setMaxEntries(Number(e.target.value))} /> {t('Default TTL (seconds)')} setDefaultTtl(Number(e.target.value))} /> {t('Visual')} JSON } > {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 {t('Fill Templates')} {saving ? t('Saving...') : t('Save')} {t('Refresh Cache')} setClearAllDialogOpen(true)} > {t('Clear All Cache')} {cacheStats && ( {t('Cache Entries')}: {cacheStats.total} /{' '} {cacheStats.cache_capacity} )} {/* Rules Table or JSON Editor */} {editMode === 'visual' ? ( {t('Name')} {t('Model Regex')} {t('Key Sources')} {t('TTL')} {t('Retry')} {t('Scope')} {t('Cache')} {t('Actions')} {rules.length === 0 ? ( {t('No rules yet')} ) : ( rules.map((rule, idx) => ( {rule.name || '-'} `${src.type}:${src.type === 'gjson' ? src.path : src.key}` )} /> {rule.ttl_seconds || '-'} {(() => { 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 })()} {rule.include_rule_name && cacheStats?.by_rule_name ? cacheStats.by_rule_name[rule.name] || 0 : 'N/A'} {rule.include_rule_name && ( setClearRuleName(rule.name)} title={t('Clear cache for this rule')} > )} { setEditingRule(rule) setRuleTemplateKey(null) setRuleEditorOpen(true) }} > handleDeleteRule(idx)} > )) )} ) : ( {t('Rules JSON')} setJsonText(e.target.value)} /> )} {clearRuleName !== null && ( !v && setClearRuleName(null)} title={t('Confirm clearing cache for this rule')} desc={`${t('Rule')}: ${clearRuleName}`} handleConfirm={handleClearRule} destructive /> )} > ) }