Skip to content

Commit 7065d70

Browse files
Merge pull request #136 from olaservo/tool-input-improvements
Add enhanced object input editing
2 parents 7b3dff6 + 59e7639 commit 7065d70

File tree

5 files changed

+418
-57
lines changed

5 files changed

+418
-57
lines changed

client/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@
2727
"@radix-ui/react-select": "^2.1.2",
2828
"@radix-ui/react-slot": "^1.1.0",
2929
"@radix-ui/react-tabs": "^1.1.1",
30+
"@types/prismjs": "^1.26.5",
3031
"class-variance-authority": "^0.7.0",
3132
"clsx": "^2.1.1",
3233
"lucide-react": "^0.447.0",
34+
"prismjs": "^1.29.0",
3335
"pkce-challenge": "^4.1.0",
3436
"react": "^18.3.1",
3537
"react-dom": "^18.3.1",
38+
"react-simple-code-editor": "^0.14.1",
3639
"react-toastify": "^10.0.6",
3740
"serve-handler": "^6.1.6",
3841
"tailwind-merge": "^2.5.3",
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { useState } from "react";
2+
import { Button } from "@/components/ui/button";
3+
import { Input } from "@/components/ui/input";
4+
import { Label } from "@/components/ui/label";
5+
import JsonEditor from "./JsonEditor";
6+
7+
export type JsonValue =
8+
| string
9+
| number
10+
| boolean
11+
| null
12+
| JsonValue[]
13+
| { [key: string]: JsonValue };
14+
15+
export type JsonSchemaType = {
16+
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
17+
description?: string;
18+
properties?: Record<string, JsonSchemaType>;
19+
items?: JsonSchemaType;
20+
};
21+
22+
type JsonObject = { [key: string]: JsonValue };
23+
24+
interface DynamicJsonFormProps {
25+
schema: JsonSchemaType;
26+
value: JsonValue;
27+
onChange: (value: JsonValue) => void;
28+
maxDepth?: number;
29+
}
30+
31+
const formatFieldLabel = (key: string): string => {
32+
return key
33+
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
34+
.replace(/_/g, " ") // Replace underscores with spaces
35+
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
36+
};
37+
38+
const DynamicJsonForm = ({
39+
schema,
40+
value,
41+
onChange,
42+
maxDepth = 3,
43+
}: DynamicJsonFormProps) => {
44+
const [isJsonMode, setIsJsonMode] = useState(false);
45+
const [jsonError, setJsonError] = useState<string>();
46+
47+
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => {
48+
switch (propSchema.type) {
49+
case "string":
50+
return "";
51+
case "number":
52+
case "integer":
53+
return 0;
54+
case "boolean":
55+
return false;
56+
case "array":
57+
return [];
58+
case "object": {
59+
const obj: JsonObject = {};
60+
if (propSchema.properties) {
61+
Object.entries(propSchema.properties).forEach(([key, prop]) => {
62+
obj[key] = generateDefaultValue(prop);
63+
});
64+
}
65+
return obj;
66+
}
67+
default:
68+
return null;
69+
}
70+
};
71+
72+
const renderFormFields = (
73+
propSchema: JsonSchemaType,
74+
currentValue: JsonValue,
75+
path: string[] = [],
76+
depth: number = 0,
77+
) => {
78+
if (
79+
depth >= maxDepth &&
80+
(propSchema.type === "object" || propSchema.type === "array")
81+
) {
82+
// Render as JSON editor when max depth is reached
83+
return (
84+
<JsonEditor
85+
value={JSON.stringify(
86+
currentValue ?? generateDefaultValue(propSchema),
87+
null,
88+
2,
89+
)}
90+
onChange={(newValue) => {
91+
try {
92+
const parsed = JSON.parse(newValue);
93+
handleFieldChange(path, parsed);
94+
setJsonError(undefined);
95+
} catch (err) {
96+
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
97+
}
98+
}}
99+
error={jsonError}
100+
/>
101+
);
102+
}
103+
104+
switch (propSchema.type) {
105+
case "string":
106+
case "number":
107+
case "integer":
108+
return (
109+
<Input
110+
type={propSchema.type === "string" ? "text" : "number"}
111+
value={(currentValue as string | number) ?? ""}
112+
onChange={(e) =>
113+
handleFieldChange(
114+
path,
115+
propSchema.type === "string"
116+
? e.target.value
117+
: Number(e.target.value),
118+
)
119+
}
120+
placeholder={propSchema.description}
121+
/>
122+
);
123+
case "boolean":
124+
return (
125+
<Input
126+
type="checkbox"
127+
checked={(currentValue as boolean) ?? false}
128+
onChange={(e) => handleFieldChange(path, e.target.checked)}
129+
className="w-4 h-4"
130+
/>
131+
);
132+
case "object":
133+
if (!propSchema.properties) return null;
134+
return (
135+
<div className="space-y-4 border rounded-md p-4">
136+
{Object.entries(propSchema.properties).map(([key, prop]) => (
137+
<div key={key} className="space-y-2">
138+
<Label>{formatFieldLabel(key)}</Label>
139+
{renderFormFields(
140+
prop,
141+
(currentValue as JsonObject)?.[key],
142+
[...path, key],
143+
depth + 1,
144+
)}
145+
</div>
146+
))}
147+
</div>
148+
);
149+
case "array": {
150+
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
151+
if (!propSchema.items) return null;
152+
return (
153+
<div className="space-y-4">
154+
{propSchema.description && (
155+
<p className="text-sm text-gray-600">{propSchema.description}</p>
156+
)}
157+
158+
{propSchema.items?.description && (
159+
<p className="text-sm text-gray-500">
160+
Items: {propSchema.items.description}
161+
</p>
162+
)}
163+
164+
<div className="space-y-2">
165+
{arrayValue.map((item, index) => (
166+
<div key={index} className="flex items-center gap-2">
167+
{renderFormFields(
168+
propSchema.items as JsonSchemaType,
169+
item,
170+
[...path, index.toString()],
171+
depth + 1,
172+
)}
173+
<Button
174+
variant="outline"
175+
size="sm"
176+
onClick={() => {
177+
const newArray = [...arrayValue];
178+
newArray.splice(index, 1);
179+
handleFieldChange(path, newArray);
180+
}}
181+
>
182+
Remove
183+
</Button>
184+
</div>
185+
))}
186+
<Button
187+
variant="outline"
188+
size="sm"
189+
onClick={() => {
190+
handleFieldChange(path, [
191+
...arrayValue,
192+
generateDefaultValue(propSchema.items as JsonSchemaType),
193+
]);
194+
}}
195+
title={
196+
propSchema.items?.description
197+
? `Add new ${propSchema.items.description}`
198+
: "Add new item"
199+
}
200+
>
201+
Add Item
202+
</Button>
203+
</div>
204+
</div>
205+
);
206+
}
207+
default:
208+
return null;
209+
}
210+
};
211+
212+
const handleFieldChange = (path: string[], fieldValue: JsonValue) => {
213+
if (path.length === 0) {
214+
onChange(fieldValue);
215+
return;
216+
}
217+
218+
const newValue = {
219+
...(typeof value === "object" && value !== null && !Array.isArray(value)
220+
? value
221+
: {}),
222+
} as JsonObject;
223+
let current: JsonObject = newValue;
224+
225+
for (let i = 0; i < path.length - 1; i++) {
226+
const key = path[i];
227+
if (!(key in current)) {
228+
current[key] = {};
229+
}
230+
current = current[key] as JsonObject;
231+
}
232+
233+
current[path[path.length - 1]] = fieldValue;
234+
onChange(newValue);
235+
};
236+
237+
return (
238+
<div className="space-y-4">
239+
<div className="flex justify-end">
240+
<Button
241+
variant="outline"
242+
size="sm"
243+
onClick={() => setIsJsonMode(!isJsonMode)}
244+
>
245+
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
246+
</Button>
247+
</div>
248+
249+
{isJsonMode ? (
250+
<JsonEditor
251+
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)}
252+
onChange={(newValue) => {
253+
try {
254+
onChange(JSON.parse(newValue));
255+
setJsonError(undefined);
256+
} catch (err) {
257+
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
258+
}
259+
}}
260+
error={jsonError}
261+
/>
262+
) : (
263+
renderFormFields(schema, value)
264+
)}
265+
</div>
266+
);
267+
};
268+
269+
export default DynamicJsonForm;

