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:
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user