Skip to content

Commit c346b93

Browse files
committed
feat: add JSON value type to key-value fields for flexible records
addParams and similar record fields accept nested objects/arrays in the schema but the UI only supported primitive types. The allowed value types are now derived from the schema: - headers (Record<string, string>) → string only, type selector hidden - addParams (Record<string, union>) → string/number/boolean/json Add inferRecordKVTypes() to extract allowed KV types from the Zod schema's record value type. Fields with a single allowed type hide the type dropdown entirely. The JSON type renders a resizable textarea for raw JSON input, parsed on save.
1 parent 900b857 commit c346b93

8 files changed

Lines changed: 111 additions & 43 deletions

File tree

src/components/configuration/FieldRenderer.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
getControlType,
1111
getEnumOptions,
1212
hasDescendant,
13-
inferKVType,
13+
toKVPair,
1414
isStringLikeItemType,
1515
splitUnionTypes,
1616
} from './utils';
@@ -413,11 +413,7 @@ export function SingleFieldRenderer({
413413
typeof currentValue === 'object' && currentValue !== null
414414
? (currentValue as Record<string, t.ConfigValue>)
415415
: {},
416-
).map(([k, v]) => ({
417-
key: k,
418-
value: typeof v === 'string' ? v : JSON.stringify(v ?? ''),
419-
valueType: inferKVType(v),
420-
}));
416+
).map(([k, v]) => toKVPair(k, v));
421417

