Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 95 additions & 7 deletions python/packages/devui/agent_framework_devui/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import inspect
import json
import logging
import types
from dataclasses import fields, is_dataclass
from types import UnionType
from typing import Any, Union, get_args, get_origin
Expand Down Expand Up @@ -340,7 +341,30 @@ def generate_input_schema(input_type: type) -> dict[str, Any]:
Returns:
JSON schema dict
"""
# 1. Built-in types
# Handle None type (no input required)
if input_type is type(None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks,
Should we consider adding a decent but minimal test for test for non_type ? probably in ``test_schema_generation.py

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Added test_none_type_schema_generation() and test_optional_type_schema_generation() in test_schema_generation.py. Both tests pass.

return {"type": "null"}

# Check for Union types (e.g., str | None, list[str] | None) before other generic types
origin = get_origin(input_type)
if origin is not None:
args = get_args(input_type)
# Check if it's a Union with None (Optional type)
if type(None) in args:
# Filter out None to get the actual type
non_none_types = [arg for arg in args if arg is not type(None)]
if len(non_none_types) == 1:
# Optional type like str | None - generate schema and mark as optional
base_schema = generate_input_schema(non_none_types[0])
base_schema["default"] = None
return base_schema
if len(non_none_types) > 1:
# Multiple non-None types - use first one and mark as optional
base_schema = generate_input_schema(non_none_types[0])
base_schema["default"] = None
return base_schema

# Built-in types
if input_type is str:
return {"type": "string"}
if input_type is dict:
Expand All @@ -352,19 +376,32 @@ def generate_input_schema(input_type: type) -> dict[str, Any]:
if input_type is bool:
return {"type": "boolean"}

# 2. Pydantic models (legacy support)
# 3. Check for generic types (list, List, Sequence, etc.)
list_origin = get_origin(input_type)
if list_origin is not None:
type_str = str(input_type)
# Handle list/array types
if list_origin is list or "list" in type_str.lower() or "List" in type_str or "Sequence" in type_str:
args = get_args(input_type)
if args:
# Get schema for item type
items_schema = _type_to_schema(args[0], "item")
return {"type": "array", "items": items_schema}
return {"type": "array"}

# 4. Pydantic models (legacy support)
if hasattr(input_type, "model_json_schema"):
return input_type.model_json_schema() # type: ignore

# 3. SerializationMixin classes (ChatMessage, etc.)
# 5. SerializationMixin classes (ChatMessage, etc.)
if is_serialization_mixin(input_type):
return generate_schema_from_serialization_mixin(input_type)

# 4. Dataclasses
# 6. Dataclasses
if is_dataclass(input_type):
return generate_schema_from_dataclass(input_type)

# 5. Fallback to string
# Fallback to string
type_name = getattr(input_type, "__name__", str(input_type))
return {"type": "string", "description": f"Input type: {type_name}"}

Expand All @@ -390,9 +427,18 @@ def parse_input_for_type(input_data: Any, target_type: type) -> Any:
Returns:
Parsed input matching target_type, or original input if parsing fails
"""
# Handle None type specially (when parameter is annotated as just `None`)
if target_type is type(None):
return None

# If already correct type, return as-is
if isinstance(input_data, target_type):
return input_data
# Note: We skip isinstance check if target_type is None to avoid isinstance() errors
try:
if isinstance(input_data, target_type):
return input_data
except TypeError:
# isinstance can raise TypeError for some special types
pass

