mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
feat(blocks;frontend): Add file multipart upload support for SendWebRequestBlock & Improve key-value input UI rendering (#10058)
Now, SendWebRequestBlock can upload files. To make this work, we also need to improve the UI rendering on the key-value pair input so that it can also render media/file upload. ### Changes 🏗️ * Add file multipart upload support for SendWebRequestBlock * Improve key-value input UI rendering to allow rendering any types as a normal input block (it was only string & number). <img width="381" alt="image" src="https://github.com/user-attachments/assets/b41d778d-8f9d-4aec-95b6-0b32bef50e89" /> ### 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: <!-- Put your test plan here: --> - [x] Test running http request block, othe key-value pair input block
This commit is contained in:
@@ -1,12 +1,19 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from io import BufferedReader
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from requests.exceptions import HTTPError, RequestException
|
from requests.exceptions import HTTPError, RequestException
|
||||||
|
|
||||||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
|
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
|
||||||
from backend.data.model import SchemaField
|
from backend.data.model import SchemaField
|
||||||
|
from backend.util.file import (
|
||||||
|
MediaFileType,
|
||||||
|
get_exec_file_path,
|
||||||
|
get_mime_type,
|
||||||
|
store_media_file,
|
||||||
|
)
|
||||||
from backend.util.request import requests
|
from backend.util.request import requests
|
||||||
|
|
||||||
logger = logging.getLogger(name=__name__)
|
logger = logging.getLogger(name=__name__)
|
||||||
@@ -38,13 +45,21 @@ class SendWebRequestBlock(Block):
|
|||||||
)
|
)
|
||||||
json_format: bool = SchemaField(
|
json_format: bool = SchemaField(
|
||||||
title="JSON format",
|
title="JSON format",
|
||||||
description="Whether to send and receive body as JSON",
|
description="If true, send the body as JSON (unless files are also present).",
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
body: Any = SchemaField(
|
body: dict | None = SchemaField(
|
||||||
description="The body of the request",
|
description="Form/JSON body payload. If files are supplied, this must be a mapping of form‑fields.",
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
files_name: str = SchemaField(
|
||||||
|
description="The name of the file field in the form data.",
|
||||||
|
default="file",
|
||||||
|
)
|
||||||
|
files: list[MediaFileType] = SchemaField(
|
||||||
|
description="Mapping of *form field name* → Image url / path / base64 url.",
|
||||||
|
default_factory=list,
|
||||||
|
)
|
||||||
|
|
||||||
class Output(BlockSchema):
|
class Output(BlockSchema):
|
||||||
response: object = SchemaField(description="The response from the server")
|
response: object = SchemaField(description="The response from the server")
|
||||||
@@ -55,67 +70,112 @@ class SendWebRequestBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="6595ae1f-b924-42cb-9a41-551a0611c4b4",
|
id="6595ae1f-b924-42cb-9a41-551a0611c4b4",
|
||||||
description="This block makes an HTTP request to the given URL.",
|
description="Make an HTTP request (JSON / form / multipart).",
|
||||||
categories={BlockCategory.OUTPUT},
|
categories={BlockCategory.OUTPUT},
|
||||||
input_schema=SendWebRequestBlock.Input,
|
input_schema=SendWebRequestBlock.Input,
|
||||||
output_schema=SendWebRequestBlock.Output,
|
output_schema=SendWebRequestBlock.Output,
|
||||||
)
|
)
|
||||||
|
|
||||||
def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
@staticmethod
|
||||||
|
def _prepare_files(
|
||||||
|
graph_exec_id: str,
|
||||||
|
files_name: str,
|
||||||
|
files: list[MediaFileType],
|
||||||
|
) -> tuple[list[tuple[str, tuple[str, BufferedReader, str]]], list[BufferedReader]]:
|
||||||
|
"""Convert the `files` mapping into the structure expected by `requests`.
|
||||||
|
|
||||||
|
Returns a tuple of (**files_payload**, **open_handles**) so we can close handles later.
|
||||||
|
"""
|
||||||
|
files_payload: list[tuple[str, tuple[str, BufferedReader, str]]] = []
|
||||||
|
open_handles: list[BufferedReader] = []
|
||||||
|
|
||||||
|
for media in files:
|
||||||
|
# Normalise to a list so we can repeat the same key
|
||||||
|
rel_path = store_media_file(graph_exec_id, media, return_content=False)
|
||||||
|
abs_path = get_exec_file_path(graph_exec_id, rel_path)
|
||||||
|
try:
|
||||||
|
handle = open(abs_path, "rb")
|
||||||
|
except Exception as e:
|
||||||
|
for h in open_handles:
|
||||||
|
try:
|
||||||
|
h.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"Failed to open file '{abs_path}': {e}") from e
|
||||||
|
|
||||||
|
open_handles.append(handle)
|
||||||
|
mime = get_mime_type(abs_path)
|
||||||
|
files_payload.append((files_name, (Path(abs_path).name, handle, mime)))
|
||||||
|
|
||||||
|
return files_payload, open_handles
|
||||||
|
|
||||||
|
def run(self, input_data: Input, *, graph_exec_id: str, **kwargs) -> BlockOutput:
|
||||||
|
# ─── Parse/normalise body ────────────────────────────────────
|
||||||
body = input_data.body
|
body = input_data.body
|
||||||
|
if isinstance(body, str):
|
||||||
|
try:
|
||||||
|
body = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# plain text – treat as form‑field value instead
|
||||||
|
input_data.json_format = False
|
||||||
|
|
||||||
if input_data.json_format:
|
# ─── Prepare files (if any) ──────────────────────────────────
|
||||||
if isinstance(body, str):
|
use_files = bool(input_data.files)
|
||||||
try:
|
files_payload: list[tuple[str, tuple[str, BufferedReader, str]]] = []
|
||||||
# Try to parse as JSON first
|
open_handles: list[BufferedReader] = []
|
||||||
body = json.loads(body)
|
if use_files:
|
||||||
except json.JSONDecodeError:
|
files_payload, open_handles = self._prepare_files(
|
||||||
# If it's not valid JSON and just plain text,
|
graph_exec_id, input_data.files_name, input_data.files
|
||||||
# we should send it as plain text instead
|
)
|
||||||
input_data.json_format = False
|
|
||||||
|
|
||||||
|
# Enforce body format rules
|
||||||
|
if use_files and input_data.json_format:
|
||||||
|
raise ValueError(
|
||||||
|
"json_format=True cannot be combined with file uploads; set json_format=False and put form fields in `body`."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Execute request ─────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
input_data.method.value,
|
input_data.method.value,
|
||||||
input_data.url,
|
input_data.url,
|
||||||
headers=input_data.headers,
|
headers=input_data.headers,
|
||||||
json=body if input_data.json_format else None,
|
files=files_payload if use_files else None,
|
||||||
|
# * If files → multipart ⇒ pass form‑fields via data=
|
||||||
data=body if not input_data.json_format else None,
|
data=body if not input_data.json_format else None,
|
||||||
|
# * Else, choose JSON vs url‑encoded based on flag
|
||||||
|
json=body if (input_data.json_format and not use_files) else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if input_data.json_format:
|
# Decide how to parse the response
|
||||||
if response.status_code == 204 or not response.content.strip():
|
if input_data.json_format or response.headers.get(
|
||||||
result = None
|
"content-type", ""
|
||||||
else:
|
).startswith("application/json"):
|
||||||
result = response.json()
|
result = (
|
||||||
|
None
|
||||||
|
if (response.status_code == 204 or not response.content.strip())
|
||||||
|
else response.json()
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = response.text
|
result = response.text
|
||||||
|
|
||||||
yield "response", result
|
# Yield according to status code bucket
|
||||||
|
if 200 <= response.status_code < 300:
|
||||||
|
yield "response", result
|
||||||
|
elif 400 <= response.status_code < 500:
|
||||||
|
yield "client_error", result
|
||||||
|
else:
|
||||||
|
yield "server_error", result
|
||||||
|
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
# Handle error responses
|
yield "error", f"HTTP error: {str(e)}"
|
||||||
try:
|
|
||||||
result = e.response.json() if input_data.json_format else str(e)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
result = str(e)
|
|
||||||
|
|
||||||
if 400 <= e.response.status_code < 500:
|
|
||||||
yield "client_error", result
|
|
||||||
elif 500 <= e.response.status_code < 600:
|
|
||||||
yield "server_error", result
|
|
||||||
else:
|
|
||||||
error_msg = (
|
|
||||||
"Unexpected status code "
|
|
||||||
f"{e.response.status_code} '{e.response.reason}'"
|
|
||||||
)
|
|
||||||
logger.warning(error_msg)
|
|
||||||
yield "error", error_msg
|
|
||||||
|
|
||||||
except RequestException as e:
|
except RequestException as e:
|
||||||
# Handle other request-related exceptions
|
yield "error", f"Request error: {str(e)}"
|
||||||
yield "error", str(e)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Catch any other unexpected exceptions
|
|
||||||
yield "error", str(e)
|
yield "error", str(e)
|
||||||
|
finally:
|
||||||
|
for h in open_handles:
|
||||||
|
try:
|
||||||
|
h.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ def store_media_file(
|
|||||||
return ext if ext else ".bin"
|
return ext if ext else ".bin"
|
||||||
|
|
||||||
def _file_to_data_uri(path: Path) -> str:
|
def _file_to_data_uri(path: Path) -> str:
|
||||||
mime_type, _ = mimetypes.guess_type(path)
|
mime_type = get_mime_type(str(path))
|
||||||
mime_type = mime_type or "application/octet-stream"
|
|
||||||
b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
|
b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
|
||||||
return f"data:{mime_type};base64,{b64}"
|
return f"data:{mime_type};base64,{b64}"
|
||||||
|
|
||||||
@@ -130,3 +129,21 @@ def store_media_file(
|
|||||||
return MediaFileType(_file_to_data_uri(target_path))
|
return MediaFileType(_file_to_data_uri(target_path))
|
||||||
else:
|
else:
|
||||||
return MediaFileType(_strip_base_prefix(target_path, base_path))
|
return MediaFileType(_strip_base_prefix(target_path, base_path))
|
||||||
|
|
||||||
|
|
||||||
|
def get_mime_type(file: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the MIME type of a file, whether it's a data URI, URL, or local path.
|
||||||
|
"""
|
||||||
|
if file.startswith("data:"):
|
||||||
|
match = re.match(r"^data:([^;]+);base64,", file)
|
||||||
|
return match.group(1) if match else "application/octet-stream"
|
||||||
|
|
||||||
|
elif file.startswith(("http://", "https://")):
|
||||||
|
parsed_url = urlparse(file)
|
||||||
|
mime_type, _ = mimetypes.guess_type(parsed_url.path)
|
||||||
|
return mime_type or "application/octet-stream"
|
||||||
|
|
||||||
|
else:
|
||||||
|
mime_type, _ = mimetypes.guess_type(file)
|
||||||
|
return mime_type or "application/octet-stream"
|
||||||
|
|||||||
@@ -493,10 +493,11 @@ export const NodeGenericInputField: FC<{
|
|||||||
schema={propSchema as BlockIOKVSubSchema}
|
schema={propSchema as BlockIOKVSubSchema}
|
||||||
entries={currentValue}
|
entries={currentValue}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
className={className}
|
|
||||||
displayName={displayName}
|
|
||||||
connections={connections}
|
connections={connections}
|
||||||
handleInputChange={handleInputChange}
|
handleInputChange={handleInputChange}
|
||||||
|
handleInputClick={handleInputClick}
|
||||||
|
className={className}
|
||||||
|
displayName={displayName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -732,6 +733,7 @@ const NodeKeyValueInput: FC<{
|
|||||||
errors: { [key: string]: string | undefined };
|
errors: { [key: string]: string | undefined };
|
||||||
connections: NodeObjectInputTreeProps["connections"];
|
connections: NodeObjectInputTreeProps["connections"];
|
||||||
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
|
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
|
||||||
|
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
|
||||||
className?: string;
|
className?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
@@ -741,6 +743,7 @@ const NodeKeyValueInput: FC<{
|
|||||||
schema,
|
schema,
|
||||||
connections,
|
connections,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
|
handleInputClick,
|
||||||
errors,
|
errors,
|
||||||
className,
|
className,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -761,7 +764,7 @@ const NodeKeyValueInput: FC<{
|
|||||||
}, [entries, schema.default, connections, nodeId, selfKey]);
|
}, [entries, schema.default, connections, nodeId, selfKey]);
|
||||||
|
|
||||||
const [keyValuePairs, setKeyValuePairs] = useState<
|
const [keyValuePairs, setKeyValuePairs] = useState<
|
||||||
{ key: string; value: string | number | null }[]
|
{ key: string; value: any }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -778,18 +781,6 @@ const NodeKeyValueInput: FC<{
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNumberType =
|
|
||||||
schema.additionalProperties &&
|
|
||||||
["number", "integer"].includes(schema.additionalProperties.type);
|
|
||||||
|
|
||||||
function convertValueType(value: string): string | number | null {
|
|
||||||
if (isNumberType) {
|
|
||||||
const numValue = Number(value);
|
|
||||||
return !isNaN(numValue) ? numValue : null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryKey(key: string): string {
|
function getEntryKey(key: string): string {
|
||||||
return `${selfKey}_#_${key}`;
|
return `${selfKey}_#_${key}`;
|
||||||
}
|
}
|
||||||
@@ -799,6 +790,11 @@ const NodeKeyValueInput: FC<{
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const propSchema =
|
||||||
|
schema.additionalProperties && schema.additionalProperties.type
|
||||||
|
? schema.additionalProperties
|
||||||
|
: ({ type: "string" } as BlockIOSimpleTypeSubSchema);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(className, keyValuePairs.length > 0 ? "flex flex-col" : "")}
|
className={cn(className, keyValuePairs.length > 0 ? "flex flex-col" : "")}
|
||||||
@@ -832,18 +828,24 @@ const NodeKeyValueInput: FC<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<LocalValuedInput
|
<NodeGenericInputField
|
||||||
type={isNumberType ? "number" : "text"}
|
className="w-full"
|
||||||
placeholder="Value"
|
nodeId={nodeId}
|
||||||
value={value ?? ""}
|
propKey={`${selfKey}_#_${key}`}
|
||||||
onChange={(e) =>
|
propSchema={propSchema}
|
||||||
|
currentValue={value}
|
||||||
|
errors={errors}
|
||||||
|
connections={connections}
|
||||||
|
displayName={displayName || beautifyString(key)}
|
||||||
|
handleInputChange={(_, newValue) =>
|
||||||
updateKeyValuePairs(
|
updateKeyValuePairs(
|
||||||
keyValuePairs.toSpliced(index, 1, {
|
keyValuePairs.toSpliced(index, 1, {
|
||||||
key: key,
|
key: key,
|
||||||
value: convertValueType(e.target.value),
|
value: newValue,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
handleInputClick={handleInputClick}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
Reference in New Issue
Block a user