422418
return (
423419
<ConfigRow
@@ -432,6 +428,7 @@ export function SingleFieldRenderer({
432428
pairs={pairs}
433429
onChange={(newPairs) => onChange(path, newPairs)}
434430
disabled={disabled}
431+
valueTypes={field.recordValueKVTypes}
435432
aria-label={fieldLabel}
436433
/>
437434
</ConfigRow>
@@ -1088,6 +1085,7 @@ export function renderInlineField(
10881085
pairs={pairs}
10891086
onChange={(p) => onChange(field.key, p)}
10901087
disabled={disabled}
1088+
valueTypes={field.recordValueKVTypes}
10911089
aria-label={fieldLabel}
10921090
/>
10931091
</InlineRow>

src/components/configuration/ProfileValueModal.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState } from 'react';
22
import { PrincipalType } from 'librechat-data-provider';
33
import { Icon, Button, Dialog } from '@clickhouse/click-ui';
44
import type * as t from '@/types';
5-
import { getEnumOptions, getArrayItemType, inferKVType } from './utils';
5+
import { getEnumOptions, getArrayItemType, toKVPair } from './utils';
66
import { KeyValueField } from './fields/KeyValueField';
77
import { TrashButton } from '@/components/shared';
88
import { getScopeTypeConfig } from '@/constants';
@@ -202,11 +202,7 @@ function ModalValueControl({
202202
typeof value === 'object' && value !== null
203203
? (value as Record<string, t.ConfigValue>)
204204
: {},
205-
).map(([k, v]) => ({
206-
key: k,
207-
value: typeof v === 'string' ? v : JSON.stringify(v ?? ''),
208-
valueType: inferKVType(v),
209-
}));
205+
).map(([k, v]) => toKVPair(k, v));
210206

211207
return (
212208
<KeyValueField

src/components/configuration/fields/KeyValueField.tsx

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,26 @@ import type * as t from '@/types';
44
import { AddItemButton, TrashButton } from '@/components/shared';
55
import { useLocalize } from '@/hooks';
66

7-
const VALUE_TYPES: t.KVValueType[] = ['string', 'number', 'boolean'];
7+
const DEFAULT_TYPES: t.KVValueType[] = ['string', 'number', 'boolean'];
88
const TYPE_LABELS: Record<t.KVValueType, string> = {
99
string: 'abc',
1010
number: '123',
1111
boolean: 'T/F',
12+
json: '{ }',
1213
};
1314

1415
export function KeyValueField({
1516
id,
1617
pairs,
1718
onChange,
1819
disabled,
20+
valueTypes,
1921
keyPlaceholder,
2022
valuePlaceholder,
2123
'aria-label': ariaLabel,
2224
}: t.KeyValueFieldProps) {
2325
const localize = useLocalize();
26+
const availableTypes = valueTypes ?? DEFAULT_TYPES;
2427
const listRef = useRef<HTMLDivElement>(null);
2528
const focusLastKeyRef = useRef(false);
2629

@@ -34,7 +37,7 @@ export function KeyValueField({
3437
});
3538

3639
const handleAdd = () => {
37-
onChange([...pairs, { key: '', value: '', valueType: 'string' }]);
40+
onChange([...pairs, { key: '', value: '', valueType: availableTypes[0] }]);
3841
focusLastKeyRef.current = true;
3942
};
4043
const handleRemove = (index: number) => onChange(pairs.filter((_, i) => i !== index));
@@ -49,11 +52,56 @@ export function KeyValueField({
4952
let coerced = pair.value;
5053
if (newType === 'boolean') {
5154
coerced = pair.value === 'true' || pair.value === '1' ? 'true' : 'false';
55+
} else if (newType === 'json' && pair.valueType !== 'json') {
56+
coerced = pair.value || '{}';
5257
}
5358
next[index] = { ...pair, value: coerced, valueType: newType };
5459
onChange(next);
5560
};
5661

62+
const renderValueInput = (vType: t.KVValueType, pair: t.KeyValuePair, index: number) => {
63+
const valueLabel = `${localize('com_ui_value')} ${index + 1}`;
64+
if (vType === 'boolean') {
65+
return (
66+
<div className="select-field-a11y flex-2">
67+
<Select
68+
value={pair.value === 'true' ? 'true' : 'false'}
69+
onSelect={(v) => handleChange(index, 'value', v)}
70+
disabled={disabled}
71+
aria-label={valueLabel}
72+
>
73+
<Select.Item value="true">true</Select.Item>
74+
<Select.Item value="false">false</Select.Item>
75+
</Select>
76+
</div>
77+
);
78+
}
79+
if (vType === 'json') {
80+
return (
81+
<textarea
82+
value={pair.value}
83+
onChange={(e) => handleChange(index, 'value', e.target.value)}
84+
placeholder='{"key": "value"}'
85+
disabled={disabled}
86+
aria-label={valueLabel}
87+
rows={2}
88+
className="config-input flex-2 resize-y font-mono text-xs"
89+
/>
90+
);
91+
}
92+
return (
93+
<input
94+
type={vType === 'number' ? 'number' : 'text'}
95+
value={pair.value}
96+
onChange={(e) => handleChange(index, 'value', e.target.value)}
97+
placeholder={valuePlaceholder ?? localize('com_ui_value')}
98+
disabled={disabled}
99+
aria-label={valueLabel}
100+
className="config-input flex-2"
101+
/>
102+
);
103+
};
104+
57105
return (
58106
<div
59107
ref={listRef}
@@ -75,37 +123,15 @@ export function KeyValueField({
75123
aria-label={`${localize('com_ui_key')} ${index + 1}`}
76124
className="config-input max-w-37.5 flex-1"
77125
/>
78-
{vType === 'boolean' ? (
79-
<div className="select-field-a11y flex-2">
80-
<Select
81-
value={pair.value === 'true' ? 'true' : 'false'}
82-
onSelect={(v) => handleChange(index, 'value', v)}
83-
disabled={disabled}
84-
aria-label={`${localize('com_ui_value')} ${index + 1}`}
85-
>
86-
<Select.Item value="true">true</Select.Item>
87-
<Select.Item value="false">false</Select.Item>
88-
</Select>
89-
</div>
90-
) : (
91-
<input
92-
type={vType === 'number' ? 'number' : 'text'}
93-
value={pair.value}
94-
onChange={(e) => handleChange(index, 'value', e.target.value)}
95-
placeholder={valuePlaceholder ?? localize('com_ui_value')}
96-
disabled={disabled}
97-
aria-label={`${localize('com_ui_value')} ${index + 1}`}
98-
className="config-input flex-2"
99-
/>
100-
)}
101-
{!disabled && (
126+
{renderValueInput(vType, pair, index)}
127+
{!disabled && availableTypes.length > 1 && (
102128
<div className="select-field-a11y w-20 shrink-0">
103129
<Select
104130
value={vType}
105131
onSelect={(v) => handleTypeChange(index, v as t.KVValueType)}
106132
aria-label={`${localize('com_config_field_type')} ${index + 1}`}
107133
>
108-
{VALUE_TYPES.map((vt) => (
134+
{availableTypes.map((vt) => (
109135
<Select.Item key={vt} value={vt}>
110136
{TYPE_LABELS[vt]}
111137
</Select.Item>

src/components/configuration/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import type * as t from '@/types';
33
export function inferKVType(v: t.ConfigValue): t.KVValueType {
44
if (typeof v === 'boolean') return 'boolean';
55
if (typeof v === 'number') return 'number';
6+
if (typeof v === 'object' && v !== null) return 'json';
67
return 'string';
78
}
89

10+
export function toKVPair(k: string, v: t.ConfigValue): t.KeyValuePair {
11+
const valueType = inferKVType(v);
12+
if (valueType === 'json') return { key: k, value: JSON.stringify(v, null, 2), valueType };
13+
return { key: k, value: typeof v === 'string' ? v : String(v ?? ''), valueType };
14+
}
15+
916
export function getControlType(field: t.SchemaField): t.ControlType {
1017
if (field.type === 'boolean') return 'toggle';
1118
if (field.type.startsWith('enum')) return 'select';

src/server/config.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,35 @@ function hasUnionObjectVariant(schema: t.ZodSchemaLike): boolean {
101101
return options.some((opt: t.ZodSchemaLike) => opt && typeof opt === 'object' && 'shape' in opt);
102102
}
103103

104+
const ZOD_TO_KV: Record<string, t.KVValueType> = {
105+
ZodString: 'string',
106+
ZodNumber: 'number',
107+
ZodBoolean: 'boolean',
108+
};
109+
110+
function inferRecordKVTypes(schema: t.ZodSchemaLike): t.KVValueType[] | undefined {
111+
if (!schema?._def) return undefined;
112+
const tn = schema._def.typeName;
113+
if (tn && tn in ZOD_TO_KV) return [ZOD_TO_KV[tn]];
114+
if (tn !== 'ZodUnion') return undefined;
115+
const types = new Set<t.KVValueType>();
116+
for (const opt of schema._def.options ?? []) {
117+
const optTn = opt?._def?.typeName;
118+
if (optTn && optTn in ZOD_TO_KV) {
119+
types.add(ZOD_TO_KV[optTn]);
120+
} else if (
121+
optTn === 'ZodRecord' ||
122+
optTn === 'ZodArray' ||
123+
optTn === 'ZodObject' ||
124+
(opt && typeof opt === 'object' && 'shape' in opt)
125+
) {
126+
types.add('json');
127+
}
128+
}
129+
return types.size > 0 ? [...types] : undefined;
130+
}
131+
132+
104133
/** Merges fields from union object variants into a single list.
105134
* When the same key appears in multiple variants with different literal
106135
* types, the literals are combined into a union(literal(...) | literal(...))
@@ -272,6 +301,7 @@ export function extractSchemaTree(
272301
let children: t.SchemaField[] | undefined;
273302
let recordValueType: 'primitive' | 'complex' | undefined;
274303
let recordValueAllowsPrimitive: boolean | undefined;
304+
let recordValueKVTypes: t.KVValueType[] | undefined;
275305

276306
if (isArray && innerSchema?._def?.type) {
277307
let elementSchema: t.ZodSchemaLike = innerSchema._def.type;
@@ -301,6 +331,7 @@ export function extractSchemaTree(
301331
recordValueAllowsPrimitive = true;
302332
} else {
303333
recordValueType = 'primitive';
334+
recordValueKVTypes = inferRecordKVTypes(unwrapped);
304335
}
305336
}
306337
}
@@ -318,6 +349,7 @@ export function extractSchemaTree(
318349
depth,
319350
recordValueType,
320351
recordValueAllowsPrimitive,
352+
recordValueKVTypes,
321353
});
322354
}
323355
}

src/types/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface SchemaField {
3939
depth: number;
4040
recordValueType?: 'primitive' | 'complex';
4141
recordValueAllowsPrimitive?: boolean;
42+
recordValueKVTypes?: KVValueType[];
4243
}
4344

4445
export interface ZodDef {
@@ -71,7 +72,7 @@ export interface SelectOption {
7172
value: string;
7273
}
7374

74-
export type KVValueType = 'string' | 'number' | 'boolean';
75+
export type KVValueType = 'string' | 'number' | 'boolean' | 'json';
7576

7677
export interface KeyValuePair {
7778
[k: string]: string | KVValueType | undefined;

src/types/fields.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactNode } from 'react';
22
import type React from 'react';
3-
import type { ConfigValue, SchemaField, SelectOption, KeyValuePair } from './config';
3+
import type { ConfigValue, SchemaField, SelectOption, KeyValuePair, KVValueType } from './config';
44

55
export interface SelectFieldProps {
66
id: string;
@@ -17,6 +17,7 @@ export interface KeyValueFieldProps {
1717
pairs: KeyValuePair[];
1818
onChange: (pairs: KeyValuePair[]) => void;
1919
disabled?: boolean;
20+
valueTypes?: KVValueType[];
2021
keyPlaceholder?: string;
2122
valuePlaceholder?: string;
2223
'aria-label'?: string;

src/utils/format.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,27 @@ export function serializeKVPairs(value: t.ConfigValue): t.ConfigValue {
1111
return value;
1212
const pairs = value as t.KeyValuePair[];
1313
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
14-
const record: Record<string, string | number | boolean> = Object.create(null);
14+
const record: Record<string, t.ConfigValue> = Object.create(null);
1515
for (const pair of pairs) {
1616
if (!pair.key || DANGEROUS_KEYS.has(pair.key)) continue;
1717
record[pair.key] = coerceKVValue(pair.value, pair.valueType ?? 'string');
1818
}
1919
return record;
2020
}
2121

22-
function coerceKVValue(raw: string, type: t.KVValueType): string | number | boolean {
22+
function coerceKVValue(raw: string, type: t.KVValueType): t.ConfigValue {
2323
if (type === 'boolean') return raw === 'true';
2424
if (type === 'number') {
2525
const n = Number(raw);
2626
return Number.isFinite(n) ? n : raw;
2727
}
28+
if (type === 'json') {
29+
try {
30+
return JSON.parse(raw) as t.ConfigValue;
31+
} catch {
32+
return raw;
33+
}
34+
}
2835
return raw;
2936
}
3037

0 commit comments

Comments
 (0)