feat(json-editor): add reusable JSON code editor
- introduce a shared themed JSON editor with line numbers, formatting, status feedback, and keyboard editing helpers. - use the shared editor in model pricing JSON mode so pricing maps get consistent editor behavior. - localize structured JSON validation messages so parse errors avoid browser-specific English text.
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
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 {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
heightClassName?: string
|
||||
}
|
||||
|
||||
export function JsonCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
heightClassName = 'h-56 min-h-56 max-h-56',
|
||||
className,
|
||||
id,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
...rootProps
|
||||
}: JsonCodeEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const lineNumbers = useMemo(() => {
|
||||
const count = Math.max(1, value.split('\n').length)
|
||||
return Array.from({ length: count }, (_, index) => index + 1)
|
||||
}, [value])
|
||||
const jsonStatus = useMemo(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return { valid: true, message: t('JSON') }
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return { valid: true, message: t('JSON') }
|
||||
} catch {
|
||||
return { valid: false, message: t('Invalid JSON') }
|
||||
}
|
||||
}, [value, t])
|
||||
|
||||
const formatJson = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
|
||||
} catch {
|
||||
// Keep invalid drafts untouched; validation feedback remains visible.
|
||||
}
|
||||
}
|
||||
|
||||
const updateValueWithSelection = (
|
||||
nextValue: string,
|
||||
selectionStart: number,
|
||||
selectionEnd = selectionStart
|
||||
) => {
|
||||
onChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
|
||||
})
|
||||
}
|
||||
|
||||
const getLineIndent = (text: string, cursor: number) => {
|
||||
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
|
||||
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget
|
||||
const start = target.selectionStart
|
||||
const end = target.selectionEnd
|
||||
const selected = value.slice(start, end)
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
|
||||
if (start !== end && selected.includes('\n')) {
|
||||
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const selectedBlock = value.slice(selectionLineStart, end)
|
||||
const lines = selectedBlock.split('\n')
|
||||
const nextBlock = event.shiftKey
|
||||
? lines
|
||||
.map((line) =>
|
||||
line.startsWith(' ')
|
||||
? line.slice(2)
|
||||
: line.startsWith('\t')
|
||||
? line.slice(1)
|
||||
: line
|
||||
)
|
||||
.join('\n')
|
||||
: lines.map((line) => ` ${line}`).join('\n')
|
||||
const nextValue =
|
||||
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
|
||||
updateValueWithSelection(
|
||||
nextValue,
|
||||
selectionLineStart,
|
||||
selectionLineStart + nextBlock.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const removable = value.slice(lineStart, lineStart + 2)
|
||||
if (removable === ' ') {
|
||||
updateValueWithSelection(
|
||||
value.slice(0, lineStart) + value.slice(lineStart + 2),
|
||||
Math.max(lineStart, start - 2),
|
||||
Math.max(lineStart, end - 2)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateValueWithSelection(`${before} ${after}`, start + 2)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const indent = getLineIndent(value, start)
|
||||
const previousChar = before.trimEnd().at(-1)
|
||||
const nextChar = after.trimStart().at(0)
|
||||
const shouldNest = previousChar === '{' || previousChar === '['
|
||||
const shouldClose =
|
||||
(previousChar === '{' && nextChar === '}') ||
|
||||
(previousChar === '[' && nextChar === ']')
|
||||
|
||||
if (shouldNest && shouldClose) {
|
||||
const innerIndent = `${indent} `
|
||||
const insert = `\n${innerIndent}\n${indent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + 1 + innerIndent.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndent = shouldNest ? `${indent} ` : indent
|
||||
const insert = `\n${nextIndent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + insert.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const pairs: Record<string, string> = {
|
||||
'"': '"',
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
}
|
||||
const closingChars = new Set(Object.values(pairs))
|
||||
|
||||
if (closingChars.has(event.key) && value[start] === event.key) {
|
||||
event.preventDefault()
|
||||
textareaRef.current?.setSelectionRange(start + 1, start + 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (pairs[event.key]) {
|
||||
event.preventDefault()
|
||||
const close = pairs[event.key]
|
||||
const wrapped = `${event.key}${selected}${close}`
|
||||
updateValueWithSelection(
|
||||
`${before}${wrapped}${after}`,
|
||||
start + 1,
|
||||
start + 1 + selected.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && start === end && start > 0) {
|
||||
const previousChar = value[start - 1]
|
||||
const nextChar = value[start]
|
||||
if (pairs[previousChar] === nextChar) {
|
||||
event.preventDefault()
|
||||
updateValueWithSelection(
|
||||
value.slice(0, start - 1) + value.slice(start + 1),
|
||||
start - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
|
||||
className
|
||||
)}
|
||||
{...rootProps}
|
||||
>
|
||||
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
|
||||
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||
<Braces className='h-3.5 w-3.5' />
|
||||
<span>{t('JSON')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{jsonStatus.valid ? (
|
||||
<CheckCircle2 className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{jsonStatus.message}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||
>
|
||||
<Code2 className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('relative flex overflow-hidden', heightClassName)}>
|
||||
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
|
||||
<div
|
||||
className='px-2 py-2 text-right'
|
||||
style={{ transform: `translateY(-${scrollTop}px)` }}
|
||||
>
|
||||
{lineNumbers.map((lineNumber) => (
|
||||
<div key={lineNumber}>{lineNumber}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id={id}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
className={cn(
|
||||
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
|
||||
heightClassName
|
||||
)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
@@ -140,10 +140,9 @@ function ModelJsonTextareaField(props: {
|
||||
<FormItem className='flex min-w-0 flex-col gap-2'>
|
||||
<FormLabel>{props.label}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className='h-56 min-h-56 max-h-56 resize-none overflow-auto [field-sizing:fixed] font-mono text-xs leading-5'
|
||||
spellCheck={false}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className='text-xs leading-5'>
|
||||
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -34,169 +34,99 @@ import { ToolPriceSettings } from './tool-price-settings'
|
||||
import { UpstreamRatioSync } from './upstream-ratio-sync'
|
||||
import {
|
||||
formatJsonForTextarea,
|
||||
type JsonValidationError,
|
||||
normalizeJsonString,
|
||||
validateJsonString,
|
||||
} from './utils'
|
||||
|
||||
const modelSchema = z.object({
|
||||
ModelPrice: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ModelRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CacheRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CreateCacheRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CompletionRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ImageRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AudioRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ExposeRatioEnabled: z.boolean(),
|
||||
BillingMode: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
BillingExpr: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
const groupSchema = z.object({
|
||||
GroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
function formatJsonValidationError(
|
||||
t: Translate,
|
||||
error?: JsonValidationError,
|
||||
fallback = 'Invalid JSON'
|
||||
) {
|
||||
if (!error) return t(fallback)
|
||||
|
||||
if (error.type === 'required') return t('Value is required')
|
||||
if (error.type === 'structure') {
|
||||
return t(
|
||||
fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
|
||||
)
|
||||
}
|
||||
|
||||
const parts = [
|
||||
error.line && error.column
|
||||
? t('JSON is invalid at line {{line}}, column {{column}}.', {
|
||||
line: error.line,
|
||||
column: error.column,
|
||||
})
|
||||
: error.position !== undefined
|
||||
? t('JSON is invalid at position {{position}}.', {
|
||||
position: error.position,
|
||||
})
|
||||
: t('JSON is invalid. Please check the syntax.'),
|
||||
]
|
||||
|
||||
if (error.missingCommaLine) {
|
||||
parts.push(
|
||||
t('Check line {{line}} for a missing comma.', {
|
||||
line: error.missingCommaLine,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function createJsonStringField(
|
||||
t: Translate,
|
||||
options?: Parameters<typeof validateJsonString>[1]
|
||||
) {
|
||||
return z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, options)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
message: formatJsonValidationError(t, result.error, result.message),
|
||||
})
|
||||
}
|
||||
}),
|
||||
TopupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
UserUsableGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
GroupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AutoGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, {
|
||||
})
|
||||
}
|
||||
|
||||
const createModelSchema = (t: Translate) =>
|
||||
z.object({
|
||||
ModelPrice: createJsonStringField(t),
|
||||
ModelRatio: createJsonStringField(t),
|
||||
CacheRatio: createJsonStringField(t),
|
||||
CreateCacheRatio: createJsonStringField(t),
|
||||
CompletionRatio: createJsonStringField(t),
|
||||
ImageRatio: createJsonStringField(t),
|
||||
AudioRatio: createJsonStringField(t),
|
||||
AudioCompletionRatio: createJsonStringField(t),
|
||||
ExposeRatioEnabled: z.boolean(),
|
||||
BillingMode: createJsonStringField(t),
|
||||
BillingExpr: createJsonStringField(t),
|
||||
})
|
||||
|
||||
const createGroupSchema = (t: Translate) =>
|
||||
z.object({
|
||||
GroupRatio: createJsonStringField(t),
|
||||
TopupGroupRatio: createJsonStringField(t),
|
||||
UserUsableGroups: createJsonStringField(t),
|
||||
GroupGroupRatio: createJsonStringField(t),
|
||||
AutoGroups: createJsonStringField(t, {
|
||||
predicate: (parsed) =>
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === 'string'),
|
||||
predicateMessage: 'Expected a JSON array of group identifiers',
|
||||
})
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON array',
|
||||
})
|
||||
}
|
||||
}),
|
||||
DefaultUseAutoGroup: z.boolean(),
|
||||
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
DefaultUseAutoGroup: z.boolean(),
|
||||
GroupSpecialUsableGroup: createJsonStringField(t),
|
||||
})
|
||||
|
||||
type ModelFormValues = z.infer<typeof modelSchema>
|
||||
type GroupFormValues = z.infer<typeof groupSchema>
|
||||
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
|
||||
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
|
||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
@@ -265,6 +195,8 @@ export function RatioSettingsCard({
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
})
|
||||
const modelSchema = useMemo(() => createModelSchema(t), [t])
|
||||
const groupSchema = useMemo(() => createGroupSchema(t), [t])
|
||||
|
||||
const modelForm = useForm<ModelFormValues>({
|
||||
resolver: zodResolver(modelSchema),
|
||||
|
||||
+47
-4
@@ -49,6 +49,14 @@ type JsonValidationOptions = {
|
||||
predicateMessage?: string
|
||||
}
|
||||
|
||||
export type JsonValidationError = {
|
||||
type: 'required' | 'structure' | 'syntax'
|
||||
line?: number
|
||||
column?: number
|
||||
position?: number
|
||||
missingCommaLine?: number
|
||||
}
|
||||
|
||||
function extractErrorPosition(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
@@ -81,8 +89,15 @@ function extractErrorPosition(
|
||||
return {}
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||
function buildSyntaxError(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
): JsonValidationError {
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
type: 'syntax',
|
||||
} satisfies JsonValidationError
|
||||
}
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
@@ -93,10 +108,29 @@ function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
message.includes('Expected property name') ||
|
||||
message.includes('Unexpected string')
|
||||
|
||||
const missingCommaLine =
|
||||
isMissingCommaError && position.line && position.line > 1
|
||||
? position.line - 1
|
||||
: undefined
|
||||
|
||||
return {
|
||||
type: 'syntax',
|
||||
...position,
|
||||
missingCommaLine,
|
||||
} satisfies JsonValidationError
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown, jsonString: string): string {
|
||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
const syntaxError = buildSyntaxError(error, jsonString)
|
||||
|
||||
if (position.line && position.column) {
|
||||
let hint = ''
|
||||
if (isMissingCommaError && position.line > 1) {
|
||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
||||
if (syntaxError.missingCommaLine) {
|
||||
hint = ` (check line ${syntaxError.missingCommaLine} for missing comma)`
|
||||
}
|
||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||
}
|
||||
@@ -119,6 +153,11 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: allowEmpty,
|
||||
message: allowEmpty ? undefined : 'Value is required',
|
||||
error: allowEmpty
|
||||
? undefined
|
||||
: ({
|
||||
type: 'required',
|
||||
} satisfies JsonValidationError),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +167,9 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: false,
|
||||
message: predicateMessage || 'JSON structure is invalid',
|
||||
error: {
|
||||
type: 'structure',
|
||||
} satisfies JsonValidationError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +178,7 @@ export function validateJsonString(
|
||||
return {
|
||||
valid: false,
|
||||
message: formatErrorMessage(error, trimmed),
|
||||
error: buildSyntaxError(error, trimmed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "Check for updates",
|
||||
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
|
||||
"Check in now": "Check in now",
|
||||
"Check line {{line}} for a missing comma.": "Check line {{line}} for a missing comma.",
|
||||
"Check out the Quick Start": "Check out the Quick Start",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Check resolved IPs against IP filters even when accessing by domain",
|
||||
"Check-in failed": "Check-in failed",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Expand",
|
||||
"Expand All": "Expand All",
|
||||
"Expected a JSON array.": "Expected a JSON array.",
|
||||
"Expected a JSON array of group identifiers": "Expected a JSON array of group identifiers",
|
||||
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
|
||||
"Expiration Time": "Expiration Time",
|
||||
"expired": "expired",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "JSON Editor",
|
||||
"JSON format error": "JSON format error",
|
||||
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON is invalid at line {{line}}, column {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "JSON is invalid at position {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "JSON is invalid. Please check the syntax.",
|
||||
"JSON map of group → description exposed when users create API keys.": "JSON map of group → description exposed when users create API keys.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON map of group → ratio applied when the user selects the group explicitly.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "JSON map of model → multiplier applied to quota billing.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "JSON Mode",
|
||||
"JSON must be an object": "JSON must be an object",
|
||||
"JSON object:": "JSON object:",
|
||||
"JSON structure is invalid": "JSON structure is invalid",
|
||||
"JSON Text": "JSON Text",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "JSON-based access control rules. Leave empty to allow all users.",
|
||||
"Just now": "Just now",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Validity Period",
|
||||
"Value": "Value",
|
||||
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
|
||||
"Value is required": "Value is required",
|
||||
"Value must be at least 0": "Value must be at least 0",
|
||||
"Value Regex": "Value Regex",
|
||||
"variable": "variable",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "Vérifier les mises à jour",
|
||||
"Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires",
|
||||
"Check in now": "Se connecter maintenant",
|
||||
"Check line {{line}} for a missing comma.": "Vérifiez la ligne {{line}} pour une virgule manquante.",
|
||||
"Check out the Quick Start": "Consultez le démarrage rapide",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Vérifier les adresses IP résolues par rapport aux filtres IP même lors de l'accès par domaine",
|
||||
"Check-in failed": "Échec de la connexion",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Développer",
|
||||
"Expand All": "Tout développer",
|
||||
"Expected a JSON array.": "Un tableau JSON est attendu.",
|
||||
"Expected a JSON array of group identifiers": "Un tableau JSON d'identifiants de groupe est attendu",
|
||||
"Experiment with prompts and models in real time.": "Expérimentez avec des prompts et des modèles en temps réel.",
|
||||
"Expiration Time": "Heure d'expiration",
|
||||
"expired": "expiré",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "Édition JSON",
|
||||
"JSON format error": "Erreur de format JSON",
|
||||
"JSON format supports service account JSON files": "Le format JSON prend en charge les fichiers JSON de compte de service",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "Le JSON est invalide à la ligne {{line}}, colonne {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "Le JSON est invalide à la position {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "Le JSON est invalide. Veuillez vérifier la syntaxe.",
|
||||
"JSON map of group → description exposed when users create API keys.": "Carte JSON de groupe → description exposée lorsque les utilisateurs créent des clés API.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "Carte JSON de groupe → ratio appliqué lorsque l'utilisateur sélectionne explicitement le groupe.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "Carte JSON de modèle → multiplicateur appliqué à la facturation par quota.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "Mode JSON",
|
||||
"JSON must be an object": "Le JSON doit être un objet",
|
||||
"JSON object:": "Objet JSON :",
|
||||
"JSON structure is invalid": "La structure JSON est invalide",
|
||||
"JSON Text": "Texte JSON",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Règles de contrôle d'accès basées sur JSON. Laisser vide pour autoriser tous les utilisateurs.",
|
||||
"Just now": "À l'instant",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Période de validité",
|
||||
"Value": "Valeur",
|
||||
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
|
||||
"Value is required": "La valeur est obligatoire",
|
||||
"Value must be at least 0": "La valeur doit être au moins 0",
|
||||
"Value Regex": "Regex de valeur",
|
||||
"variable": "variable",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "更新を確認",
|
||||
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
|
||||
"Check in now": "今すぐチェックイン",
|
||||
"Check line {{line}} for a missing comma.": "{{line}} 行目にカンマの抜けがないか確認してください。",
|
||||
"Check out the Quick Start": "クイックスタートをご確認ください",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
|
||||
"Check-in failed": "チェックインできませんでした",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "展開",
|
||||
"Expand All": "すべて展開",
|
||||
"Expected a JSON array.": "JSON 配列が必要です。",
|
||||
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
|
||||
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
|
||||
"Expiration Time": "有効期限",
|
||||
"expired": "期限切れ",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "JSON編集",
|
||||
"JSON format error": "JSONフォーマットエラー",
|
||||
"JSON format supports service account JSON files": "JSON形式はサービスアカウントJSONファイルをサポートします",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON は {{line}} 行目、{{column}} 列目で無効です。",
|
||||
"JSON is invalid at position {{position}}.": "JSON は位置 {{position}} で無効です。",
|
||||
"JSON is invalid. Please check the syntax.": "JSON が無効です。構文を確認してください。",
|
||||
"JSON map of group → description exposed when users create API keys.": "ユーザーがAPIキーを作成する際に公開される、グループ → 説明のJSONマップ。",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
|
||||
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "JSONモード",
|
||||
"JSON must be an object": "JSON はオブジェクトである必要があります",
|
||||
"JSON object:": "JSONオブジェクト:",
|
||||
"JSON structure is invalid": "JSON 構造が無効です",
|
||||
"JSON Text": "JSONテキスト",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
|
||||
"Just now": "たった今",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "有効期間",
|
||||
"Value": "値",
|
||||
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
|
||||
"Value is required": "値は必須です",
|
||||
"Value must be at least 0": "値は 0 以上である必要があります",
|
||||
"Value Regex": "Value 正規表現",
|
||||
"variable": "変数",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "Проверить обновления",
|
||||
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
|
||||
"Check in now": "Войдите сейчас",
|
||||
"Check line {{line}} for a missing comma.": "Проверьте строку {{line}} на пропущенную запятую.",
|
||||
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
|
||||
"Check-in failed": "Регистрация не удалась.",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Развернуть",
|
||||
"Expand All": "Развернуть все",
|
||||
"Expected a JSON array.": "Ожидается JSON-массив.",
|
||||
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
|
||||
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
|
||||
"Expiration Time": "Время истечения срока действия",
|
||||
"expired": "истек",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "Редактирование JSON",
|
||||
"JSON format error": "Ошибка формата JSON",
|
||||
"JSON format supports service account JSON files": "Формат JSON поддерживает JSON-файлы сервисного аккаунта",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON недействителен в строке {{line}}, столбце {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "JSON недействителен в позиции {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "JSON недействителен. Проверьте синтаксис.",
|
||||
"JSON map of group → description exposed when users create API keys.": "JSON-карта группы → описание, отображаемое при создании пользователями ключей API.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "Режим JSON",
|
||||
"JSON must be an object": "JSON должен быть объектом",
|
||||
"JSON object:": "Объект JSON:",
|
||||
"JSON structure is invalid": "Структура JSON недействительна",
|
||||
"JSON Text": "JSON текст",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
|
||||
"Just now": "Только что",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Срок действия",
|
||||
"Value": "Значение",
|
||||
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
|
||||
"Value is required": "Значение обязательно",
|
||||
"Value must be at least 0": "Значение должно быть не менее 0",
|
||||
"Value Regex": "Регулярное выражение значения",
|
||||
"variable": "переменная",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "Kiểm tra cập nhật",
|
||||
"Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên",
|
||||
"Check in now": "Điểm danh ngay",
|
||||
"Check line {{line}} for a missing comma.": "Kiểm tra dòng {{line}} xem có thiếu dấu phẩy không.",
|
||||
"Check out the Quick Start": "Xem hướng dẫn bắt đầu nhanh",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "Kiểm tra các IP đã phân giải đối chiếu với các bộ lọc IP ngay cả khi truy cập bằng tên miền",
|
||||
"Check-in failed": "Điểm danh thất bại",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "Mở rộng",
|
||||
"Expand All": "Mở rộng tất cả",
|
||||
"Expected a JSON array.": "Cần là một mảng JSON.",
|
||||
"Expected a JSON array of group identifiers": "Cần là một mảng JSON gồm các định danh nhóm",
|
||||
"Experiment with prompts and models in real time.": "Thử nghiệm với prompt và mô hình theo thời gian thực.",
|
||||
"Expiration Time": "Thời gian hết hạn",
|
||||
"expired": "Đã hết hạn",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "Trình chỉnh sửa JSON",
|
||||
"JSON format error": "Lỗi định dạng JSON",
|
||||
"JSON format supports service account JSON files": "Định dạng JSON hỗ trợ các tệp JSON tài khoản dịch vụ",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON không hợp lệ tại dòng {{line}}, cột {{column}}.",
|
||||
"JSON is invalid at position {{position}}.": "JSON không hợp lệ tại vị trí {{position}}.",
|
||||
"JSON is invalid. Please check the syntax.": "JSON không hợp lệ. Vui lòng kiểm tra cú pháp.",
|
||||
"JSON map of group → description exposed when users create API keys.": "Ánh xạ JSON của nhóm → mô tả được hiển thị khi người dùng tạo khóa API.",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "Bản đồ JSON của nhóm → tỷ lệ được áp dụng khi người dùng chọn nhóm đó một cách rõ ràng.",
|
||||
"JSON map of model → multiplier applied to quota billing.": "Bản đồ JSON của mô hình → hệ số nhân áp dụng cho thanh toán hạn mức.",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "Chế độ JSON",
|
||||
"JSON must be an object": "JSON phải là object",
|
||||
"JSON object:": "Đối tượng JSON:",
|
||||
"JSON structure is invalid": "Cấu trúc JSON không hợp lệ",
|
||||
"JSON Text": "Văn bản JSON",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "Quy tắc kiểm soát truy cập dựa trên JSON. Để trống để cho phép tất cả người dùng.",
|
||||
"Just now": "Vừa nãy",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "Thời hạn hiệu lực",
|
||||
"Value": "Giá trị",
|
||||
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
|
||||
"Value is required": "Giá trị là bắt buộc",
|
||||
"Value must be at least 0": "Giá trị phải ít nhất là 0",
|
||||
"Value Regex": "Regex giá trị",
|
||||
"variable": "biến",
|
||||
|
||||
Vendored
+7
@@ -679,6 +679,7 @@
|
||||
"Check for updates": "检查更新",
|
||||
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
|
||||
"Check in now": "立即签到",
|
||||
"Check line {{line}} for a missing comma.": "请检查第 {{line}} 行是否缺少逗号。",
|
||||
"Check out the Quick Start": "请查看 新手入门",
|
||||
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
|
||||
"Check-in failed": "签到失败",
|
||||
@@ -1527,6 +1528,7 @@
|
||||
"Expand": "展开",
|
||||
"Expand All": "全部展开",
|
||||
"Expected a JSON array.": "应为 JSON 数组。",
|
||||
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
|
||||
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
|
||||
"Expiration Time": "过期时间",
|
||||
"expired": "已过期",
|
||||
@@ -2093,6 +2095,9 @@
|
||||
"JSON Editor": "JSON 编辑",
|
||||
"JSON format error": "JSON 格式错误",
|
||||
"JSON format supports service account JSON files": "JSON 格式支持服务账户 JSON 文件",
|
||||
"JSON is invalid at line {{line}}, column {{column}}.": "JSON 在第 {{line}} 行、第 {{column}} 列无效。",
|
||||
"JSON is invalid at position {{position}}.": "JSON 在位置 {{position}} 无效。",
|
||||
"JSON is invalid. Please check the syntax.": "JSON 无效,请检查语法。",
|
||||
"JSON map of group → description exposed when users create API keys.": "分组 → 描述的 JSON 映射,在用户创建 API 密钥时公开。",
|
||||
"JSON map of group → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
|
||||
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
|
||||
@@ -2101,6 +2106,7 @@
|
||||
"JSON Mode": "JSON 模式",
|
||||
"JSON must be an object": "JSON 必须是对象",
|
||||
"JSON object:": "JSON 对象:",
|
||||
"JSON structure is invalid": "JSON 结构无效",
|
||||
"JSON Text": "JSON 文本",
|
||||
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
|
||||
"Just now": "刚刚",
|
||||
@@ -4313,6 +4319,7 @@
|
||||
"Validity Period": "有效期",
|
||||
"Value": "值",
|
||||
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
|
||||
"Value is required": "值为必填项",
|
||||
"Value must be at least 0": "值必须至少为 0",
|
||||
"Value Regex": "Value 正则",
|
||||
"variable": "变量",
|
||||
|
||||
Vendored
+1
@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [
|
||||
'Failed to delete API key',
|
||||
'Failed to delete API keys',
|
||||
'Failed to update API key status',
|
||||
'Expected a JSON array of group identifiers',
|
||||
'Successfully created {{count}} API Key(s)',
|
||||
'Successfully deleted {{count}} API key(s)',
|
||||
'Enter API key for this channel',
|
||||
|
||||
Reference in New Issue
Block a user