feat(frontend): Implement UI for Agent Input subtypes (#9700)

- Follow-up to #9657

<img width="280" alt="image"
src="https://github.com/user-attachments/assets/2f3cd683-db63-485f-8914-5654c34f1a4c"
/>

<img width="520" alt="image"
src="https://github.com/user-attachments/assets/de7e7cb9-61d4-4071-aea8-393ff5200c54"
/>

### Changes 🏗️

* Implement the input UI for Agent Input subtypes.
* Refactor node-input-component, extra out data type decision logic,
share it with runner/library input.
* Add `format` field for short-text, long-text, and mediafile type.
* Unify UI data type enum.

Out of scope:
- Styling for these inputs.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Use all the available agent input subtypes in an agent and run it

---------

Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
Zamil Majdy
2025-04-02 00:21:46 +04:00
committed by GitHub
parent dbb85baf4c
commit 77b18b00c7
26 changed files with 928 additions and 762 deletions

View File

@@ -4,19 +4,19 @@ from typing import Any, List
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType
from backend.data.model import SchemaField
from backend.util import json
from backend.util.file import MediaFile, store_media_file
from backend.util.file import store_media_file
from backend.util.mock import MockObject
from backend.util.type import convert
from backend.util.type import MediaFileType, convert
class FileStoreBlock(Block):
class Input(BlockSchema):
file_in: MediaFile = SchemaField(
file_in: MediaFileType = SchemaField(
description="The file to store in the temporary directory, it can be a URL, data URI, or local path."
)
class Output(BlockSchema):
file_out: MediaFile = SchemaField(
file_out: MediaFileType = SchemaField(
description="The relative path to the stored file in the temporary directory."
)

View File

@@ -3,10 +3,11 @@ from typing import Any, Optional
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType
from backend.data.model import SchemaField
from backend.util.file import MediaFile, store_media_file
from backend.util.file import store_media_file
from backend.util.mock import MockObject
from backend.util.settings import Config
from backend.util.text import TextFormatter
from backend.util.type import LongTextType, MediaFileType, ShortTextType
formatter = TextFormatter()
config = Config()
@@ -39,11 +40,7 @@ class AgentInputBlock(Block):
description="The placeholder values to be passed as input.",
default=[],
advanced=True,
)
limit_to_placeholder_values: bool = SchemaField(
description="Whether to limit the selection to placeholder values.",
default=False,
advanced=True,
hidden=True,
)
advanced: bool = SchemaField(
description="Whether to show the input in the advanced section, if the field is not required.",
@@ -56,6 +53,12 @@ class AgentInputBlock(Block):
advanced=True,
)
def generate_schema(self):
schema = self.get_field_schema("value")
if possible_values := self.placeholder_values:
schema["enum"] = possible_values
return schema
class Output(BlockSchema):
result: Any = SchemaField(description="The value passed as input.")
@@ -72,14 +75,12 @@ class AgentInputBlock(Block):
"name": "input_1",
"description": "Example test input.",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Hello, World!",
"name": "input_2",
"description": "Example test input with placeholders.",
"placeholder_values": ["Hello, World!"],
"limit_to_placeholder_values": True,
},
],
"test_output": [
@@ -141,6 +142,9 @@ class AgentOutputBlock(Block):
advanced=True,
)
def generate_schema(self):
return self.get_field_schema("value")
class Output(BlockSchema):
output: Any = SchemaField(description="The value recorded as output.")
name: Any = SchemaField(description="The name of the value recorded as output.")
@@ -200,12 +204,11 @@ class AgentOutputBlock(Block):
class AgentShortTextInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[str] = SchemaField(
value: Optional[ShortTextType] = SchemaField(
description="Short text input.",
default=None,
advanced=False,
title="Default Value",
json_schema_extra={"format": "short-text"},
)
class Output(AgentInputBlock.Output):
@@ -224,14 +227,12 @@ class AgentShortTextInputBlock(AgentInputBlock):
"name": "short_text_1",
"description": "Short text example 1",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Quick test",
"name": "short_text_2",
"description": "Short text example 2",
"placeholder_values": ["Quick test", "Another option"],
"limit_to_placeholder_values": True,
},
],
test_output=[
@@ -243,12 +244,11 @@ class AgentShortTextInputBlock(AgentInputBlock):
class AgentLongTextInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[str] = SchemaField(
value: Optional[LongTextType] = SchemaField(
description="Long text input (potentially multi-line).",
default=None,
advanced=False,
title="Default Value",
json_schema_extra={"format": "long-text"},
)
class Output(AgentInputBlock.Output):
@@ -267,14 +267,12 @@ class AgentLongTextInputBlock(AgentInputBlock):
"name": "long_text_1",
"description": "Long text example 1",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Another multiline text input.",
"name": "long_text_2",
"description": "Long text example 2",
"placeholder_values": ["Another multiline text input."],
"limit_to_placeholder_values": True,
},
],
test_output=[
@@ -309,14 +307,12 @@ class AgentNumberInputBlock(AgentInputBlock):
"name": "number_input_1",
"description": "Number example 1",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": 314,
"name": "number_input_2",
"description": "Number example 2",
"placeholder_values": [314, 2718],
"limit_to_placeholder_values": True,
},
],
test_output=[
@@ -410,7 +406,7 @@ class AgentFileInputBlock(AgentInputBlock):
"""
class Input(AgentInputBlock.Input):
value: Optional[MediaFile] = SchemaField(
value: Optional[MediaFileType] = SchemaField(
description="Path or reference to an uploaded file.",
default=None,
advanced=False,
@@ -459,8 +455,7 @@ class AgentFileInputBlock(AgentInputBlock):
class AgentDropdownInputBlock(AgentInputBlock):
"""
A specialized text input block that relies on placeholder_values +
limit_to_placeholder_values to present a dropdown.
A specialized text input block that relies on placeholder_values to present a dropdown.
"""
class Input(AgentInputBlock.Input):
@@ -476,10 +471,6 @@ class AgentDropdownInputBlock(AgentInputBlock):
advanced=False,
title="Dropdown Options",
)
limit_to_placeholder_values: bool = SchemaField(
description="Whether the selection is limited to placeholder values.",
default=True,
)
class Output(AgentInputBlock.Output):
result: str = SchemaField(description="Selected dropdown value.")
@@ -496,14 +487,12 @@ class AgentDropdownInputBlock(AgentInputBlock):
"value": "Option A",
"name": "dropdown_1",
"placeholder_values": ["Option A", "Option B", "Option C"],
"limit_to_placeholder_values": True,
"description": "Dropdown example 1",
},
{
"value": "Option C",
"name": "dropdown_2",
"placeholder_values": ["Option A", "Option B", "Option C"],
"limit_to_placeholder_values": True,
"description": "Dropdown example 2",
},
],

View File

@@ -8,13 +8,13 @@ from moviepy.video.io.VideoFileClip import VideoFileClip
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.file import MediaFile, get_exec_file_path, store_media_file
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
class MediaDurationBlock(Block):
class Input(BlockSchema):
media_in: MediaFile = SchemaField(
media_in: MediaFileType = SchemaField(
description="Media input (URL, data URI, or local path)."
)
is_video: bool = SchemaField(
@@ -69,7 +69,7 @@ class LoopVideoBlock(Block):
"""
class Input(BlockSchema):
video_in: MediaFile = SchemaField(
video_in: MediaFileType = SchemaField(
description="The input video (can be a URL, data URI, or local path)."
)
# Provide EITHER a `duration` or `n_loops` or both. We'll demonstrate `duration`.
@@ -137,7 +137,7 @@ class LoopVideoBlock(Block):
assert isinstance(looped_clip, VideoFileClip)
# 4) Save the looped output
output_filename = MediaFile(
output_filename = MediaFileType(
f"{node_exec_id}_looped_{os.path.basename(local_video_path)}"
)
output_abspath = get_exec_file_path(graph_exec_id, output_filename)
@@ -162,10 +162,10 @@ class AddAudioToVideoBlock(Block):
"""
class Input(BlockSchema):
video_in: MediaFile = SchemaField(
video_in: MediaFileType = SchemaField(
description="Video input (URL, data URI, or local path)."
)
audio_in: MediaFile = SchemaField(
audio_in: MediaFileType = SchemaField(
description="Audio input (URL, data URI, or local path)."
)
volume: float = SchemaField(
@@ -178,7 +178,7 @@ class AddAudioToVideoBlock(Block):
)
class Output(BlockSchema):
video_out: MediaFile = SchemaField(
video_out: MediaFileType = SchemaField(
description="Final video (with attached audio), as a path or data URI."
)
error: str = SchemaField(
@@ -229,7 +229,7 @@ class AddAudioToVideoBlock(Block):
final_clip = video_clip.with_audio(audio_clip)
# 4) Write to output file
output_filename = MediaFile(
output_filename = MediaFileType(
f"{node_exec_id}_audio_attached_{os.path.basename(local_video_path)}"
)
output_abspath = os.path.join(abs_temp_dir, output_filename)

View File

@@ -6,13 +6,14 @@ from backend.blocks.nvidia._auth import (
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from backend.util.type import MediaFileType
class NvidiaDeepfakeDetectBlock(Block):
class Input(BlockSchema):
credentials: NvidiaCredentialsInput = NvidiaCredentialsField()
image_base64: str = SchemaField(
description="Image to analyze for deepfakes", image_upload=True
image_base64: MediaFileType = SchemaField(
description="Image to analyze for deepfakes",
)
return_image: bool = SchemaField(
description="Whether to return the processed image with markings",
@@ -22,16 +23,12 @@ class NvidiaDeepfakeDetectBlock(Block):
class Output(BlockSchema):
status: str = SchemaField(
description="Detection status (SUCCESS, ERROR, CONTENT_FILTERED)",
default="",
)
image: str = SchemaField(
image: MediaFileType = SchemaField(
description="Processed image with detection markings (if return_image=True)",
default="",
image_output=True,
)
is_deepfake: float = SchemaField(
description="Probability that the image is a deepfake (0-1)",
default=0.0,
)
def __init__(self):

View File

@@ -12,7 +12,7 @@ from backend.data.model import (
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util.file import MediaFile, store_media_file
from backend.util.file import MediaFileType, store_media_file
from backend.util.request import Requests
@@ -57,7 +57,7 @@ class ScreenshotWebPageBlock(Block):
)
class Output(BlockSchema):
image: MediaFile = SchemaField(description="The screenshot image data")
image: MediaFileType = SchemaField(description="The screenshot image data")
error: str = SchemaField(description="Error message if the screenshot failed")
def __init__(self):
@@ -142,7 +142,7 @@ class ScreenshotWebPageBlock(Block):
return {
"image": store_media_file(
graph_exec_id=graph_exec_id,
file=MediaFile(
file=MediaFileType(
f"data:image/{format.value};base64,{b64encode(response.content).decode('utf-8')}"
),
return_content=True,

View File

@@ -120,21 +120,26 @@ class BlockSchema(BaseModel):
def get_mismatch_error(cls, data: BlockInput) -> str | None:
return cls.validate_data(data)
@classmethod
def get_field_schema(cls, field_name: str) -> dict[str, Any]:
model_schema = cls.jsonschema().get("properties", {})
if not model_schema:
raise ValueError(f"Invalid model schema {cls}")
property_schema = model_schema.get(field_name)
if not property_schema:
raise ValueError(f"Invalid property name {field_name}")
return property_schema
@classmethod
def validate_field(cls, field_name: str, data: BlockInput) -> str | None:
"""
Validate the data against a specific property (one of the input/output name).
Returns the validation error message if the data does not match the schema.
"""
model_schema = cls.jsonschema().get("properties", {})
if not model_schema:
return f"Invalid model schema {cls}"
property_schema = model_schema.get(field_name)
if not property_schema:
return f"Invalid property name {field_name}"
try:
property_schema = cls.get_field_schema(field_name)
jsonschema.validate(json.to_dict(data), property_schema)
return None
except jsonschema.ValidationError as e:

View File

@@ -165,46 +165,48 @@ class BaseGraph(BaseDbModel):
@property
def input_schema(self) -> dict[str, Any]:
return self._generate_schema(
AgentInputBlock.Input,
[
node.input_default
*(
(b.input_schema, node.input_default)
for node in self.nodes
if (b := get_block(node.block_id))
and b.block_type == BlockType.INPUT
and "name" in node.input_default
],
and issubclass(b.input_schema, AgentInputBlock.Input)
)
)
@computed_field
@property
def output_schema(self) -> dict[str, Any]:
return self._generate_schema(
AgentOutputBlock.Input,
[
node.input_default
*(
(b.input_schema, node.input_default)
for node in self.nodes
if (b := get_block(node.block_id))
and b.block_type == BlockType.OUTPUT
and "name" in node.input_default
],
and issubclass(b.input_schema, AgentOutputBlock.Input)
)
)
@staticmethod
def _generate_schema(
type_class: Type[AgentInputBlock.Input] | Type[AgentOutputBlock.Input],
data: list[dict],
*props: tuple[Type[AgentInputBlock.Input] | Type[AgentOutputBlock.Input], dict],
) -> dict[str, Any]:
props = []
for p in data:
schema = []
for type_class, input_default in props:
try:
props.append(type_class(**p))
schema.append(type_class(**input_default))
except Exception as e:
logger.warning(f"Invalid {type_class}: {p}, {e}")
logger.warning(f"Invalid {type_class}: {input_default}, {e}")
return {
"type": "object",
"properties": {
p.name: {
**{
k: v
for k, v in p.generate_schema().items()
if k not in ["description", "default"]
},
"secret": p.secret,
# Default value has to be set for advanced fields.
"advanced": p.advanced and p.value is not None,
@@ -212,9 +214,9 @@ class BaseGraph(BaseDbModel):
**({"description": p.description} if p.description else {}),
**({"default": p.value} if p.value is not None else {}),
}
for p in props
for p in schema
},
"required": [p.name for p in props if p.value is None],
"required": [p.name for p in schema if p.value is None],
}

View File

@@ -141,10 +141,8 @@ def SchemaField(
secret: bool = False,
exclude: bool = False,
hidden: Optional[bool] = None,
depends_on: list[str] | None = None,
image_upload: Optional[bool] = None,
image_output: Optional[bool] = None,
json_schema_extra: dict[str, Any] | None = None,
depends_on: Optional[list[str]] = None,
json_schema_extra: Optional[dict[str, Any]] = None,
**kwargs,
) -> T:
if default is PydanticUndefined and default_factory is None:
@@ -160,8 +158,6 @@ def SchemaField(
"advanced": advanced,
"hidden": hidden,
"depends_on": depends_on,
"image_upload": image_upload,
"image_output": image_output,
**(json_schema_extra or {}),
}.items()
if v is not None

View File

@@ -7,8 +7,8 @@ import uuid
from pathlib import Path
from urllib.parse import urlparse
# This "requests" presumably has additional checks against internal networks for SSRF.
from backend.util.request import requests
from backend.util.type import MediaFileType
TEMP_DIR = Path(tempfile.gettempdir()).resolve()
@@ -29,30 +29,9 @@ def clean_exec_files(graph_exec_id: str, file: str = "") -> None:
shutil.rmtree(exec_path)
class MediaFile(str):
"""
MediaFile is a string that represents a file. It can be one of the following:
- Data URI: base64 encoded media file. See https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data/
- URL: Media file hosted on the internet, it starts with http:// or https://.
- Local path (anything else): A temporary file path living within graph execution time.
Note: Replace this type alias into a proper class, when more information is needed.
"""
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
return handler(str)
@classmethod
def __get_pydantic_json_schema__(cls, core_schema, handler):
json_schema = handler(core_schema)
json_schema["format"] = "file"
return json_schema
def store_media_file(
graph_exec_id: str, file: MediaFile, return_content: bool = False
) -> MediaFile:
graph_exec_id: str, file: MediaFileType, return_content: bool = False
) -> MediaFileType:
"""
Safely handle 'file' (a data URI, a URL, or a local path relative to {temp}/exec_file/{exec_id}),
placing or verifying it under:
@@ -61,7 +40,7 @@ def store_media_file(
If 'return_content=True', return a data URI (data:<mime>;base64,<content>).
Otherwise, returns the file media path relative to the exec_id folder.
For each MediaFile type:
For each MediaFileType type:
- Data URI:
-> decode and store in a new random file in that folder
- URL:
@@ -148,6 +127,6 @@ def store_media_file(
# Return result
if return_content:
return MediaFile(_file_to_data_uri(target_path))
return MediaFileType(_file_to_data_uri(target_path))
else:
return MediaFile(_strip_base_prefix(target_path, base_path))
return MediaFileType(_strip_base_prefix(target_path, base_path))

View File

@@ -228,7 +228,7 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="Whether to use the new agent image generation service",
)
enable_agent_input_subtype_blocks: bool = Field(
default=False,
default=True,
description="Whether to enable the agent input subtype blocks",
)

View File

@@ -195,3 +195,38 @@ def convert(value: Any, target_type: Type[T]) -> T:
return cast(T, _try_convert(value, target_type, raise_on_mismatch=False))
except Exception as e:
raise ConversionError(f"Failed to convert {value} to {target_type}") from e
class FormattedStringType(str):
string_format: str
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
return handler(str)
@classmethod
def __get_pydantic_json_schema__(cls, core_schema, handler):
json_schema = handler(core_schema)
json_schema["format"] = cls.string_format
return json_schema
class MediaFileType(FormattedStringType):
"""
MediaFile is a string that represents a file. It can be one of the following:
- Data URI: base64 encoded media file. See https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data/
- URL: Media file hosted on the internet, it starts with http:// or https://.
- Local path (anything else): A temporary file path living within graph execution time.
Note: Replace this type alias into a proper class, when more information is needed.
"""
string_format = "file"
class LongTextType(FormattedStringType):
string_format = "long-text"
class ShortTextType(FormattedStringType):
string_format = "short-text"

View File

@@ -74,7 +74,7 @@
@layer components {
.agpt-border-input {
@apply border border-input focus-visible:border-gray-400 focus-visible:outline-none;
@apply m-0.5 border border-input focus-visible:border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-400;
}
.agpt-shadow-input {

View File

@@ -1,4 +1,5 @@
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
import { FC, memo, useCallback } from "react";
import { Handle, Position } from "@xyflow/react";
@@ -11,6 +12,7 @@ type HandleProps = {
isRequired?: boolean;
side: "left" | "right";
title?: string;
className?: string;
};
// Move the constant out of the component to avoid re-creation on every render.
@@ -46,6 +48,7 @@ const NodeHandle: FC<HandleProps> = ({
isRequired,
side,
title,
className,
}) => {
const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${
side === "left" ? "text-left" : "text-right"
@@ -53,7 +56,12 @@ const NodeHandle: FC<HandleProps> = ({
const label = (
<div className="flex flex-grow flex-row">
<span className="text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100">
<span
className={cn(
"text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
className,
)}
>
{title || schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""}
</span>

View File

@@ -8,7 +8,11 @@ import RunnerOutputUI, { BlockOutput } from "./runner-ui/RunnerOutputUI";
import RunnerInputUI from "./runner-ui/RunnerInputUI";
import { Node } from "@xyflow/react";
import { filterBlocksByType } from "@/lib/utils";
import { BlockIORootSchema, BlockUIType } from "@/lib/autogpt-server-api/types";
import {
BlockIOObjectSubSchema,
BlockIORootSchema,
BlockUIType,
} from "@/lib/autogpt-server-api/types";
import { CustomNode } from "./CustomNode";
interface HardcodedValues {
@@ -16,7 +20,6 @@ interface HardcodedValues {
description: any;
value: any;
placeholder_values: any;
limit_to_placeholder_values: any;
}
export interface InputItem {
@@ -80,16 +83,14 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
({
id: node.id,
type: "input" as const,
inputSchema: node.data.inputSchema as BlockIORootSchema,
inputSchema: (node.data.inputSchema as BlockIOObjectSubSchema)
.properties.value as BlockIORootSchema,
hardcodedValues: {
name: (node.data.hardcodedValues as any).name || "",
description: (node.data.hardcodedValues as any).description || "",
value: (node.data.hardcodedValues as any).value,
placeholder_values:
(node.data.hardcodedValues as any).placeholder_values || [],
limit_to_placeholder_values:
(node.data.hardcodedValues as any)
.limit_to_placeholder_values || false,
},
}) satisfies InputItem,
);
@@ -111,7 +112,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
}, [nodes]);
const handleInputChange = useCallback(
(nodeId: string, field: string, value: string) => {
(nodeId: string, field: string, value: any) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {

View File

@@ -6,18 +6,11 @@ import { GraphExecutionID, GraphMeta } from "@/lib/autogpt-server-api";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LocalValuedInput } from "@/components/ui/input";
import { TypeBasedInput } from "@/components/type-based-input";
import { useToastOnFail } from "@/components/ui/use-toast";
import { Pencil2Icon } from "@radix-ui/react-icons";
import { Textarea } from "@/components/ui/textarea";
import SchemaTooltip from "@/components/SchemaTooltip";
import { IconPlay } from "@/components/ui/icons";
import { Button } from "@/components/agptui/Button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export default function AgentRunDraftView({
graph,
@@ -33,27 +26,6 @@ export default function AgentRunDraftView({
const agentInputs = graph.input_schema.properties;
const [inputValues, setInputValues] = useState<Record<string, any>>({});
const [expandedInputKey, setExpandedInputKey] = useState<string | null>(null);
const [tempInputValue, setTempInputValue] = useState("");
const openInputPopout = useCallback(
(key: string) => {
setTempInputValue(inputValues[key] || "");
setExpandedInputKey(key);
},
[inputValues],
);
const closeInputPopout = useCallback(() => {
setExpandedInputKey(null);
}, []);
const saveAndCloseInputPopout = useCallback(() => {
if (!expandedInputKey) return;
setInputValues((obj) => ({ ...obj, [expandedInputKey]: tempInputValue }));
closeInputPopout();
}, [expandedInputKey, tempInputValue, closeInputPopout]);
const doRun = useCallback(
() =>
@@ -89,68 +61,29 @@ export default function AgentRunDraftView({
</CardHeader>
<CardContent className="flex flex-col gap-4">
{Object.entries(agentInputs).map(([key, inputSubSchema]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
<div key={key} className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<SchemaTooltip description={inputSubSchema.description} />
</label>
<div className="nodrag relative">
<LocalValuedInput
// TODO: render specific inputs based on input types
defaultValue={
"default" in inputSubSchema ? inputSubSchema.default : ""
}
value={inputValues[key] ?? undefined}
className="rounded-full pr-8"
onChange={(e) =>
setInputValues((obj) => ({
...obj,
[key]: e.target.value,
}))
}
/>
<Button
variant="ghost"
size="icon"
className="absolute inset-1 left-auto h-7 w-8 rounded-full"
onClick={() => openInputPopout(key)}
title="Open a larger textbox input"
>
<Pencil2Icon className="m-0 p-0" />
</Button>
</div>
<TypeBasedInput
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) =>
setInputValues((obj) => ({
...obj,
[key]: value,
}))
}
/>
</div>
))}
</CardContent>
</Card>
</div>
{/* Pop-out Long Input Modal */}
<Dialog
open={expandedInputKey !== null}
onOpenChange={(open) => !open && closeInputPopout()}
>
<DialogContent className="sm:max-w-[720px]">
<DialogHeader>
<DialogTitle>
Edit {expandedInputKey && agentInputs[expandedInputKey].title}
</DialogTitle>
</DialogHeader>
<Textarea
value={tempInputValue}
onChange={(e) => setTempInputValue(e.target.value)}
className="min-h-[320px]"
/>
<div className="flex justify-end gap-2">
<Button onClick={closeInputPopout}>Cancel</Button>
<Button variant="primary" onClick={saveAndCloseInputPopout}>
Save
</Button>
</div>
</DialogContent>
</Dialog>
{/* Actions */}
<aside className="w-48 xl:w-56">
<div className="flex flex-col gap-8">

View File

@@ -1,4 +1,3 @@
// history.ts
import { CustomNodeData } from "./CustomNode";
import { CustomEdgeData } from "./CustomEdge";
import { Edge } from "@xyflow/react";

View File

@@ -49,27 +49,8 @@ export const FlowInfo: React.FC<
refresh: () => void;
}
> = ({ flow, executions, flowVersion, refresh, ...props }) => {
const {
agentName,
setAgentName,
agentDescription,
setAgentDescription,
savedAgent,
availableNodes,
availableFlows,
getOutputType,
requestSave,
requestSaveAndRun,
requestStopRun,
scheduleRunner,
isRunning,
isScheduling,
setIsScheduling,
nodes,
setNodes,
edges,
setEdges,
} = useAgentGraph(flow.agent_id, flow.agent_version, undefined, false);
const { requestSaveAndRun, requestStopRun, isRunning, nodes, setNodes } =
useAgentGraph(flow.agent_id, flow.agent_version, undefined, false);
const api = useBackendAPI();
const { toast } = useToast();
@@ -85,7 +66,6 @@ export const FlowInfo: React.FC<
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [openCron, setOpenCron] = useState(false);
const [isRunnerInputOpen, setIsRunnerInputOpen] = useState(false);
const isDisabled = !selectedFlowVersion;
@@ -110,9 +90,6 @@ export const FlowInfo: React.FC<
value: (node.data.hardcodedValues as any).value,
placeholder_values:
(node.data.hardcodedValues as any).placeholder_values || [],
limit_to_placeholder_values:
(node.data.hardcodedValues as any).limit_to_placeholder_values ||
false,
},
}));
@@ -132,17 +109,6 @@ export const FlowInfo: React.FC<
return { inputs, outputs };
}, [nodes]);
const handleScheduleButton = () => {
if (!selectedFlowVersion) {
toast({
title: "Please select a flow version before scheduling",
duration: 2000,
});
return;
}
setOpenCron(true);
};
useEffect(() => {
api
.getGraphAllVersions(flow.agent_id)
@@ -161,7 +127,7 @@ export const FlowInfo: React.FC<
};
const handleInputChange = useCallback(
(nodeId: string, field: string, value: string) => {
(nodeId: string, field: string, value: any) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {

View File

@@ -18,8 +18,17 @@ import {
BlockIONumberSubSchema,
BlockIOBooleanSubSchema,
BlockIOSimpleTypeSubSchema,
DataType,
determineDataType,
} from "@/lib/autogpt-server-api/types";
import React, { FC, useCallback, useEffect, useMemo, useState } from "react";
import React, {
FC,
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from "react";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import {
@@ -102,92 +111,6 @@ const NodeObjectInputTree: FC<NodeObjectInputTreeProps> = ({
export default NodeObjectInputTree;
const NodeImageInput: FC<{
selfKey: string;
schema: BlockIOStringSubSchema;
value?: string;
error?: string;
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
className?: string;
displayName: string;
}> = ({
selfKey,
schema,
value = "",
error,
handleInputChange,
className,
displayName,
}) => {
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith("image/")) {
console.error("Please upload an image file");
return;
}
// Convert to base64
const reader = new FileReader();
reader.onload = (e) => {
const base64String = (e.target?.result as string).split(",")[1];
handleInputChange(selfKey, base64String);
};
reader.readAsDataURL(file);
},
[selfKey, handleInputChange],
);
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="nodrag flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
document.getElementById(`${selfKey}-upload`)?.click()
}
className="w-full"
>
{value ? "Change Image" : `Upload ${displayName}`}
</Button>
{value && (
<Button
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => handleInputChange(selfKey, "")}
>
<Cross2Icon className="h-4 w-4" />
</Button>
)}
</div>
<input
id={`${selfKey}-upload`}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{value && (
<div className="relative mt-2 rounded-md border border-gray-300 p-2 dark:border-gray-600">
<img
src={`data:image/jpeg;base64,${value}`}
alt="Preview"
className="max-h-32 w-full rounded-md object-contain"
/>
</div>
)}
</div>
{error && <span className="error-message">{error}</span>}
</div>
);
};
const NodeDateTimeInput: FC<{
selfKey: string;
schema: BlockIOStringSubSchema;
@@ -209,10 +132,8 @@ const NodeDateTimeInput: FC<{
hideDate = false,
hideTime = false,
}) => {
const date = value ? new Date(value) : undefined;
const [timeInput, setTimeInput] = useState(
value && date ? format(date, "HH:mm") : "00:00",
);
const dateInput = value && !hideDate ? new Date(value) : new Date();
const timeInput = value && !hideTime ? format(dateInput, "HH:mm") : "00:00";
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) return;
@@ -230,9 +151,6 @@ const NodeDateTimeInput: FC<{
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTimeInput(newTime);
if (!value) return;
if (hideDate) {
// Only pass HH:mm if date is hidden
@@ -240,9 +158,8 @@ const NodeDateTimeInput: FC<{
} else {
// Otherwise pass full date/time
const [hours, minutes] = newTime.split(":").map(Number);
const newDate = new Date(value);
newDate.setHours(hours, minutes);
handleInputChange(selfKey, newDate.toISOString());
dateInput.setHours(hours, minutes);
handleInputChange(selfKey, dateInput.toISOString());
}
};
@@ -259,13 +176,17 @@ const NodeDateTimeInput: FC<{
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value && date ? format(date, "PPP") : <span>Pick a date</span>}
{value && dateInput ? (
format(dateInput, "PPP")
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
selected={dateInput}
onSelect={handleDateSelect}
autoFocus
/>
@@ -305,6 +226,7 @@ const NodeFileInput: FC<{
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
console.log(">>> file", file);
if (!file) return;
const reader = new FileReader();
@@ -337,15 +259,15 @@ const NodeFileInput: FC<{
return "File";
}, []);
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="nodrag flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
document.getElementById(`${selfKey}-upload`)?.click()
}
onClick={() => inputRef.current?.click()}
className="w-full"
>
{value ? `Change ${displayName}` : `Upload ${displayName}`}
@@ -354,14 +276,17 @@ const NodeFileInput: FC<{
<Button
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => handleInputChange(selfKey, "")}
onClick={() => {
inputRef.current && (inputRef.current!.value = "");
handleInputChange(selfKey, "");
}}
>
<Cross2Icon className="h-4 w-4" />
</Button>
)}
</div>
<input
id={`${selfKey}-upload`}
ref={inputRef}
type="file"
accept="*/*"
onChange={handleFileChange}
@@ -404,283 +329,8 @@ export const NodeGenericInputField: FC<{
className = cn(className);
displayName ||= propSchema.title || beautifyString(propKey);
if ("allOf" in propSchema) {
// If this happens, that is because Pydantic wraps $refs in an allOf if the
// $ref has sibling schema properties (which isn't technically allowed),
// so there will only be one item in allOf[].
// AFAIK this should NEVER happen though, as $refs are resolved server-side.
propSchema = propSchema.allOf[0];
console.warn(`Unsupported 'allOf' in schema for '${propKey}'!`, propSchema);
}
if ("credentials_provider" in propSchema) {
return (
<NodeCredentialsInput
selfKey={propKey}
value={currentValue}
errors={errors}
className={className}
handleInputChange={handleInputChange}
/>
);
}
if ("properties" in propSchema) {
// Render a multi-select for all-boolean sub-schemas with more than 3 properties
if (
Object.values(propSchema.properties).every(
(subSchema) => "type" in subSchema && subSchema.type == "boolean",
) &&
Object.keys(propSchema.properties).length >= 3
) {
const options = Object.keys(propSchema.properties);
const selectedKeys = Object.entries(currentValue || {})
.filter(([_, v]) => v)
.map(([k, _]) => k);
return (
<NodeMultiSelectInput
selfKey={propKey}
schema={propSchema}
selection={selectedKeys}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={(key, selection) => {
handleInputChange(
key,
Object.fromEntries(
options.map((option) => [option, selection.includes(option)]),
),
);
}}
/>
);
}
return (
<NodeObjectInputTree
nodeId={nodeId}
selfKey={propKey}
schema={propSchema}
object={currentValue}
errors={errors}
className={cn("border-l border-gray-500 pl-2", className)} // visual indent
displayName={displayName}
connections={connections}
handleInputClick={handleInputClick}
handleInputChange={handleInputChange}
/>
);
}
if ("additionalProperties" in propSchema) {
return (
<NodeKeyValueInput
nodeId={nodeId}
selfKey={propKey}
schema={propSchema}
entries={currentValue}
errors={errors}
className={className}
displayName={displayName}
connections={connections}
handleInputChange={handleInputChange}
/>
);
}
if ("anyOf" in propSchema) {
// Optional oneOf
if (
"oneOf" in propSchema.anyOf[0] &&
propSchema.anyOf[0].oneOf &&
"discriminator" in propSchema.anyOf[0] &&
propSchema.anyOf[0].discriminator
) {
return (
<NodeOneOfDiscriminatorField
nodeId={nodeId}
propKey={propKey}
propSchema={propSchema.anyOf[0]}
defaultValue={propSchema.default}
currentValue={currentValue}
errors={errors}
connections={connections}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
className={className}
displayName={displayName}
/>
);
}
// optional items
const types = propSchema.anyOf.map((s) =>
"type" in s ? s.type : undefined,
);
if (types.includes("string") && types.includes("null")) {
// optional string and datetime
if (
"format" in propSchema.anyOf[0] &&
["date-time", "date", "time"].includes(
propSchema.anyOf[0].format as string,
)
) {
return (
<NodeDateTimeInput
hideDate={propSchema.anyOf[0].format === "time"}
hideTime={propSchema.anyOf[0].format === "date"}
selfKey={propKey}
schema={propSchema.anyOf[0]}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
/>
);
}
if (
"format" in propSchema.anyOf[0] &&
propSchema.anyOf[0].format === "file"
) {
return (
<NodeFileInput
selfKey={propKey}
schema={propSchema.anyOf[0] as BlockIOStringSubSchema}
value={currentValue}
error={errors[propKey]}
handleInputChange={handleInputChange}
className={className}
displayName={displayName}
/>
);
}
return (
<NodeStringInput
selfKey={propKey}
schema={
{
...propSchema,
type: "string",
enum: (propSchema.anyOf[0] as BlockIOStringSubSchema).enum,
} as BlockIOStringSubSchema
}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
/>
);
} else if (
(types.includes("integer") || types.includes("number")) &&
types.includes("null")
) {
return (
<NodeNumberInput
selfKey={propKey}
schema={
{
...propSchema,
type: "integer",
} as BlockIONumberSubSchema
}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
/>
);
} else if (types.includes("array") && types.includes("null")) {
return (
<NodeArrayInput
nodeId={nodeId}
selfKey={propKey}
schema={
{
...propSchema,
type: "array",
items: (propSchema.anyOf[0] as BlockIOArraySubSchema).items,
} as BlockIOArraySubSchema
}
entries={currentValue}
errors={errors}
className={className}
displayName={displayName}
connections={connections}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
/>
);
} else if (types.includes("object") && types.includes("null")) {
// rendering optional mutliselect
if (
Object.values(
(propSchema.anyOf[0] as BlockIOObjectSubSchema).properties,
).every(
(subSchema) => "type" in subSchema && subSchema.type == "boolean",
) &&
Object.keys((propSchema.anyOf[0] as BlockIOObjectSubSchema).properties)
.length >= 1
) {
const options = Object.keys(
(propSchema.anyOf[0] as BlockIOObjectSubSchema).properties,
);
const selectedKeys = Object.entries(currentValue || {})
.filter(([_, v]) => v)
.map(([k, _]) => k);
return (
<NodeMultiSelectInput
selfKey={propKey}
schema={propSchema.anyOf[0] as BlockIOObjectSubSchema}
selection={selectedKeys}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={(key, selection) => {
handleInputChange(
key,
Object.fromEntries(
options.map((option) => [option, selection.includes(option)]),
),
);
}}
/>
);
}
return (
<NodeKeyValueInput
nodeId={nodeId}
selfKey={propKey}
schema={
{
...propSchema,
type: "object",
additionalProperties: (propSchema.anyOf[0] as BlockIOKVSubSchema)
.additionalProperties,
} as BlockIOKVSubSchema
}
entries={currentValue}
errors={errors}
className={className}
displayName={displayName}
connections={connections}
handleInputChange={handleInputChange}
/>
);
}
}
if (
"oneOf" in propSchema &&
propSchema.oneOf &&
"discriminator" in propSchema &&
propSchema.discriminator
) {
@@ -701,71 +351,56 @@ export const NodeGenericInputField: FC<{
);
}
if (!("type" in propSchema)) {
return (
<NodeFallbackInput
selfKey={propKey}
schema={propSchema}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
/>
);
}
const dt = determineDataType(propSchema);
switch (dt) {
case DataType.CREDENTIALS:
return (
<NodeCredentialsInput
selfKey={propKey}
value={currentValue}
errors={errors}
className={className}
handleInputChange={handleInputChange}
/>
);
switch (propSchema.type) {
case "string":
if ("image_upload" in propSchema && propSchema.image_upload === true) {
return (
<NodeImageInput
selfKey={propKey}
schema={propSchema}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
/>
);
}
if (
"format" in propSchema &&
["date-time", "date", "time"].includes(propSchema.format as string)
) {
return (
<NodeDateTimeInput
hideDate={propSchema.format === "time"}
hideTime={propSchema.format === "date"}
selfKey={propKey}
schema={propSchema}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
/>
);
}
if ("format" in propSchema && propSchema.format === "file") {
return (
<NodeFileInput
selfKey={propKey}
schema={propSchema}
value={currentValue}
error={errors[propKey]}
handleInputChange={handleInputChange}
className={className}
displayName={displayName}
/>
);
}
case DataType.DATE:
case DataType.TIME:
case DataType.DATE_TIME:
const hideDate = dt === DataType.TIME;
const hideTime = dt === DataType.DATE;
return (
<NodeDateTimeInput
selfKey={propKey}
schema={propSchema as BlockIOStringSubSchema}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
hideDate={hideDate}
hideTime={hideTime}
/>
);
case DataType.FILE:
return (
<NodeFileInput
selfKey={propKey}
schema={propSchema as BlockIOStringSubSchema}
value={currentValue}
error={errors[propKey]}
handleInputChange={handleInputChange}
className={className}
displayName={displayName}
/>
);
case DataType.SELECT:
return (
<NodeStringInput
selfKey={propKey}
schema={propSchema}
schema={propSchema as BlockIOStringSubSchema}
value={currentValue}
error={errors[propKey]}
className={className}
@@ -774,11 +409,41 @@ export const NodeGenericInputField: FC<{
handleInputClick={handleInputClick}
/>
);
case "boolean":
case DataType.MULTI_SELECT:
const schema = propSchema as BlockIOObjectSubSchema;
return (
<NodeMultiSelectInput
selfKey={propKey}
schema={schema}
selection={Object.entries(currentValue || {})
.filter(([_, v]) => v)
.map(([k, _]) => k)}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={(key, selection) => {
// If you want to build an object of booleans from `selection`
// (like your old code), do it here. Otherwise adapt to your actual UI.
// Example:
const allKeys = schema.properties
? Object.keys(schema.properties)
: [];
handleInputChange(
key,
Object.fromEntries(
allKeys.map((opt) => [opt, selection.includes(opt)]),
),
);
}}
/>
);
case DataType.BOOLEAN:
return (
<NodeBooleanInput
selfKey={propKey}
schema={propSchema}
schema={propSchema as BlockIOBooleanSubSchema}
value={currentValue}
error={errors[propKey]}
className={className}
@@ -786,12 +451,12 @@ export const NodeGenericInputField: FC<{
handleInputChange={handleInputChange}
/>
);
case "number":
case "integer":
case DataType.NUMBER:
return (
<NodeNumberInput
selfKey={propKey}
schema={propSchema}
schema={propSchema as BlockIONumberSubSchema}
value={currentValue}
error={errors[propKey]}
className={className}
@@ -799,12 +464,13 @@ export const NodeGenericInputField: FC<{
handleInputChange={handleInputChange}
/>
);
case "array":
case DataType.ARRAY:
return (
<NodeArrayInput
nodeId={nodeId}
selfKey={propKey}
schema={propSchema}
schema={propSchema as BlockIOArraySubSchema}
entries={currentValue}
errors={errors}
className={className}
@@ -814,12 +480,13 @@ export const NodeGenericInputField: FC<{
handleInputClick={handleInputClick}
/>
);
case "object":
case DataType.KEY_VALUE:
return (
<NodeKeyValueInput
nodeId={nodeId}
selfKey={propKey}
schema={propSchema}
schema={propSchema as BlockIOKVSubSchema}
entries={currentValue}
errors={errors}
className={className}
@@ -828,15 +495,30 @@ export const NodeGenericInputField: FC<{
handleInputChange={handleInputChange}
/>
);
default:
console.warn(
`Schema for '${propKey}' specifies unknown type:`,
propSchema,
);
case DataType.OBJECT:
return (
<NodeFallbackInput
<NodeObjectInputTree
nodeId={nodeId}
selfKey={propKey}
schema={propSchema}
schema={propSchema as any}
object={currentValue}
errors={errors}
className={cn("border-l border-gray-500 pl-2", className)} // visual indent
displayName={displayName}
connections={connections}
handleInputClick={handleInputClick}
handleInputChange={handleInputChange}
/>
);
case DataType.LONG_TEXT:
case DataType.SHORT_TEXT:
default:
return (
<NodeStringInput
selfKey={propKey}
schema={propSchema as BlockIOStringSubSchema}
value={currentValue}
error={errors[propKey]}
className={className}
@@ -1036,10 +718,6 @@ const NodeCredentialsInput: FC<{
);
};
const InputRef = (value: any): ((el: HTMLInputElement | null) => void) => {
return (el) => el && value != null && (el.value = value);
};
const NodeKeyValueInput: FC<{
nodeId: string;
selfKey: string;
@@ -1123,6 +801,8 @@ const NodeKeyValueInput: FC<{
// because the `key` can change with each input, causing the input to lose focus.
<div key={index}>
<NodeHandle
title={`#${key}`}
className="text-sm text-gray-500"
keyName={getEntryKey(key)}
schema={{ type: "string" }}
isConnected={isConnected(key)}
@@ -1254,6 +934,8 @@ const NodeArrayInput: FC<{
return (
<div key={entryKey}>
<NodeHandle
title={`#${index + 1}`}
className="text-sm text-gray-500"
keyName={entryKey}
schema={schema.items!}
isConnected={isConnected}
@@ -1271,6 +953,7 @@ const NodeArrayInput: FC<{
currentValue={entry}
errors={errors}
connections={connections}
displayName={displayName || beautifyString(selfKey)}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
/>
@@ -1388,7 +1071,7 @@ const NodeStringInput: FC<{
value ||= schema.default || "";
return (
<div className={className}>
{schema.enum ? (
{schema.enum && schema.enum.length > 0 ? (
<Select
defaultValue={value}
onValueChange={(newValue) => handleInputChange(selfKey, newValue)}
@@ -1397,11 +1080,13 @@ const NodeStringInput: FC<{
<SelectValue placeholder={schema.placeholder || displayName} />
</SelectTrigger>
<SelectContent className="nodrag">
{schema.enum.map((option, index) => (
<SelectItem key={index} value={option}>
{beautifyString(option)}
</SelectItem>
))}
{schema.enum
.filter((option) => option)
.map((option, index) => (
<SelectItem key={index} value={option}>
{beautifyString(option)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (

View File

@@ -1,16 +1,16 @@
import React from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
BlockIOStringSubSchema,
BlockIOSubSchema,
} from "@/lib/autogpt-server-api";
import { TypeBasedInput } from "@/components/type-based-input";
import SchemaTooltip from "../SchemaTooltip";
interface InputBlockProps {
id: string;
name: string;
schema: BlockIOSubSchema;
description?: string;
value: string;
placeholder_values?: any[];
@@ -20,47 +20,30 @@ interface InputBlockProps {
export function InputBlock({
id,
name,
schema,
description,
value,
placeholder_values,
onInputChange,
}: InputBlockProps) {
if (placeholder_values && placeholder_values.length > 0) {
schema = { ...schema, enum: placeholder_values } as BlockIOStringSubSchema;
}
return (
<div className="space-y-1">
<h3 className="text-base font-semibold">{name || "Unnamed Input"}</h3>
{description && <p className="text-sm text-gray-600">{description}</p>}
<div>
{placeholder_values && placeholder_values.length > 1 ? (
<Select
onValueChange={(value) => onInputChange(id, "value", value)}
value={value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a value" />
</SelectTrigger>
<SelectContent>
{placeholder_values.map((placeholder, index) => (
<SelectItem
key={index}
value={placeholder.toString()}
data-testid={`run-dialog-input-${name}-${placeholder.toString()}`}
>
{placeholder.toString()}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`${id}-Value`}
data-testid={`run-dialog-input-${name}`}
value={value}
onChange={(e) => onInputChange(id, "value", e.target.value)}
placeholder={placeholder_values?.[0]?.toString() || "Enter value"}
className="w-full"
/>
)}
</div>
<div className="space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{name || "Unnamed Input"}
<SchemaTooltip description={description} />
</label>
<TypeBasedInput
id={`${id}-Value`}
data-testid={`run-dialog-input-${name}`}
schema={schema}
value={value}
placeholder={description}
onChange={(value) => onInputChange(id, "value", value)}
/>
</div>
);
}

View File

@@ -5,21 +5,22 @@ import { BlockInput } from "./RunnerInputUI";
interface InputListProps {
blockInputs: BlockInput[];
onInputChange: (nodeId: string, field: string, value: string) => void;
onInputChange: (nodeId: string, field: string, value: any) => void;
}
export function InputList({ blockInputs, onInputChange }: InputListProps) {
return (
<ScrollArea className="h-[20vh] overflow-auto pr-4 sm:h-[30vh] md:h-[40vh] lg:h-[50vh]">
<ScrollArea className="max-h-[60vh] overflow-auto">
<div className="space-y-4">
{blockInputs && blockInputs.length > 0 ? (
blockInputs.map((block) => (
<InputBlock
key={block.id}
id={block.id}
schema={block.inputSchema}
name={block.hardcodedValues.name}
description={block.hardcodedValues.description}
value={block.hardcodedValues.value?.toString() || ""}
value={block.hardcodedValues.value || ""}
placeholder_values={block.hardcodedValues.placeholder_values}
onInputChange={onInputChange}
/>

View File

@@ -19,7 +19,6 @@ export interface BlockInput {
description: string;
value: any;
placeholder_values?: any[];
limit_to_placeholder_values?: boolean;
};
}
@@ -27,7 +26,7 @@ interface RunSettingsUiProps {
isOpen: boolean;
onClose: () => void;
blockInputs: BlockInput[];
onInputChange: (nodeId: string, field: string, value: string) => void;
onInputChange: (nodeId: string, field: string, value: any) => void;
onRun: () => void;
onSchedule: () => Promise<void>;
scheduledInput: boolean;
@@ -58,8 +57,8 @@ export function RunnerInputUI({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-[400px] md:max-w-[500px] lg:max-w-[600px]">
<DialogHeader className="px-4 py-4">
<DialogContent className="flex flex-col px-10 py-8">
<DialogHeader>
<DialogTitle className="text-2xl">
{scheduledInput ? "Schedule Settings" : "Run Settings"}
</DialogTitle>
@@ -67,14 +66,14 @@ export function RunnerInputUI({
Configure settings for running your agent.
</DialogDescription>
</DialogHeader>
<div className="flex-grow overflow-y-auto px-4 py-4">
<div className="flex-grow overflow-y-auto">
<InputList blockInputs={blockInputs} onInputChange={onInputChange} />
</div>
<DialogFooter className="px-6 py-4">
<DialogFooter>
<Button
data-testid="run-dialog-run-button"
onClick={scheduledInput ? handleSchedule : handleRun}
className="px-8 py-2 text-lg"
className="text-lg"
disabled={scheduledInput ? isScheduling : isRunning}
>
{scheduledInput ? "Schedule" : isRunning ? "Running..." : "Run"}

View File

@@ -0,0 +1,425 @@
import React, { FC } from "react";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { Cross2Icon, FileTextIcon } from "@radix-ui/react-icons";
import { Input as BaseInput } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { determineDataType, DataType } from "@/lib/autogpt-server-api/types";
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
/**
* A generic prop structure for the TypeBasedInput.
*
* onChange expects an event-like object with e.target.value so the parent
* can do something like setInputValues(e.target.value).
*/
export interface TypeBasedInputProps {
schema: BlockIOSubSchema;
value?: any;
placeholder?: string;
onChange: (value: any) => void;
}
const inputClasses = "min-h-11 rounded-full border px-4 py-2.5";
function Input({
className,
...props
}: React.InputHTMLAttributes<HTMLInputElement>) {
return <BaseInput {...props} className={cn(inputClasses, className)} />;
}
/**
* A generic, data-type-based input component that uses Shadcn UI.
* It inspects the schema via `determineDataType` and renders
* the correct UI component.
*/
export const TypeBasedInput: FC<
TypeBasedInputProps & React.HTMLAttributes<HTMLElement>
> = ({ schema, value, placeholder, onChange, ...props }) => {
const dataType = determineDataType(schema);
let innerInputElement: React.ReactNode = null;
switch (dataType) {
case DataType.NUMBER:
innerInputElement = (
<Input
type="number"
value={value ?? ""}
placeholder={placeholder || "Enter number"}
onChange={(e) => onChange(Number(e.target.value))}
{...props}
/>
);
break;
case DataType.LONG_TEXT:
innerInputElement = (
<Textarea
className="rounded-[12px] px-3 py-2"
value={value ?? ""}
placeholder={placeholder || "Enter text"}
onChange={(e) => onChange(e.target.value)}
{...props}
/>
);
break;
case DataType.BOOLEAN:
innerInputElement = (
<>
<span className="text-sm text-gray-500">{placeholder}</span>
<Switch
className={placeholder ? "ml-auto" : "mx-auto"}
checked={!!value}
onCheckedChange={(checked) => onChange(checked)}
{...props}
/>
</>
);
break;
case DataType.DATE:
innerInputElement = (
<DatePicker
value={value}
placeholder={placeholder}
onChange={onChange}
className={cn(inputClasses)}
/>
);
break;
case DataType.TIME:
innerInputElement = (
<TimePicker value={value?.toString()} onChange={onChange} />
);
break;
case DataType.DATE_TIME:
innerInputElement = (
<Input
type="datetime-local"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "Enter date and time"}
{...props}
/>
);
break;
case DataType.FILE:
innerInputElement = (
<FileInput
value={value}
placeholder={placeholder}
onChange={onChange}
{...props}
/>
);
break;
case DataType.SELECT:
if (
"enum" in schema &&
Array.isArray(schema.enum) &&
schema.enum.length > 0
) {
innerInputElement = (
<Select value={value ?? ""} onValueChange={(val) => onChange(val)}>
<SelectTrigger
className={cn(inputClasses, "text-sm text-gray-500")}
>
<SelectValue placeholder={placeholder || "Select an option"} />
</SelectTrigger>
<SelectContent className="rounded-[12px] border">
{schema.enum
.filter((opt) => opt)
.map((opt) => (
<SelectItem key={opt} value={opt}>
{String(opt)}
</SelectItem>
))}
</SelectContent>
</Select>
);
break;
}
case DataType.SHORT_TEXT:
default:
innerInputElement = (
<Input
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "Enter text"}
{...props}
/>
);
}
return <div className="no-drag relative flex">{innerInputElement}</div>;
};
interface DatePickerProps {
value?: Date;
placeholder?: string;
onChange: (date: Date | undefined) => void;
className?: string;
}
export function DatePicker({
value,
placeholder,
onChange,
className,
}: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start font-normal",
!value && "text-muted-foreground",
className,
)}
>
<CalendarIcon className="mr-2 h-5 w-5" />
{value ? (
format(value, "PPP")
) : (
<span>{placeholder || "Pick a date"}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="flex min-h-[340px] w-auto p-0">
<Calendar
mode="single"
selected={value}
onSelect={(selected) => onChange(selected)}
autoFocus
/>
</PopoverContent>
</Popover>
);
}
interface TimePickerProps {
value?: string;
onChange: (time: string) => void;
className?: string;
}
export function TimePicker({ value, onChange }: TimePickerProps) {
const pad = (n: number) => n.toString().padStart(2, "0");
const [hourNum, minuteNum] = value ? value.split(":").map(Number) : [0, 0];
const meridiem = hourNum >= 12 ? "PM" : "AM";
const hour = pad(hourNum % 12 || 12);
const minute = pad(minuteNum);
const changeTime = (hour: string, minute: string, meridiem: string) => {
const hour24 = (Number(hour) % 12) + (meridiem === "PM" ? 12 : 0);
onChange(`${pad(hour24)}:${minute}`);
};
return (
<div className="flex items-center space-x-3">
<div className="flex flex-col items-center">
<Select
value={hour}
onValueChange={(val) => changeTime(val, minute, meridiem)}
>
<SelectTrigger className={cn("text-center", inputClasses)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => pad(i + 1)).map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col items-center">
<span className="m-auto text-xl font-bold">:</span>
</div>
<div className="flex flex-col items-center">
<Select
value={minute}
onValueChange={(val) => changeTime(hour, val, meridiem)}
>
<SelectTrigger className={cn("text-center", inputClasses)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => pad(i)).map((m) => (
<SelectItem key={m} value={m.toString()}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col items-center">
<Select
value={meridiem}
onValueChange={(val) => changeTime(hour, minute, val)}
>
<SelectTrigger className={cn("text-center", inputClasses)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
function getFileLabel(value: string) {
if (value.startsWith("data:")) {
const matches = value.match(/^data:([^;]+);/);
if (matches?.[1]) {
const mimeParts = matches[1].split("/");
if (mimeParts.length > 1) {
return `${mimeParts[1].toUpperCase()} file`;
}
return `${matches[1]} file`;
}
} else {
const pathParts = value.split(".");
if (pathParts.length > 1) {
const ext = pathParts.pop();
if (ext) return `${ext.toUpperCase()} file`;
}
}
return "File";
}
function getFileSize(value: string) {
if (value.startsWith("data:")) {
const matches = value.match(/;base64,(.*)/);
if (matches?.[1]) {
const size = Math.ceil((matches[1].length * 3) / 4);
if (size > 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
} else {
return `${(size / 1024).toFixed(2)} KB`;
}
}
} else {
return "";
}
}
interface FileInputProps {
value?: string; // base64 string or empty
placeholder?: string; // e.g. "Resume", "Document", etc.
onChange: (value: string) => void;
className?: string;
}
const FileInput: FC<FileInputProps> = ({
value,
placeholder,
onChange,
className,
}) => {
const loadFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target?.result as string;
onChange(base64String);
};
reader.readAsDataURL(file);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) loadFile(file);
};
const handleFileDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) loadFile(file);
};
const inputRef = React.useRef<HTMLInputElement>(null);
return (
<div className={cn("w-full", className)}>
{value ? (
<div className="flex min-h-14 items-center gap-4">
<div className="agpt-border-input flex min-h-14 w-full items-center justify-between rounded-[12px] bg-zinc-50 p-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<FileTextIcon className="h-7 w-7 text-black" />
<div className="flex flex-col gap-0.5">
<span className="font-normal text-black">
{getFileLabel(value)}
</span>
<span>{getFileSize(value)}</span>
</div>
</div>
<Cross2Icon
className="h-5 w-5 cursor-pointer text-black"
onClick={() => {
inputRef.current && (inputRef.current.value = "");
onChange("");
}}
/>
</div>
</div>
) : (
<div className="flex min-h-14 items-center gap-4">
<div
onDrop={handleFileDrop}
onDragOver={(e) => e.preventDefault()}
className="agpt-border-input flex min-h-14 w-full items-center justify-center rounded-[12px] border-dashed bg-zinc-50 text-sm text-gray-500"
>
Choose a file or drag and drop it here
</div>
<Button variant="default" onClick={() => inputRef.current?.click()}>
Browse File
</Button>
</div>
)}
<input
ref={inputRef}
type="file"
accept="*/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
};

View File

@@ -42,8 +42,8 @@ function Calendar({
caption_label: "hidden",
nav: "hidden",
button_previous:
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 ",
button_next: " h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
button_next: "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:

View File

@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"agpt-border-input flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"agpt-border-input flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@@ -153,10 +153,11 @@ export default function useAgentGraph(
setAgentDescription(graph.description);
setNodes((prevNodes) => {
const newNodes = graph.nodes.map((node) => {
const _newNodes = graph.nodes.map((node) => {
const block = availableNodes.find(
(block) => block.id === node.block_id,
)!;
if (!block) return null;
const prevNode = prevNodes.find((n) => n.id === node.id);
const flow =
block.uiType == BlockUIType.AGENT
@@ -199,6 +200,7 @@ export default function useAgentGraph(
};
return newNode;
});
const newNodes = _newNodes.filter((n) => n !== null);
setEdges(() =>
graph.links.map((link) => {
const adjustedSourceName = link.source_name?.startsWith("tools_^_")
@@ -411,7 +413,10 @@ export default function useAgentGraph(
) {
return;
}
console.warn("Error", error);
console.warn(`Error in ${node.data.blockType}: ${error}`, {
data: inputData,
schema: node.data.inputSchema,
});
errorMessage = error.message || "Invalid input";
if (path && error.message) {
const key = path.slice(1);

View File

@@ -65,6 +65,23 @@ export type BlockIOSimpleTypeSubSchema =
| BlockIOBooleanSubSchema
| BlockIONullSubSchema;
export enum DataType {
SHORT_TEXT = "short-text",
LONG_TEXT = "long-text",
NUMBER = "number",
DATE = "date",
TIME = "time",
DATE_TIME = "date-time",
FILE = "file",
SELECT = "select",
MULTI_SELECT = "multi-select",
BOOLEAN = "boolean",
CREDENTIALS = "credentials",
OBJECT = "object",
KEY_VALUE = "key-value",
ARRAY = "array",
}
export type BlockIOSubSchemaMeta = {
title?: string;
description?: string;
@@ -102,6 +119,7 @@ export type BlockIOStringSubSchema = BlockIOSubSchemaMeta & {
secret?: true;
default?: string;
format?: string;
maxLength?: number;
};
export type BlockIONumberSubSchema = BlockIOSubSchemaMeta & {
@@ -276,6 +294,7 @@ export type GraphIOSubSchema = Omit<
type: never; // bodge to avoid type checking hell; doesn't exist at runtime
default?: string;
secret: boolean;
metadata?: any;
};
/* Mirror of backend/data/graph.py:Graph */
@@ -845,3 +864,142 @@ export type AdminPendingSubmissionsRequest = {
page: number;
page_size: number;
};
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
date: DataType.DATE,
time: DataType.TIME,
file: DataType.FILE,
"date-time": DataType.DATE_TIME,
"short-text": DataType.SHORT_TEXT,
"long-text": DataType.LONG_TEXT,
};
function _handleStringSchema(strSchema: BlockIOStringSubSchema): DataType {
if (strSchema.format) {
const type = _stringFormatToDataTypeMap[strSchema.format];
if (type) return type;
}
if (strSchema.enum) return DataType.SELECT;
if (strSchema.maxLength && strSchema.maxLength > 200)
return DataType.LONG_TEXT;
return DataType.SHORT_TEXT;
}
function _handleSingleTypeSchema(subSchema: BlockIOSubSchema): DataType {
if (subSchema.type === "string") {
return _handleStringSchema(subSchema as BlockIOStringSubSchema);
}
if (subSchema.type === "boolean") {
return DataType.BOOLEAN;
}
if (subSchema.type === "number" || subSchema.type === "integer") {
return DataType.NUMBER;
}
if (subSchema.type === "array") {
/** Commented code below since we haven't yet support rendering of a multi-select with array { items: enum } type */
// if ("items" in subSchema && subSchema.items && "enum" in subSchema.items) {
// return DataType.MULTI_SELECT; // array + enum => multi-select
// }
return DataType.ARRAY;
}
if (subSchema.type === "object") {
if (
("additionalProperties" in subSchema && subSchema.additionalProperties) ||
!("properties" in subSchema)
) {
return DataType.KEY_VALUE; // if additionalProperties / no properties => key-value
}
if (
Object.values(subSchema.properties).every(
(prop) => prop.type === "boolean",
)
) {
return DataType.MULTI_SELECT; // if all props are boolean => multi-select
}
return DataType.OBJECT;
}
return DataType.SHORT_TEXT;
}
export function determineDataType(schema: BlockIOSubSchema): DataType {
if ("allOf" in schema) {
// If this happens, that is because Pydantic wraps $refs in an allOf if the
// $ref has sibling schema properties (which isn't technically allowed),
// so there will only be one item in allOf[].
// this should NEVER happen though, as $refs are resolved server-side.
console.warn(
`Detected 'allOf' wrapper: ${schema}. Normalizing use ${schema.allOf[0]} instead.`,
);
schema = schema.allOf[0];
}
// Credentials override
if ("credentials_provider" in schema) {
return DataType.CREDENTIALS;
}
// enum == SELECT
if ("enum" in schema) {
return DataType.SELECT;
}
// Handle anyOf => optional types (string|null, number|null, etc.)
if ("anyOf" in schema) {
// e.g. schema.anyOf might look like [{ type: "string", ... }, { type: "null" }]
const types = schema.anyOf.map((sub) =>
"type" in sub ? sub.type : undefined,
);
// (string | null)
if (types.includes("string") && types.includes("null")) {
const strSchema = schema.anyOf.find(
(s) => s.type === "string",
) as BlockIOStringSubSchema;
return _handleStringSchema(strSchema);
}
// (number|integer) & null
if (
(types.includes("number") || types.includes("integer")) &&
types.includes("null")
) {
// Just reuse our single-type logic for whichever is not null
const numSchema = schema.anyOf.find(
(s) => s.type === "number" || s.type === "integer",
);
if (numSchema) {
return _handleSingleTypeSchema(numSchema);
}
return DataType.NUMBER; // fallback
}
// (array | null)
if (types.includes("array") && types.includes("null")) {
const arrSchema = schema.anyOf.find((s) => s.type === "array");
if (arrSchema) return _handleSingleTypeSchema(arrSchema);
return DataType.ARRAY;
}
// (object | null)
if (types.includes("object") && types.includes("null")) {
const objSchema = schema.anyOf.find(
(s) => s.type === "object",
) as BlockIOObjectSubSchema;
if (objSchema) return _handleSingleTypeSchema(objSchema);
return DataType.OBJECT;
}
}
// oneOf + discriminator => user picks which variant => SELECT
if ("oneOf" in schema && "discriminator" in schema && schema.discriminator) {
return DataType.SELECT;
}
// Direct type
if ("type" in schema) {
return _handleSingleTypeSchema(schema);
}
// Fallback
return DataType.SHORT_TEXT;
}