client/src/components/JsonEditor.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Editor from "react-simple-code-editor";
2+
import Prism from "prismjs";
3+
import "prismjs/components/prism-json";
4+
import "prismjs/themes/prism.css";
5+
import { Button } from "@/components/ui/button";
6+
7+
interface JsonEditorProps {
8+
value: string;
9+
onChange: (value: string) => void;
10+
error?: string;
11+
}
12+
13+
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
14+
const formatJson = (json: string): string => {
15+
try {
16+
return JSON.stringify(JSON.parse(json), null, 2);
17+
} catch {
18+
return json;
19+
}
20+
};
21+
22+
return (
23+
<div className="relative space-y-2">
24+
<div className="flex justify-end">
25+
<Button
26+
variant="outline"
27+
size="sm"
28+
onClick={() => onChange(formatJson(value))}
29+
>
30+
Format JSON
31+
</Button>
32+
</div>
33+
<div
34+
className={`border rounded-md ${
35+
error ? "border-red-500" : "border-gray-200 dark:border-gray-800"
36+
}`}
37+
>
38+
<Editor
39+
value={value}
40+
onValueChange={onChange}
41+
highlight={(code) =>
42+
Prism.highlight(code, Prism.languages.json, "json")
43+
}
44+
padding={10}
45+
style={{
46+
fontFamily: '"Fira code", "Fira Mono", monospace',
47+
fontSize: 14,
48+
backgroundColor: "transparent",
49+
minHeight: "100px",
50+
}}
51+
className="w-full"
52+
/>
53+
</div>
54+
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
55+
</div>
56+
);
57+
};
58+
59+
export default JsonEditor;

0 commit comments

Comments
 (0)