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
This commit is contained in:
+20
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "newapi-web",
|
||||
@@ -59,6 +60,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vaul": "^1.1.2",
|
||||
"yace": "^1.1.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14",
|
||||
},
|
||||
@@ -75,6 +77,7 @@
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
|
||||
"@xyflow/react": "^12.11.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"knip": "^6.24.0",
|
||||
"oxfmt": "^0.57.0",
|
||||
"oxlint": "^1.72.0",
|
||||
@@ -926,6 +929,10 @@
|
||||
|
||||
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="],
|
||||
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="],
|
||||
@@ -1054,6 +1061,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
@@ -1288,7 +1297,7 @@
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
@@ -1438,6 +1447,8 @@
|
||||
|
||||
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
@@ -2382,12 +2393,18 @@
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"yace": ["yace@1.1.0", "", {}, "sha512-jB29trAxPBvTIR/lXsgh97q22n/Rq5ZHbDT4DT6WUvXVlJLR7jdwiBKD+0zi3XZAD5N+YKa3GrdIrelqIXj2oQ=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
@@ -2528,6 +2545,8 @@
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"markdown-it-ts/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vaul": "^1.1.2",
|
||||
"yace": "^1.1.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -90,6 +91,7 @@
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
|
||||
"@xyflow/react": "^12.11.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"knip": "^6.24.0",
|
||||
"oxfmt": "^0.57.0",
|
||||
"oxlint": "^1.72.0",
|
||||
|
||||
@@ -16,201 +16,252 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
|
||||
import { AlertCircle, Braces, CheckCircle2, Code2, Copy } from 'lucide-react'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Yace, type Plugin } from 'yace'
|
||||
import { code } from 'yace/highlighters/code'
|
||||
import { autoClose, history, tab } from 'yace/plugins'
|
||||
|
||||
import {
|
||||
createScrollLayerSynchronizer,
|
||||
formatJsonDraft,
|
||||
getCursorLocation,
|
||||
getJsonValidationState,
|
||||
jsonSmartEnter,
|
||||
type CursorLocation,
|
||||
} from '@/components/json-code-editor/json-code-editor-utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
|
||||
export type JsonCodeEditorProps = Omit<
|
||||
ComponentProps<'div'>,
|
||||
'name' | 'onBlur' | 'onChange'
|
||||
> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
name?: string
|
||||
onBlur?: () => void
|
||||
textareaRef?: (element: HTMLTextAreaElement | null) => void
|
||||
disabled?: boolean
|
||||
heightClassName?: string
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
'data-form-root'?: string
|
||||
}
|
||||
|
||||
export function JsonCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
onBlur,
|
||||
textareaRef,
|
||||
disabled,
|
||||
heightClassName = 'h-56 min-h-56 max-h-56',
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
className,
|
||||
id,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
'data-form-root': dataFormRoot,
|
||||
...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') }
|
||||
const mountRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Yace | null>(null)
|
||||
const latestValueRef = useRef(value)
|
||||
const latestOnChangeRef = useRef(onChange)
|
||||
const latestOnBlurRef = useRef(onBlur)
|
||||
const [cursorLocation, setCursorLocation] = useState<CursorLocation>({
|
||||
line: 1,
|
||||
column: 1,
|
||||
})
|
||||
const jsonStatus = useMemo(() => getJsonValidationState(value), [value])
|
||||
const editorPlugins = useMemo<Plugin[]>(
|
||||
() => [
|
||||
history(),
|
||||
tab(' '),
|
||||
jsonSmartEnter(),
|
||||
autoClose({ '"': '"', '{': '}', '[': ']' }),
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
latestValueRef.current = value
|
||||
latestOnChangeRef.current = onChange
|
||||
latestOnBlurRef.current = onBlur
|
||||
|
||||
useEffect(() => {
|
||||
const mountNode = mountRef.current
|
||||
if (!mountNode) {
|
||||
return
|
||||
}
|
||||
}, [value, t])
|
||||
|
||||
const editor = new Yace(mountNode, {
|
||||
value: latestValueRef.current,
|
||||
lineNumbers: true,
|
||||
highlighters: [code()],
|
||||
plugins: editorPlugins,
|
||||
styles: {
|
||||
color: 'inherit',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: '1.25rem',
|
||||
minHeight: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: '0.5rem 0.75rem 0.5rem 0.5rem',
|
||||
},
|
||||
})
|
||||
editorRef.current = editor
|
||||
|
||||
const handleUpdate = (nextValue: string) => {
|
||||
if (nextValue !== latestValueRef.current) {
|
||||
latestOnChangeRef.current(nextValue)
|
||||
}
|
||||
}
|
||||
const updateCursorLocation = () => {
|
||||
setCursorLocation(
|
||||
getCursorLocation(editor.value, editor.textarea.selectionStart)
|
||||
)
|
||||
}
|
||||
const lineNumberLayer = [...mountNode.querySelectorAll('pre')].find(
|
||||
(preLayer) => preLayer !== editor.pre
|
||||
)
|
||||
const scrollSynchronizer = lineNumberLayer
|
||||
? createScrollLayerSynchronizer(editor.textarea, {
|
||||
contentLayer: editor.pre,
|
||||
lineNumberLayer,
|
||||
})
|
||||
: null
|
||||
const syncScrollLayers = () => scrollSynchronizer?.sync()
|
||||
const handleBlur = () => latestOnBlurRef.current?.()
|
||||
|
||||
editor.onUpdate(handleUpdate)
|
||||
editor.textarea.addEventListener('click', updateCursorLocation)
|
||||
editor.textarea.addEventListener('input', updateCursorLocation)
|
||||
editor.textarea.addEventListener('keyup', updateCursorLocation)
|
||||
editor.textarea.addEventListener('select', updateCursorLocation)
|
||||
editor.textarea.addEventListener('blur', handleBlur)
|
||||
editor.textarea.addEventListener('scroll', syncScrollLayers, {
|
||||
passive: true,
|
||||
})
|
||||
editor.textarea.classList.add('json-code-editor-textarea')
|
||||
editor.pre.classList.add('json-code-editor-highlight')
|
||||
editor.pre.setAttribute('aria-hidden', 'true')
|
||||
if (lineNumberLayer) {
|
||||
lineNumberLayer.classList.add('json-code-editor-lines')
|
||||
lineNumberLayer.setAttribute('aria-hidden', 'true')
|
||||
}
|
||||
updateCursorLocation()
|
||||
|
||||
return () => {
|
||||
editor.textarea.removeEventListener('click', updateCursorLocation)
|
||||
editor.textarea.removeEventListener('input', updateCursorLocation)
|
||||
editor.textarea.removeEventListener('keyup', updateCursorLocation)
|
||||
editor.textarea.removeEventListener('select', updateCursorLocation)
|
||||
editor.textarea.removeEventListener('blur', handleBlur)
|
||||
editor.textarea.removeEventListener('scroll', syncScrollLayers)
|
||||
editor.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
}, [editorPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = editorRef.current?.textarea ?? null
|
||||
textareaRef?.(textarea)
|
||||
|
||||
return () => textareaRef?.(null)
|
||||
}, [textareaRef])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || editor.value === value) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.update({ value })
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedAriaInvalid = ariaInvalid ?? !jsonStatus.isValid
|
||||
|
||||
editor.textarea.disabled = Boolean(disabled)
|
||||
editor.textarea.id = id ?? ''
|
||||
editor.textarea.name = name ?? ''
|
||||
|
||||
if (ariaLabel) {
|
||||
editor.textarea.setAttribute('aria-label', ariaLabel)
|
||||
} else {
|
||||
editor.textarea.removeAttribute('aria-label')
|
||||
}
|
||||
|
||||
if (dataFormRoot) {
|
||||
editor.textarea.setAttribute('data-form-root', String(dataFormRoot))
|
||||
} else {
|
||||
editor.textarea.removeAttribute('data-form-root')
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
editor.textarea.placeholder = placeholder
|
||||
} else {
|
||||
editor.textarea.removeAttribute('placeholder')
|
||||
}
|
||||
|
||||
if (resolvedAriaInvalid) {
|
||||
editor.textarea.setAttribute('aria-invalid', String(resolvedAriaInvalid))
|
||||
} else {
|
||||
editor.textarea.removeAttribute('aria-invalid')
|
||||
}
|
||||
|
||||
if (ariaDescribedBy) {
|
||||
editor.textarea.setAttribute('aria-describedby', ariaDescribedBy)
|
||||
} else {
|
||||
editor.textarea.removeAttribute('aria-describedby')
|
||||
}
|
||||
}, [
|
||||
ariaDescribedBy,
|
||||
ariaInvalid,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
dataFormRoot,
|
||||
id,
|
||||
jsonStatus.isValid,
|
||||
name,
|
||||
placeholder,
|
||||
])
|
||||
|
||||
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 result = formatJsonDraft(value)
|
||||
if (result.didFormat) {
|
||||
onChange(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
const updateValueWithSelection = (
|
||||
nextValue: string,
|
||||
selectionStart: number,
|
||||
selectionEnd = selectionStart
|
||||
) => {
|
||||
onChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
|
||||
})
|
||||
const handleCopy = async () => {
|
||||
const didCopy = await copyToClipboard(value)
|
||||
if (didCopy) {
|
||||
toast.success(t('Copied to clipboard'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(t('Failed to copy'))
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const statusMessage = t(jsonStatus.messageKey)
|
||||
const cursorText = `${cursorLocation.line}:${cursorLocation.column}`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -218,66 +269,65 @@ export function JsonCodeEditor({
|
||||
'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
|
||||
)}
|
||||
data-form-root={dataFormRoot}
|
||||
{...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' />
|
||||
<Braces className='h-3.5 w-3.5' aria-hidden='true' />
|
||||
<span>{t('JSON')}</span>
|
||||
<span className='text-muted-foreground/70 font-mono'>
|
||||
{cursorText}
|
||||
</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.isValid ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{jsonStatus.valid ? (
|
||||
<CheckCircle2 className='h-3.5 w-3.5' />
|
||||
{jsonStatus.isValid ? (
|
||||
<CheckCircle2 className='h-3.5 w-3.5' aria-hidden='true' />
|
||||
) : (
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
<AlertCircle className='h-3.5 w-3.5' aria-hidden='true' />
|
||||
)}
|
||||
{jsonStatus.message}
|
||||
{statusMessage}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||
onClick={handleCopy}
|
||||
disabled={disabled || !value}
|
||||
>
|
||||
<Code2 className='mr-1 h-3.5 w-3.5' />
|
||||
<Copy className='mr-1 h-3.5 w-3.5' aria-hidden='true' />
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.isValid || !value.trim()}
|
||||
>
|
||||
<Code2 className='mr-1 h-3.5 w-3.5' aria-hidden='true' />
|
||||
{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
|
||||
className={cn(
|
||||
'bg-background relative overflow-hidden pl-2',
|
||||
'has-[textarea:disabled]:bg-input/30 has-[textarea:disabled]:opacity-70',
|
||||
heightClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={mountRef}
|
||||
className='json-code-editor-yace text-foreground h-full font-mono text-xs leading-5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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 assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import {
|
||||
applyJsonSmartEnter,
|
||||
createScrollLayerSynchronizer,
|
||||
formatJsonDraft,
|
||||
getCursorLocation,
|
||||
getJsonValidationState,
|
||||
} from '../json-code-editor-utils'
|
||||
|
||||
describe('json code editor utils', () => {
|
||||
test('treats empty drafts as valid editable JSON drafts', () => {
|
||||
assert.deepEqual(getJsonValidationState(' \n'), {
|
||||
isValid: true,
|
||||
messageKey: 'JSON',
|
||||
})
|
||||
})
|
||||
|
||||
test('reports invalid JSON without throwing away the draft', () => {
|
||||
assert.deepEqual(getJsonValidationState('{"model": }'), {
|
||||
isValid: false,
|
||||
messageKey: 'Invalid JSON',
|
||||
})
|
||||
})
|
||||
|
||||
test('formats valid JSON with stable two-space indentation', () => {
|
||||
assert.deepEqual(formatJsonDraft('{"model":{"ratio":2}}'), {
|
||||
didFormat: true,
|
||||
value: '{\n "model": {\n "ratio": 2\n }\n}',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps invalid JSON drafts unchanged when formatting is requested', () => {
|
||||
assert.deepEqual(formatJsonDraft('{"model": }'), {
|
||||
didFormat: false,
|
||||
value: '{"model": }',
|
||||
})
|
||||
})
|
||||
|
||||
test('derives the one-based cursor line and column from text offsets', () => {
|
||||
assert.deepEqual(getCursorLocation('{\n "model": 1\n}', 5), {
|
||||
line: 2,
|
||||
column: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test('expands paired JSON brackets with a nested indentation line', () => {
|
||||
assert.deepEqual(applyJsonSmartEnter('{}', 1, 1), {
|
||||
value: '{\n \n}',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test('coalesces scroll updates while keeping line numbers horizontally fixed', () => {
|
||||
const source = { scrollLeft: 12, scrollTop: 40 }
|
||||
const contentLayer = { style: { transform: '' } }
|
||||
const lineNumberLayer = { style: { transform: '' } }
|
||||
const queuedFrames: Array<() => void> = []
|
||||
const synchronizer = createScrollLayerSynchronizer(
|
||||
source,
|
||||
{ contentLayer, lineNumberLayer },
|
||||
(callback) => {
|
||||
queuedFrames.push(callback)
|
||||
return queuedFrames.length
|
||||
}
|
||||
)
|
||||
|
||||
synchronizer.sync()
|
||||
source.scrollLeft = 24
|
||||
source.scrollTop = 80
|
||||
synchronizer.sync()
|
||||
|
||||
assert.equal(queuedFrames.length, 1)
|
||||
|
||||
queuedFrames[0]()
|
||||
|
||||
assert.equal(contentLayer.style.transform, 'translate3d(-24px, -80px, 0)')
|
||||
assert.equal(lineNumberLayer.style.transform, 'translate3d(0, -80px, 0)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
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 assert from 'node:assert/strict'
|
||||
import { after, describe, test } from 'node:test'
|
||||
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
const domWindow = new Window()
|
||||
const domGlobals = [
|
||||
'window',
|
||||
'document',
|
||||
'navigator',
|
||||
'HTMLElement',
|
||||
'HTMLTextAreaElement',
|
||||
'Node',
|
||||
'Element',
|
||||
'Event',
|
||||
'CustomEvent',
|
||||
'MutationObserver',
|
||||
'requestAnimationFrame',
|
||||
'cancelAnimationFrame',
|
||||
'getComputedStyle',
|
||||
] as const
|
||||
|
||||
for (const key of domGlobals) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value: domWindow[key],
|
||||
})
|
||||
}
|
||||
|
||||
const { act } = await import('react')
|
||||
const { createRoot } = await import('react-dom/client')
|
||||
const i18next = (await import('i18next')).default
|
||||
const { initReactI18next } = await import('react-i18next')
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
JSON: 'JSON',
|
||||
'Invalid JSON': 'Invalid JSON',
|
||||
'Copied to clipboard': 'Copied to clipboard',
|
||||
'Failed to copy': 'Failed to copy',
|
||||
'Format JSON': 'Format JSON',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const { JsonCodeEditor } = await import('../../json-code-editor')
|
||||
const reactTestGlobals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean
|
||||
}
|
||||
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
type RenderedEditor = {
|
||||
container: HTMLDivElement
|
||||
root: ReturnType<typeof createRoot>
|
||||
}
|
||||
|
||||
async function renderEditor(
|
||||
props: React.ComponentProps<typeof JsonCodeEditor>
|
||||
): Promise<RenderedEditor> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
await act(async () => {
|
||||
root.render(<JsonCodeEditor {...props} />)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
async function unmountEditor(rendered: RenderedEditor) {
|
||||
await act(async () => rendered.root.unmount())
|
||||
rendered.container.remove()
|
||||
}
|
||||
|
||||
describe('JsonCodeEditor component', () => {
|
||||
after(() => {
|
||||
domWindow.close()
|
||||
})
|
||||
|
||||
test('forwards form attributes and lifecycle callbacks to the textarea', async () => {
|
||||
const blurCalls: number[] = []
|
||||
const refValues: Array<HTMLTextAreaElement | null> = []
|
||||
const rendered = await renderEditor({
|
||||
value: '{"model":"gpt"}',
|
||||
onChange: () => undefined,
|
||||
id: 'json-input',
|
||||
name: 'model_config',
|
||||
placeholder: '{"model":"gpt"}',
|
||||
disabled: true,
|
||||
'aria-describedby': 'model-help',
|
||||
'aria-invalid': true,
|
||||
'data-form-root': 'settings-form',
|
||||
onBlur: () => blurCalls.push(1),
|
||||
textareaRef: (element) => refValues.push(element),
|
||||
})
|
||||
const textarea = rendered.container.querySelector('textarea')
|
||||
|
||||
assert.ok(textarea)
|
||||
assert.equal(textarea.id, 'json-input')
|
||||
assert.equal(textarea.name, 'model_config')
|
||||
assert.equal(textarea.placeholder, '{"model":"gpt"}')
|
||||
assert.equal(textarea.disabled, true)
|
||||
assert.equal(textarea.getAttribute('aria-describedby'), 'model-help')
|
||||
assert.equal(textarea.getAttribute('aria-invalid'), 'true')
|
||||
assert.equal(textarea.getAttribute('data-form-root'), 'settings-form')
|
||||
|
||||
await act(async () => textarea.dispatchEvent(new Event('blur')))
|
||||
assert.deepEqual(blurCalls, [1])
|
||||
assert.equal(refValues[0], textarea)
|
||||
|
||||
await unmountEditor(rendered)
|
||||
assert.equal(refValues.at(-1), null)
|
||||
})
|
||||
|
||||
test('emits user edits and synchronizes a controlled value', async () => {
|
||||
const changes: string[] = []
|
||||
const rendered = await renderEditor({
|
||||
value: '{"count":1}',
|
||||
onChange: (value) => changes.push(value),
|
||||
})
|
||||
const textarea = rendered.container.querySelector('textarea')
|
||||
|
||||
assert.ok(textarea)
|
||||
await act(async () => {
|
||||
textarea.value = '{"count":2}'
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
assert.deepEqual(changes, ['{"count":2}'])
|
||||
|
||||
await act(async () => {
|
||||
rendered.root.render(
|
||||
<JsonCodeEditor
|
||||
value='{"count":3}'
|
||||
onChange={(value) => changes.push(value)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
assert.equal(textarea.value, '{"count":3}')
|
||||
|
||||
await unmountEditor(rendered)
|
||||
})
|
||||
|
||||
test('formats valid JSON through the public toolbar action', async () => {
|
||||
const changes: string[] = []
|
||||
const rendered = await renderEditor({
|
||||
value: '{"model":{"ratio":2}}',
|
||||
onChange: (value) => changes.push(value),
|
||||
})
|
||||
const formatButton = [
|
||||
...rendered.container.querySelectorAll('button'),
|
||||
].find((button) => button.textContent?.includes('Format JSON'))
|
||||
|
||||
assert.ok(formatButton)
|
||||
await act(async () => formatButton.click())
|
||||
assert.deepEqual(changes, ['{\n "model": {\n "ratio": 2\n }\n}'])
|
||||
|
||||
await unmountEditor(rendered)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
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 type { Plugin, TextareaProps } from 'yace'
|
||||
|
||||
export type JsonValidationState = {
|
||||
isValid: boolean
|
||||
messageKey: 'JSON' | 'Invalid JSON'
|
||||
}
|
||||
|
||||
export type JsonFormatResult = {
|
||||
didFormat: boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
export type CursorLocation = {
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
type ScrollSource = {
|
||||
scrollLeft: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
type TransformLayer = {
|
||||
style: Pick<CSSStyleDeclaration, 'transform'>
|
||||
}
|
||||
|
||||
type ScrollLayers = {
|
||||
contentLayer: TransformLayer
|
||||
lineNumberLayer: TransformLayer
|
||||
}
|
||||
|
||||
type RequestFrame = (callback: () => void) => number
|
||||
|
||||
export type ScrollLayerSynchronizer = {
|
||||
sync: () => void
|
||||
}
|
||||
|
||||
export function getJsonValidationState(value: string): JsonValidationState {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return { isValid: true, messageKey: 'JSON' }
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return { isValid: true, messageKey: 'JSON' }
|
||||
} catch {
|
||||
return { isValid: false, messageKey: 'Invalid JSON' }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatJsonDraft(value: string): JsonFormatResult {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return { didFormat: false, value }
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
didFormat: true,
|
||||
value: JSON.stringify(JSON.parse(trimmed), null, 2),
|
||||
}
|
||||
} catch {
|
||||
return { didFormat: false, value }
|
||||
}
|
||||
}
|
||||
|
||||
export function getCursorLocation(
|
||||
value: string,
|
||||
selectionStart: number
|
||||
): CursorLocation {
|
||||
const boundedSelectionStart = Math.min(
|
||||
Math.max(selectionStart, 0),
|
||||
value.length
|
||||
)
|
||||
const linesBeforeCursor = value.slice(0, boundedSelectionStart).split('\n')
|
||||
const currentLine = linesBeforeCursor.at(-1) ?? ''
|
||||
|
||||
return {
|
||||
line: linesBeforeCursor.length,
|
||||
column: currentLine.length + 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollLayerSynchronizer(
|
||||
source: ScrollSource,
|
||||
layers: ScrollLayers,
|
||||
requestFrame: RequestFrame = window.requestAnimationFrame
|
||||
): ScrollLayerSynchronizer {
|
||||
let hasPendingFrame = false
|
||||
|
||||
return {
|
||||
sync: () => {
|
||||
if (hasPendingFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
hasPendingFrame = true
|
||||
requestFrame(() => {
|
||||
hasPendingFrame = false
|
||||
layers.contentLayer.style.transform = `translate3d(-${source.scrollLeft}px, -${source.scrollTop}px, 0)`
|
||||
layers.lineNumberLayer.style.transform = `translate3d(0, -${source.scrollTop}px, 0)`
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function applyJsonSmartEnter(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
): TextareaProps | undefined {
|
||||
const before = value.slice(0, selectionStart)
|
||||
const after = value.slice(selectionEnd)
|
||||
const indent = getLineIndent(value, selectionStart)
|
||||
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}`
|
||||
const nextSelection = selectionStart + 1 + innerIndent.length
|
||||
|
||||
return {
|
||||
value: `${before}${insert}${after}`,
|
||||
selectionStart: nextSelection,
|
||||
selectionEnd: nextSelection,
|
||||
}
|
||||
}
|
||||
|
||||
if (!indent && !shouldNest) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nextIndent = shouldNest ? `${indent} ` : indent
|
||||
const insert = `\n${nextIndent}`
|
||||
const nextSelection = selectionStart + insert.length
|
||||
|
||||
return {
|
||||
value: `${before}${insert}${after}`,
|
||||
selectionStart: nextSelection,
|
||||
selectionEnd: nextSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonSmartEnter(): Plugin {
|
||||
return (props, event) => {
|
||||
if (event.type !== 'keydown') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const keyboardEvent = event as KeyboardEvent
|
||||
if (keyboardEvent.key !== 'Enter') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nextProps = applyJsonSmartEnter(
|
||||
props.value,
|
||||
props.selectionStart,
|
||||
props.selectionEnd
|
||||
)
|
||||
|
||||
if (!nextProps) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
return nextProps
|
||||
}
|
||||
}
|
||||
|
||||
function getLineIndent(value: string, cursor: number): string {
|
||||
const lineStart = value.lastIndexOf('\n', cursor - 1) + 1
|
||||
|
||||
return value.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
|
||||
}
|
||||
@@ -20,10 +20,9 @@ import { Code, Table, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type JsonEditorProps = {
|
||||
value: string
|
||||
@@ -285,15 +284,14 @@ export function JsonEditor({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={jsonValue}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
onChange={handleJsonChange}
|
||||
placeholder={
|
||||
template ? JSON.stringify(template, null, 2) : '{"key": "value"}'
|
||||
}
|
||||
disabled={disabled}
|
||||
rows={8}
|
||||
className={cn('font-mono text-sm')}
|
||||
ariaLabel={t('JSON')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -54,7 +55,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -461,12 +461,6 @@ export function AdvancedCustomEditorDialog({
|
||||
if (jsonError) setJsonError('')
|
||||
}
|
||||
|
||||
const formatJson = () => {
|
||||
const parsed = parseJsonEditorConfig()
|
||||
if (!parsed) return
|
||||
setJsonText(stringifyAdvancedCustomConfig(parsed))
|
||||
}
|
||||
|
||||
const applyTemplate = (mode: 'fill' | 'append') => {
|
||||
const templateConfig = getAdvancedCustomTemplateConfig(templateKey)
|
||||
let nextConfig = templateConfig
|
||||
@@ -713,26 +707,19 @@ export function AdvancedCustomEditorDialog({
|
||||
) : (
|
||||
<div className='p-4'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={formatJson}
|
||||
>
|
||||
{t('Format')}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Advanced text editing')}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={jsonText}
|
||||
onChange={(event) => handleJsonChange(event.target.value)}
|
||||
onChange={handleJsonChange}
|
||||
placeholder={stringifyAdvancedCustomConfig(
|
||||
getAdvancedCustomTemplateConfig(templateKey)
|
||||
)}
|
||||
rows={22}
|
||||
className='min-h-[420px] font-mono text-xs'
|
||||
heightClassName='h-[420px] min-h-[420px] max-h-[420px]'
|
||||
aria-invalid={Boolean(jsonError)}
|
||||
ariaLabel={t('Advanced text editing')}
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Edit JSON text directly. Format will be validated on save.')}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
editTagChannels,
|
||||
@@ -370,13 +370,12 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
{t('(Optional: redirect model names)')}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
id='model-mapping'
|
||||
value={modelMapping}
|
||||
onChange={(e) => setModelMapping(e.target.value)}
|
||||
onChange={setModelMapping}
|
||||
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
|
||||
rows={4}
|
||||
className='font-mono text-sm'
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -1656,17 +1657,6 @@ export function ParamOverrideEditorDialog(
|
||||
[t]
|
||||
)
|
||||
|
||||
const formatJson = useCallback(() => {
|
||||
const trimmed = jsonText.trim()
|
||||
if (!trimmed) return
|
||||
if (!verifyJSON(trimmed)) {
|
||||
toast.error(t('Parameter override must be valid JSON format'))
|
||||
return
|
||||
}
|
||||
setJsonText(JSON.stringify(JSON.parse(trimmed), null, 2))
|
||||
setJsonError('')
|
||||
}, [jsonText, t])
|
||||
|
||||
const visualValidationError = useMemo(() => {
|
||||
if (editMode !== 'visual') return ''
|
||||
try {
|
||||
@@ -1832,12 +1822,12 @@ export function ParamOverrideEditorDialog(
|
||||
<p className='text-muted-foreground mb-2 text-sm'>
|
||||
{t('Legacy Format (JSON Object)')}
|
||||
</p>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={legacyValue}
|
||||
onChange={(e) => setLegacyValue(e.target.value)}
|
||||
onChange={setLegacyValue}
|
||||
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
|
||||
rows={14}
|
||||
className='font-mono text-xs'
|
||||
heightClassName='h-72 min-h-72 max-h-72'
|
||||
ariaLabel={t('Legacy Format (JSON Object)')}
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t(
|
||||
@@ -2044,24 +2034,17 @@ export function ParamOverrideEditorDialog(
|
||||
/* JSON mode */
|
||||
<div className='p-4'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={formatJson}
|
||||
>
|
||||
{t('Format')}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Advanced text editing')}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
onChange={handleJsonChange}
|
||||
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
|
||||
rows={20}
|
||||
className='font-mono text-xs'
|
||||
heightClassName='h-[420px] min-h-[420px] max-h-[420px]'
|
||||
aria-invalid={Boolean(jsonError)}
|
||||
ariaLabel={t('Advanced text editing')}
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Edit JSON text directly. Format will be validated on save.')}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
sideDrawerSectionClassName,
|
||||
sideDrawerSwitchItemClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { JsonEditor } from '@/components/json-editor'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
@@ -3912,17 +3913,18 @@ export function ChannelMutateDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
disabled={
|
||||
sensitiveLocked || isSubmitting
|
||||
}
|
||||
rows={8}
|
||||
placeholder={t(
|
||||
'Override request parameters. Cannot override stream parameter.'
|
||||
)}
|
||||
className='max-h-72 min-h-40 resize-y overflow-auto font-mono text-xs'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -3984,25 +3986,6 @@ export function ChannelMutateDrawer({
|
||||
>
|
||||
{t('Passthrough Template')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
field.value || '{}'
|
||||
)
|
||||
field.onChange(
|
||||
JSON.stringify(parsed, null, 2)
|
||||
)
|
||||
} catch {
|
||||
/* ignore invalid JSON */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Format')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
@@ -4014,17 +3997,19 @@ export function ChannelMutateDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='font-mono text-sm'
|
||||
rows={6}
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
disabled={
|
||||
sensitiveLocked || isSubmitting
|
||||
}
|
||||
placeholder={t(
|
||||
'Enter JSON to override request headers'
|
||||
)}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className='text-xs'>
|
||||
|
||||
@@ -20,12 +20,11 @@ import { Code, Plus, Table, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ModelMappingEditorProps = {
|
||||
value: string
|
||||
@@ -327,17 +326,14 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
|
||||
</Button>
|
||||
</TabsContent>
|
||||
<TabsContent value='json'>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={jsonValue}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
onChange={handleJsonChange}
|
||||
placeholder={t('{"original-model": "replacement-model"}')}
|
||||
disabled={props.disabled}
|
||||
rows={8}
|
||||
className={cn(
|
||||
'font-mono text-sm',
|
||||
jsonError && 'border-destructive'
|
||||
)}
|
||||
className={jsonError ? 'border-destructive' : undefined}
|
||||
aria-invalid={Boolean(jsonError)}
|
||||
ariaLabel={t('Model Mapping')}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -59,7 +60,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
checkClusterNameAvailability,
|
||||
@@ -702,10 +702,14 @@ export function CreateDeploymentDrawer({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Environment variables (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-24 font-mono text-xs'
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='{"KEY":"VALUE"}'
|
||||
{...field}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -722,10 +726,14 @@ export function CreateDeploymentDrawer({
|
||||
{t('Secret environment variables (JSON)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-24 font-mono text-xs'
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='{"SECRET":"VALUE"}'
|
||||
{...field}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { getDeployment, updateDeployment } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
@@ -398,10 +398,14 @@ export function UpdateConfigDialog({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Env (JSON object)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-40 font-mono text-xs'
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='{"KEY":"VALUE"}'
|
||||
{...field}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -415,10 +419,14 @@ export function UpdateConfigDialog({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Secret env (JSON object)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-40 font-mono text-xs'
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='{"SECRET":"VALUE"}'
|
||||
{...field}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
+8
-4
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -604,12 +604,16 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={t(
|
||||
'Optional JSON policy to restrict access based on user info fields'
|
||||
)}
|
||||
className='min-h-[80px] font-mono text-xs'
|
||||
{...field}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { SettingsForm } from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
@@ -172,12 +172,17 @@ export function ChatSettingsSection({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Chat configuration JSON')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={12}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={t(
|
||||
'[{"ChatGPT":"https://chat.openai.com"},{"Lobe Chat":"https://chat-preview.lobehub.com/?settings={...}"}]'
|
||||
)}
|
||||
{...field}
|
||||
heightClassName='h-72 min-h-72 max-h-72'
|
||||
aria-invalid={Boolean(form.formState.errors.Chats)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { SettingsAccordion } from '../components/settings-accordion'
|
||||
import {
|
||||
@@ -199,7 +199,16 @@ export function JsonToggleSection({
|
||||
<FormItem>
|
||||
<FormLabel>{textareaLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={12} placeholder={placeholder} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={placeholder}
|
||||
heightClassName='h-72 min-h-72 max-h-72'
|
||||
aria-invalid={Boolean(form.formState.errors.json)}
|
||||
/>
|
||||
</FormControl>
|
||||
{textareaDescription && (
|
||||
<FormDescription>{t(textareaDescription)}</FormDescription>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { SettingsSwitchField } from '../../components/settings-form-layout'
|
||||
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
|
||||
@@ -661,11 +661,14 @@ export function ChannelAffinitySection(props: Props) {
|
||||
/>
|
||||
) : (
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Rules JSON')}</Label>
|
||||
<Textarea
|
||||
className='min-h-[300px] font-mono text-xs'
|
||||
<Label htmlFor='channel-affinity-rules-json'>
|
||||
{t('Rules JSON')}
|
||||
</Label>
|
||||
<JsonCodeEditor
|
||||
id='channel-affinity-rules-json'
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
onChange={setJsonText}
|
||||
heightClassName='h-[300px] min-h-[300px] max-h-[300px]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -134,6 +135,9 @@ export function RuleEditorDialog(props: Props) {
|
||||
param_override_template_json: '',
|
||||
},
|
||||
})
|
||||
const paramOverrideTemplateField = form.register(
|
||||
'param_override_template_json'
|
||||
)
|
||||
|
||||
const resetFromRule = (r: Partial<AffinityRule>) => {
|
||||
form.reset({
|
||||
@@ -434,12 +438,30 @@ export function RuleEditorDialog(props: Props) {
|
||||
</div>
|
||||
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Parameter Override Template (JSON)')}</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
<Label htmlFor='channel-affinity-param-override-template'>
|
||||
{t('Parameter Override Template (JSON)')}
|
||||
</Label>
|
||||
<JsonCodeEditor
|
||||
id='channel-affinity-param-override-template'
|
||||
value={form.watch('param_override_template_json') || ''}
|
||||
onChange={(value) =>
|
||||
form.setValue('param_override_template_json', value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
name={paramOverrideTemplateField.name}
|
||||
onBlur={() => {
|
||||
void paramOverrideTemplateField.onBlur({
|
||||
target: {
|
||||
name: paramOverrideTemplateField.name,
|
||||
value: form.getValues('param_override_template_json'),
|
||||
},
|
||||
type: 'blur',
|
||||
})
|
||||
}}
|
||||
textareaRef={paramOverrideTemplateField.ref}
|
||||
placeholder='{"operations": [...]}'
|
||||
{...form.register('param_override_template_json')}
|
||||
className='font-mono text-xs'
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { RiskAcknowledgementDialog } from '@/components/risk-acknowledgement-dialog'
|
||||
import {
|
||||
Alert,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { confirmPaymentCompliance } from '../api'
|
||||
@@ -984,15 +984,19 @@ export function PaymentSettingsSection({
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
rows={4}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={t(
|
||||
'[{"name":"支付宝","type":"alipay","icon":"SiAlipay"}]'
|
||||
)}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.PayMethods
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
@@ -1045,13 +1049,17 @@ export function PaymentSettingsSection({
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
rows={4}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='[10, 20, 50, 100]'
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.AmountOptions
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
@@ -1101,13 +1109,17 @@ export function PaymentSettingsSection({
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
rows={4}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='{"100":0.95,"200":0.9}'
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.AmountDiscount
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
@@ -1568,13 +1580,17 @@ export function PaymentSettingsSection({
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
rows={4}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder='[{"name":"Basic","productId":"prod_xxx","price":10,"quota":500000,"currency":"USD"}]'
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.value)
|
||||
}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.CreemProducts
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -196,7 +196,16 @@ export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Request Header Overrides')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.claude?.model_headers_settings
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -215,7 +224,16 @@ export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Default Max Tokens')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.claude?.default_max_tokens
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Example')}{' '}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -248,7 +248,16 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Safety Settings')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.gemini?.safety_settings
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -267,7 +276,16 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Version Overrides')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.gemini?.version_settings
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -286,10 +304,17 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Supported Imagine Models')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={6}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={imaginePlaceholder}
|
||||
{...field}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.gemini?.supported_imagine_models
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -157,21 +157,6 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
||||
|
||||
const pingEnabled = form.watch('general_setting.ping_interval_enabled')
|
||||
|
||||
const formatJsonField = (
|
||||
field:
|
||||
| 'global.thinking_model_blacklist'
|
||||
| 'global.chat_completions_to_responses_policy'
|
||||
) => {
|
||||
const raw = form.getValues(field)
|
||||
if (!raw || !raw.trim()) return
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(raw), null, 2)
|
||||
form.setValue(field, formatted, { shouldDirty: true })
|
||||
} catch {
|
||||
toast.error(t('Invalid JSON format'))
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (values: GlobalModelSettingsFormValues) => {
|
||||
const flattenedDefaults = flattenGlobalValues(defaultValues)
|
||||
const flattenedValues = flattenGlobalValues(values)
|
||||
@@ -233,11 +218,14 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
||||
{t('Models that skip thinking suffix processing')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={`${t('Example:')}\n${thinkingBlacklistExample}`}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
heightClassName='h-32 min-h-32 max-h-32'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -245,18 +233,6 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
||||
'Models listed here will not automatically append or remove -thinking / -nothinking suffixes.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
formatJsonField('global.thinking_model_blacklist')
|
||||
}
|
||||
>
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -292,11 +268,13 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t('Policy JSON')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={8}
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
placeholder={`${t('Example (specific channels):')}\n${chatToResponsesPolicyExample}\n\n${t('Example (all channels):')}\n${chatToResponsesPolicyAllChannelsExample}`}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -331,18 +309,6 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
||||
>
|
||||
{t('Fill example (all channels)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
formatJsonField(
|
||||
'global.chat_completions_to_responses_policy'
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -50,7 +51,6 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -215,7 +215,13 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group ratios')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -234,7 +240,14 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Top-up group ratios')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -254,7 +267,14 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Selectable groups')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -273,7 +293,13 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Inter-group overrides')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Nested JSON: source group →')}{' '}
|
||||
@@ -294,7 +320,14 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auto assignment order')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
@@ -313,7 +346,13 @@ export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t('Special usable group rules')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
|
||||
@@ -149,6 +149,9 @@ function ModelJsonTextareaField(props: {
|
||||
<JsonCodeEditor
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
textareaRef={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className='text-xs leading-5'>
|
||||
|
||||
@@ -22,10 +22,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
@@ -308,12 +308,11 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
<JsonCodeEditor
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
className='font-mono text-sm'
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
onChange={handleJsonChange}
|
||||
heightClassName='h-72 min-h-72 max-h-72'
|
||||
aria-invalid={Boolean(jsonError)}
|
||||
/>
|
||||
{jsonError && <p className='text-destructive text-sm'>{jsonError}</p>}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
@@ -273,11 +273,16 @@ export function RateLimitSection({ defaultValues }: RateLimitSectionProps) {
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
rows={8}
|
||||
<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}`}
|
||||
className='font-mono text-sm'
|
||||
{...field}
|
||||
aria-invalid={Boolean(
|
||||
form.formState.errors.ModelRequestRateLimitGroup
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
@@ -45,6 +45,49 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
.json-code-editor-textarea {
|
||||
overflow: auto !important;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.json-code-editor-textarea::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 1;
|
||||
-webkit-text-fill-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.json-code-editor-textarea,
|
||||
.json-code-editor-highlight,
|
||||
.json-code-editor-lines {
|
||||
overflow-wrap: normal !important;
|
||||
white-space: pre !important;
|
||||
}
|
||||
|
||||
.json-code-editor-lines {
|
||||
background: var(--background);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.json-code-editor-highlight .yace-tok--str {
|
||||
color: var(--chart-5);
|
||||
}
|
||||
|
||||
.json-code-editor-highlight .yace-tok--num {
|
||||
color: var(--chart-4);
|
||||
}
|
||||
|
||||
.json-code-editor-highlight .yace-tok--kw {
|
||||
color: var(--chart-1);
|
||||
}
|
||||
|
||||
.json-code-editor-highlight .yace-tok--punc {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.json-code-editor-highlight .yace-tok--com {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user