# Handle string input
if isinstance(input_data, str):
Expand Down Expand Up @@ -517,6 +563,48 @@ def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
Returns:
Parsed input or original dict
"""
# Handle Union types (e.g., str | None, int | None) - extract non-None type
origin = get_origin(target_type)
if origin is Union or (hasattr(types, "UnionType") and origin is types.UnionType):
args = get_args(target_type)
# Filter out NoneType to get base type
non_none_types = [arg for arg in args if arg is not type(None)]
if len(non_none_types) == 1:
# Recursively parse with the base type (e.g., str from str | None)
base_type = non_none_types[0]

# Handle None value explicitly
if "input" in input_dict and input_dict["input"] is None:
return None

# Handle empty dict for optional types - treat as None
if not input_dict or input_dict == {}:
return None

# Parse with base type
return _parse_dict_input(input_dict, base_type)

# Handle list/array types - extract from "input" field
list_origin = get_origin(target_type)
if list_origin is list or target_type is list:
try:
# Try "input" field first (common for workflow inputs)
if "input" in input_dict:
value = input_dict["input"]
if isinstance(value, list):
return value
# If single item, wrap in list
return [value] if value is not None else []

# If single-key dict, extract the value
if len(input_dict) == 1:
value = next(iter(input_dict.values()))
if isinstance(value, list):
return value
return [value] if value is not None else []
except (ValueError, TypeError) as e:
logger.debug(f"Failed to convert dict to list: {e}")

# Handle primitive types - extract from common field names
if target_type in (str, int, float, bool):
try:
Expand Down

Large diffs are not rendered by default.

This file was deleted.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions python/packages/devui/agent_framework_devui/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="/assets/index-D_Y1oSGu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CE4pGoXh.css">
<script type="module" crossorigin src="/assets/index-87VYqhCr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ode5s4DB.css">
</head>
<body>
<div id="root"></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,21 @@ function isShortField(fieldName: string): boolean {
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;

// Determine if this should be a textarea based on JSON Schema format field
// or heuristics (long descriptions, specific field types)
const shouldBeTextarea =
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
schema.format === "textarea" ||
(description && description.length > 100) ||
(type === "string" && !enumValues && !isShortField(name));

// Determine if this field should span full width
const shouldSpanFullWidth =
shouldBeTextarea ||
(description && description.length > 150);

const shouldSpanTwoColumns =
shouldBeTextarea ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
type === "array";

const fieldContent = (() => {
// Handle different field types based on JSON Schema
switch (type) {
case "string":
if (enumValues) {
Expand Down Expand Up @@ -150,6 +146,34 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
);
}

case "integer":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
step="1"
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);

case "number":
return (
<div className="space-y-2">
Expand All @@ -160,6 +184,7 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
<Input
id={name}
type="number"
step="any"
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val = parseFloat(e.target.value);
Expand All @@ -186,9 +211,12 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>
<Label htmlFor={name} className="cursor-pointer">
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
<span className="ml-2 text-muted-foreground font-normal">
({Boolean(value) ? 'true' : 'false'})
</span>
</Label>
</div>
{description && (
Expand All @@ -198,11 +226,21 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
);

case "array":
const itemType = schema.items?.type || "string";
const itemTypeName =
itemType === "string" ? "strings" :
itemType === "integer" ? "integers" :
itemType === "number" ? "numbers" :
"items";

return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
<span className="ml-2 text-xs text-muted-foreground font-normal">
(list of {itemTypeName})
</span>
</Label>
<Textarea
id={name}
Expand All @@ -220,8 +258,8 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
placeholder={`Enter ${itemTypeName} separated by commas (e.g., item1, item2, item3)`}
rows={3}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
Expand Down Expand Up @@ -304,9 +342,9 @@ export function WorkflowInputForm({
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const requiredFields = inputSchema.required || [];
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
const isSimpleInput = inputSchema.type && ["string", "integer", "number", "boolean", "array"].includes(inputSchema.type) && !inputSchema.enum;

// Plan D: Separate required and optional fields first
// Separate required and optional fields
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));

// Detect ChatMessage-like pattern
Expand Down Expand Up @@ -340,13 +378,22 @@ export function WorkflowInputForm({
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;

// Update canSubmit to check required fields properly
// For ChatMessage: role is auto-filled, so it's always valid
// Check if this is an optional type (X | None) via anyOf or default: None
const isOptionalType =
(inputSchema.anyOf?.some(schema => schema.type === "null") ?? false) ||
('default' in inputSchema && inputSchema.default === null);

// Validate form submission readiness
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
? inputSchema.type === "boolean"
? formData.value !== undefined
: inputSchema.type === "array"
? isOptionalType || (Array.isArray(formData.value) && formData.value.length > 0)
: formData.value !== undefined && formData.value !== "" || isOptionalType
: isOptionalType
? true
: requiredFields.length > 0
? requiredFields.every(fieldName => {
// Auto-filled fields are always valid
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
return true;
}
Expand All @@ -356,19 +403,28 @@ export function WorkflowInputForm({

// Initialize form data
useEffect(() => {
if (inputSchema.type === "string") {
setFormData({ value: inputSchema.default || "" });
if (inputSchema.type && ["string", "integer", "number", "boolean", "array"].includes(inputSchema.type)) {
const defaultValue =
inputSchema.default !== undefined
? inputSchema.default
: inputSchema.type === "boolean"
? false
: inputSchema.type === "array"
? []
: "";
setFormData({ value: defaultValue });
} else if (inputSchema.type === "object" && inputSchema.properties) {
const initialData: Record<string, unknown> = {};
Object.entries(inputSchema.properties).forEach(([key, fieldSchema]) => {
if (fieldSchema.default !== undefined) {
initialData[key] = fieldSchema.default;
} else if (fieldSchema.enum && fieldSchema.enum.length > 0) {
initialData[key] = fieldSchema.enum[0];
} else if (fieldSchema.type === "boolean") {
initialData[key] = false;
}
});

// Auto-fill role="user" for ChatMessage-like inputs
if (isChatMessageLike && !initialData['role']) {
initialData['role'] = 'user';
}
Expand All @@ -381,9 +437,8 @@ export function WorkflowInputForm({
e.preventDefault();
setLoading(true);

// Simplified submission logic
if (inputSchema.type === "string") {
onSubmit({ input: formData.value || "" });
if (inputSchema.type && ["string", "integer", "number", "boolean", "array"].includes(inputSchema.type)) {
onSubmit({ input: formData.value !== undefined ? formData.value : "" });
} else if (inputSchema.type === "object") {
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
Expand Down Expand Up @@ -433,7 +488,7 @@ export function WorkflowInputForm({
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
isRequired={!isOptionalType}
/>
)}

Expand Down
Loading