Merge branch 'main' into add-log-level-setting
This commit is contained in:
@@ -1,26 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
|
||||
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type JsonSchemaType = {
|
||||
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||
type:
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "null";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
};
|
||||
|
||||
type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
interface DynamicJsonFormProps {
|
||||
schema: JsonSchemaType;
|
||||
value: JsonValue;
|
||||
@@ -28,13 +38,6 @@ interface DynamicJsonFormProps {
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const formatFieldLabel = (key: string): string => {
|
||||
return key
|
||||
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
|
||||
.replace(/_/g, " ") // Replace underscores with spaces
|
||||
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
schema,
|
||||
value,
|
||||
@@ -43,29 +46,65 @@ const DynamicJsonForm = ({
|
||||
}: DynamicJsonFormProps) => {
|
||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||
const [jsonError, setJsonError] = useState<string>();
|
||||
// Store the raw JSON string to allow immediate feedback during typing
|
||||
// while deferring parsing until the user stops typing
|
||||
const [rawJsonValue, setRawJsonValue] = useState<string>(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
|
||||
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => {
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object": {
|
||||
const obj: JsonObject = {};
|
||||
if (propSchema.properties) {
|
||||
Object.entries(propSchema.properties).forEach(([key, prop]) => {
|
||||
obj[key] = generateDefaultValue(prop);
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
// Use a ref to manage debouncing timeouts to avoid parsing JSON
|
||||
// on every keystroke which would be inefficient and error-prone
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Debounce JSON parsing and parent updates to handle typing gracefully
|
||||
const debouncedUpdateParent = useCallback(
|
||||
(jsonString: string) => {
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
|
||||
// Set a new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
onChange(parsed);
|
||||
setJsonError(undefined);
|
||||
} catch {
|
||||
// Don't set error during normal typing
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[onChange, setJsonError],
|
||||
);
|
||||
|
||||
// Update rawJsonValue when value prop changes
|
||||
useEffect(() => {
|
||||
if (!isJsonMode) {
|
||||
setRawJsonValue(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
}
|
||||
}, [value, schema, isJsonMode]);
|
||||
|
||||
const handleSwitchToFormMode = () => {
|
||||
if (isJsonMode) {
|
||||
// When switching to Form mode, ensure we have valid JSON
|
||||
try {
|
||||
const parsed = JSON.parse(rawJsonValue);
|
||||
// Update the parent component's state with the parsed value
|
||||
onChange(parsed);
|
||||
// Switch to form mode
|
||||
setIsJsonMode(false);
|
||||
} catch (err) {
|
||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
} else {
|
||||
// Update raw JSON value when switching to JSON mode
|
||||
setRawJsonValue(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
setIsJsonMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,21 +142,68 @@ const DynamicJsonForm = ({
|
||||
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required fields by setting undefined
|
||||
// This preserves the distinction between empty string and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required number fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
type={propSchema.type === "string" ? "text" : "number"}
|
||||
value={(currentValue as string | number) ?? ""}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
path,
|
||||
propSchema.type === "string"
|
||||
? e.target.value
|
||||
: Number(e.target.value),
|
||||
)
|
||||
}
|
||||
type="number"
|
||||
step="1"
|
||||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required integer fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
// Only update if it's a valid integer
|
||||
if (!isNaN(num) && Number.isInteger(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
@@ -127,25 +213,53 @@ const DynamicJsonForm = ({
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "object":
|
||||
if (!propSchema.properties) return null;
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
{renderFormFields(
|
||||
prop,
|
||||
(currentValue as JsonObject)?.[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "object": {
|
||||
// Handle case where we have a value but no schema properties
|
||||
const objectValue = (currentValue as JsonObject) || {};
|
||||
|
||||
// If we have schema properties, use them to render fields
|
||||
if (propSchema.properties) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
{renderFormFields(
|
||||
prop,
|
||||
objectValue[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have a value but no schema properties, render fields based on the value
|
||||
else if (Object.keys(objectValue).length > 0) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(objectValue).map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
handleFieldChange([...path, key], e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have neither schema properties nor value, return null
|
||||
return null;
|
||||
}
|
||||
case "array": {
|
||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (!propSchema.items) return null;
|
||||
@@ -187,9 +301,12 @@ const DynamicJsonForm = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const defaultValue = generateDefaultValue(
|
||||
propSchema.items as JsonSchemaType,
|
||||
);
|
||||
handleFieldChange(path, [
|
||||
...arrayValue,
|
||||
generateDefaultValue(propSchema.items as JsonSchemaType),
|
||||
defaultValue ?? null,
|
||||
]);
|
||||
}}
|
||||
title={
|
||||
@@ -215,139 +332,65 @@ const DynamicJsonForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const updateArray = (
|
||||
array: JsonValue[],
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonValue[] => {
|
||||
const [index, ...restPath] = path;
|
||||
const arrayIndex = Number(index);
|
||||
|
||||
// Validate array index
|
||||
if (isNaN(arrayIndex)) {
|
||||
console.error(`Invalid array index: ${index}`);
|
||||
return array;
|
||||
}
|
||||
|
||||
// Check array bounds
|
||||
if (arrayIndex < 0) {
|
||||
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
|
||||
return array;
|
||||
}
|
||||
|
||||
const newArray = [...array];
|
||||
|
||||
if (restPath.length === 0) {
|
||||
newArray[arrayIndex] = value;
|
||||
} else {
|
||||
// Ensure index position exists
|
||||
if (arrayIndex >= array.length) {
|
||||
console.warn(`Extending array to index ${arrayIndex}`);
|
||||
newArray.length = arrayIndex + 1;
|
||||
newArray.fill(null, array.length, arrayIndex);
|
||||
}
|
||||
newArray[arrayIndex] = updateValue(
|
||||
newArray[arrayIndex],
|
||||
restPath,
|
||||
value,
|
||||
);
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const updateObject = (
|
||||
obj: JsonObject,
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonObject => {
|
||||
const [key, ...restPath] = path;
|
||||
|
||||
// Validate object key
|
||||
if (typeof key !== "string") {
|
||||
console.error(`Invalid object key: ${key}`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
const newObj = { ...obj };
|
||||
|
||||
if (restPath.length === 0) {
|
||||
newObj[key] = value;
|
||||
} else {
|
||||
// Ensure key exists
|
||||
if (!(key in newObj)) {
|
||||
console.warn(`Creating new key in object: ${key}`);
|
||||
newObj[key] = {};
|
||||
}
|
||||
newObj[key] = updateValue(newObj[key], restPath, value);
|
||||
}
|
||||
return newObj;
|
||||
};
|
||||
|
||||
const updateValue = (
|
||||
current: JsonValue,
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonValue => {
|
||||
if (path.length === 0) return value;
|
||||
|
||||
try {
|
||||
if (!current) {
|
||||
current = !isNaN(Number(path[0])) ? [] : {};
|
||||
}
|
||||
|
||||
// Type checking
|
||||
if (Array.isArray(current)) {
|
||||
return updateArray(current, path, value);
|
||||
} else if (typeof current === "object" && current !== null) {
|
||||
return updateObject(current, path, value);
|
||||
} else {
|
||||
console.error(
|
||||
`Cannot update path ${path.join(".")} in non-object/array value:`,
|
||||
current,
|
||||
);
|
||||
return current;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating value at path ${path.join(".")}:`, error);
|
||||
return current;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const newValue = updateValue(value, path, fieldValue);
|
||||
const newValue = updateValueAtPath(value, path, fieldValue);
|
||||
onChange(newValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to update form value:", error);
|
||||
// Keep the original value unchanged
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldUseJsonMode =
|
||||
schema.type === "object" &&
|
||||
(!schema.properties || Object.keys(schema.properties).length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUseJsonMode && !isJsonMode) {
|
||||
setIsJsonMode(true);
|
||||
}
|
||||
}, [shouldUseJsonMode, isJsonMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsJsonMode(!isJsonMode)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isJsonMode ? (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)}
|
||||
value={rawJsonValue}
|
||||
onChange={(newValue) => {
|
||||
try {
|
||||
onChange(JSON.parse(newValue));
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
// Always update local state
|
||||
setRawJsonValue(newValue);
|
||||
|
||||
// Use the debounced function to attempt parsing and updating parent
|
||||
debouncedUpdateParent(newValue);
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
|
||||
// render a simple representation of the JSON data
|
||||
schema.type === "object" &&
|
||||
(typeof value !== "object" ||
|
||||
value === null ||
|
||||
Object.keys(value).length === 0) &&
|
||||
rawJsonValue &&
|
||||
rawJsonValue !== "{}" ? (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Form view not available for this JSON structure. Using simplified
|
||||
view:
|
||||
</p>
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
|
||||
{rawJsonValue}
|
||||
</pre>
|
||||
<p className="text-sm text-gray-500">
|
||||
Use JSON mode for full editing capabilities.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderFormFields(schema, value)
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Editor from "react-simple-code-editor";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-json";
|
||||
@@ -10,7 +11,20 @@ interface JsonEditorProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
||||
const JsonEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
error: externalError,
|
||||
}: JsonEditorProps) => {
|
||||
const [editorContent, setEditorContent] = useState(value);
|
||||
const [internalError, setInternalError] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditorContent(value);
|
||||
}, [value]);
|
||||
|
||||
const formatJson = (json: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(json), null, 2);
|
||||
@@ -19,25 +33,42 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorChange = (newContent: string) => {
|
||||
setEditorContent(newContent);
|
||||
setInternalError(undefined);
|
||||
onChange(newContent);
|
||||
};
|
||||
|
||||
const handleFormatJson = () => {
|
||||
try {
|
||||
const formatted = formatJson(editorContent);
|
||||
setEditorContent(formatted);
|
||||
onChange(formatted);
|
||||
setInternalError(undefined);
|
||||
} catch (err) {
|
||||
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = internalError || externalError;
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(formatJson(value))}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={handleFormatJson}>
|
||||
Format JSON
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`border rounded-md ${
|
||||
error ? "border-red-500" : "border-gray-200 dark:border-gray-800"
|
||||
displayError
|
||||
? "border-red-500"
|
||||
: "border-gray-200 dark:border-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
value={editorContent}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={(code) =>
|
||||
Prism.highlight(code, Prism.languages.json, "json")
|
||||
}
|
||||
@@ -51,7 +82,9 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
{displayError && (
|
||||
<p className="text-sm text-red-500 mt-1">{displayError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
@@ -214,7 +215,10 @@ const ToolsTab = ({
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={(params[key] as JsonValue) ?? {}}
|
||||
value={
|
||||
(params[key] as JsonValue) ??
|
||||
generateDefaultValue(prop)
|
||||
}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
|
||||
Reference in New Issue
Block a user