/* 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 { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { Plus, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Textarea } from '@/components/ui/textarea' import { SettingsSwitchField } from '../../components/settings-form-layout' import { RULE_TEMPLATES } from './constants' import type { AffinityRule, KeySource } from './types' const KEY_SOURCE_TYPES = [ 'context_int', 'context_string', 'request_header', 'gjson', ] as const const CONTEXT_KEY_PRESETS = [ 'id', 'token_id', 'token_key', 'token_group', 'group', 'username', 'user_group', 'user_email', 'specific_channel_id', ] interface RuleFormValues { name: string model_regex_text: string path_regex_text: string user_agent_include_text: string value_regex: string ttl_seconds: number skip_retry_on_failure: boolean include_using_group: boolean include_model_name: boolean include_rule_name: boolean param_override_template_json: string } function normalizeStringList(text: string): string[] { return text .split('\n') .map((s) => s.trim()) .filter((s) => s.length > 0) } function normalizeKeySource(src: Partial): KeySource { const type = (src?.type || 'gjson') as KeySource['type'] if (type === 'gjson') return { type, key: '', path: src?.path || '' } return { type, key: src?.key || '', path: '' } } interface Props { open: boolean onOpenChange: (open: boolean) => void rule: AffinityRule | null onSave: (rule: AffinityRule) => void templateKey?: string | null } export function RuleEditorDialog(props: Props) { const { t } = useTranslation() const isEdit = !!props.rule?.name const [keySources, setKeySources] = useState([ { type: 'gjson', path: '' }, ]) const [advancedOpen, setAdvancedOpen] = useState(false) const form = useForm({ defaultValues: { name: '', model_regex_text: '', path_regex_text: '', user_agent_include_text: '', value_regex: '', ttl_seconds: 0, skip_retry_on_failure: false, include_using_group: true, include_model_name: false, include_rule_name: true, param_override_template_json: '', }, }) const resetFromRule = (r: Partial) => { form.reset({ name: r.name || '', model_regex_text: (r.model_regex || []).join('\n'), path_regex_text: (r.path_regex || []).join('\n'), user_agent_include_text: (r.user_agent_include || []).join('\n'), value_regex: r.value_regex || '', ttl_seconds: r.ttl_seconds || 0, skip_retry_on_failure: !!r.skip_retry_on_failure, include_using_group: r.include_using_group ?? true, include_model_name: !!r.include_model_name, include_rule_name: r.include_rule_name ?? true, param_override_template_json: r.param_override_template ? JSON.stringify(r.param_override_template, null, 2) : '', }) const sources = (r.key_sources || []).map(normalizeKeySource) setKeySources(sources.length > 0 ? sources : [{ type: 'gjson', path: '' }]) if (r.param_override_template) setAdvancedOpen(true) } useEffect(() => { if (!props.open) return if (props.rule) { resetFromRule(props.rule) } else if (props.templateKey && RULE_TEMPLATES[props.templateKey]) { resetFromRule(RULE_TEMPLATES[props.templateKey]) } else { form.reset({ name: '', model_regex_text: '', path_regex_text: '', user_agent_include_text: '', value_regex: '', ttl_seconds: 0, skip_retry_on_failure: false, include_using_group: true, include_model_name: false, include_rule_name: true, param_override_template_json: '', }) setKeySources([{ type: 'gjson', path: '' }]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.open, props.rule, props.templateKey]) const handleSave = (values: RuleFormValues) => { const modelRegex = normalizeStringList(values.model_regex_text) if (modelRegex.length === 0) { toast.error(t('At least one model regex pattern is required')) return } const validKeySources = keySources .map(normalizeKeySource) .filter((s) => s.type && (s.type === 'gjson' ? s.path : s.key)) if (validKeySources.length === 0) { toast.error(t('At least one valid key source is required')) return } let paramTemplate: Record | null = null if (values.param_override_template_json.trim()) { try { const parsed = JSON.parse(values.param_override_template_json) if ( typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null ) { toast.error(t('Parameter override template must be a JSON object')) return } paramTemplate = parsed } catch { toast.error(t('Invalid JSON in parameter override template')) return } } const rule: AffinityRule = { id: props.rule?.id, name: values.name.trim(), model_regex: modelRegex, path_regex: normalizeStringList(values.path_regex_text), user_agent_include: normalizeStringList(values.user_agent_include_text), key_sources: validKeySources, value_regex: values.value_regex.trim(), ttl_seconds: Number(values.ttl_seconds || 0), skip_retry_on_failure: values.skip_retry_on_failure, include_using_group: values.include_using_group, include_model_name: values.include_model_name, include_rule_name: values.include_rule_name, param_override_template: paramTemplate, } props.onSave(rule) props.onOpenChange(false) } return ( {isEdit ? t('Edit Rule') : t('Add Rule')}