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:
QuentinHsu
2026-06-06 15:14:26 +08:00
parent 75c05bb4b8
commit 0f043ae404
11 changed files with 459 additions and 158 deletions
@@ -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
View File
@@ -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),
}
}
}