Files
new-api/web/src/features/system-settings/request-limits/rate-limit-section.tsx
T
QuentinHsu eb4a1bd193 perf(json-editor): unify admin JSON editing experience (#6421)
* perf(json-editor): improve JSON editing experience

- integrate Yace for syntax highlighting, history, indentation, auto-closing, and smart line breaks.
- add copy support, cursor location feedback, and synchronized content and line-number scrolling.
- extract JSON editor utilities and cover key interactions with unit tests.

* perf(system-settings): improve JSON configuration editing

- replace raw JSON textareas with the shared editor for highlighting, validation, copy, and formatting.
- preserve field-specific examples and make placeholders visible through the transparent editor layer.
- remove duplicate formatting controls while keeping existing form validation and save behavior.

* perf(json-editor): standardize JSON inputs across admin settings

- replace pure JSON textareas with the shared editor across system settings and channel workflows.
- preserve form focus, validation, placeholders, and visual or JSON editing modes.
- add happy-dom component coverage for form bindings, controlled updates, and formatting.

* fix(json-code-editor): address accessibility review findings

- Drop the unconditional aria-label that overrode every field's
  label-derived accessible name; add an optional ariaLabel prop and
  set it at call sites without an associated label
- Associate standalone Labels via htmlFor/id in channel-affinity views
- Hide the highlight mirror and line-number layers from the
  accessibility tree (aria-hidden)
- Give the line-number gutter an opaque background so horizontally
  scrolled code no longer slides under it
- Degrade to no scroll sync instead of destroying the editor when the
  line-number layer is not found
2026-07-25 19:10:28 +08:00

325 lines
11 KiB
TypeScript

/*
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 { zodResolver } from '@hookform/resolvers/zod'
import { Code2, Palette } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import { JsonCodeEditor } from '@/components/json-code-editor'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { RateLimitVisualEditor } from './rate-limit-visual-editor'
const isValidJSON = (value: string | undefined) => {
if (!value || value.trim() === '') return true
try {
const parsed = JSON.parse(value)
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
return false
}
for (const [, val] of Object.entries(parsed)) {
if (!Array.isArray(val) || val.length !== 2) return false
if (typeof val[0] !== 'number' || typeof val[1] !== 'number') return false
if (val[0] < 0 || val[1] < 1) return false
if (val[0] > 2147483647 || val[1] > 2147483647) return false
}
return true
} catch {
return false
}
}
const createRateLimitSchema = (t: (key: string) => string) =>
z.object({
ModelRequestRateLimitEnabled: z.boolean(),
ModelRequestRateLimitDurationMinutes: z.number().min(0),
ModelRequestRateLimitCount: z.number().min(0).max(100000000),
ModelRequestRateLimitSuccessCount: z.number().min(1).max(100000000),
ModelRequestRateLimitGroup: z
.string()
.optional()
.refine(isValidJSON, {
message: t('Invalid JSON format or values out of allowed range'),
}),
})
type RateLimitFormValues = z.infer<ReturnType<typeof createRateLimitSchema>>
type RateLimitSectionProps = {
defaultValues: RateLimitFormValues
}
export function RateLimitSection({ defaultValues }: RateLimitSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [useVisualEditor, setUseVisualEditor] = useState(true)
const rateLimitSchema = createRateLimitSchema(t)
const form = useForm<RateLimitFormValues>({
resolver: zodResolver(rateLimitSchema),
mode: 'onChange', // Enable real-time validation
defaultValues,
})
useEffect(() => {
form.reset(defaultValues)
}, [defaultValues, form])
const onSubmit = async (values: RateLimitFormValues) => {
const updates = Object.entries(values).filter(
([key, value]) =>
value !== defaultValues[key as keyof RateLimitFormValues]
)
for (const [key, value] of updates) {
await updateOption.mutateAsync({ key, value: value ?? '' })
}
}
return (
<SettingsSection title={t('Rate Limiting')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save rate limits'
/>
<FormField
control={form.control}
name='ModelRequestRateLimitEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable rate limiting')}</FormLabel>
<FormDescription>
{t(
'This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<div className='grid gap-4 md:grid-cols-3'>
<FormField
control={form.control}
name='ModelRequestRateLimitDurationMinutes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Limit period')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={0}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
/>
<span className='text-muted-foreground text-sm'>
{t('minutes')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Time window for rate limiting')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ModelRequestRateLimitCount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max requests per period')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={0}
max={100000000}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Including failed requests, 0 = unlimited')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ModelRequestRateLimitSuccessCount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max successful requests')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={1}
max={100000000}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Only successful requests')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='ModelRequestRateLimitGroup'
render={({ field }) => (
<FormItem>
<div className='flex items-center justify-between'>
<FormLabel>{t('Group-based rate limits')}</FormLabel>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setUseVisualEditor(!useVisualEditor)}
>
{useVisualEditor ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('JSON Mode')}
</>
) : (
<>
<Palette className='mr-2 h-4 w-4' />
{t('Visual Mode')}
</>
)}
</Button>
</div>
<FormControl>
{useVisualEditor ? (
<RateLimitVisualEditor
value={field.value || ''}
onChange={field.onChange}
/>
) : (
<JsonCodeEditor
value={field.value || ''}
onChange={field.onChange}
name={field.name}
onBlur={field.onBlur}
textareaRef={field.ref}
placeholder={`{\n "default": [200, 100],\n "vip": [0, 1000]\n}`}
aria-invalid={Boolean(
form.formState.errors.ModelRequestRateLimitGroup
)}
/>
)}
</FormControl>
{!useVisualEditor && (
<FormDescription>
<div className='space-y-1 text-xs'>
<p className='font-semibold'>{t('Format:')}</p>
<ul className='list-inside list-disc space-y-0.5 pl-2'>
<li>
{t('JSON object:')}{' '}
{`{"groupName": [maxRequests, maxSuccess]}`}
</li>
<li>
{t('Example:')}{' '}
{`{"default": [200, 100], "vip": [0, 1000]}`}
</li>
<li>
{t(
'maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647'
)}
</li>
<li>
{t(
'Group config overrides global limits, shares the same period'
)}
</li>
</ul>
</div>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</SettingsForm>
</Form>
</SettingsSection>
)
}