mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-08 22:58:01 -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 logging
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from io import BufferedReader
|
||||
from pathlib import Path
|
||||
|
||||
from requests.exceptions import HTTPError, RequestException
|
||||
|
||||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
|
||||
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
|
||||
|
||||
logger = logging.getLogger(name=__name__)
|
||||
@@ -38,13 +45,21 @@ class SendWebRequestBlock(Block):
|
||||
)
|
||||
json_format: bool = SchemaField(
|
||||
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,
|
||||
)
|
||||
body: Any = SchemaField(
|
||||
description="The body of the request",
|
||||
body: dict | None = SchemaField(
|
||||
description="Form/JSON body payload. If files are supplied, this must be a mapping of form‑fields.",
|
||||
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):
|
||||
response: object = SchemaField(description="The response from the server")
|
||||
@@ -55,67 +70,112 @@ class SendWebRequestBlock(Block):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
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},
|
||||
input_schema=SendWebRequestBlock.Input,
|
||||
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
|
||||
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:
|
||||
if isinstance(body, str):
|
||||
try:
|
||||
# Try to parse as JSON first
|
||||
body = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
# If it's not valid JSON and just plain text,
|
||||
# we should send it as plain text instead
|
||||
input_data.json_format = False
|
||||
# ─── Prepare files (if any) ──────────────────────────────────
|
||||
use_files = bool(input_data.files)
|
||||
files_payload: list[tuple[str, tuple[str, BufferedReader, str]]] = []
|
||||
open_handles: list[BufferedReader] = []
|
||||
if use_files:
|
||||
files_payload, open_handles = self._prepare_files(
|
||||
graph_exec_id, input_data.files_name, input_data.files
|
||||
)
|
||||
|
||||
# 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:
|
||||
response = requests.request(
|
||||
input_data.method.value,
|
||||
input_data.url,
|
||||
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,
|
||||
# * 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:
|
||||
if response.status_code == 204 or not response.content.strip():
|
||||
result = None
|
||||
else:
|
||||
result = response.json()
|
||||
# Decide how to parse the response
|
||||
if input_data.json_format or response.headers.get(
|
||||
"content-type", ""
|
||||
).startswith("application/json"):
|
||||
result = (
|
||||
None
|
||||
if (response.status_code == 204 or not response.content.strip())
|
||||
else response.json()
|
||||
)
|
||||
else:
|
||||
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:
|
||||
# Handle error responses
|
||||
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
|
||||
|
||||
yield "error", f"HTTP error: {str(e)}"
|
||||
except RequestException as e:
|
||||
# Handle other request-related exceptions
|
||||
yield "error", str(e)
|
||||
|
||||
yield "error", f"Request error: {str(e)}"
|
||||
except Exception as e:
|
||||
# Catch any other unexpected exceptions
|
||||
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"
|
||||
|
||||
def _file_to_data_uri(path: Path) -> str:
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
mime_type = mime_type or "application/octet-stream"
|
||||
mime_type = get_mime_type(str(path))
|
||||
b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{b64}"
|
||||
|
||||
@@ -130,3 +129,21 @@ def store_media_file(
|
||||
return MediaFileType(_file_to_data_uri(target_path))
|
||||
else:
|
||||
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}
|
||||
entries={currentValue}
|
||||
errors={errors}
|
||||
className={className}
|
||||
displayName={displayName}
|
||||
connections={connections}
|
||||
handleInputChange={handleInputChange}
|
||||
handleInputClick={handleInputClick}
|
||||
className={className}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -732,6 +733,7 @@ const NodeKeyValueInput: FC<{
|
||||
errors: { [key: string]: string | undefined };
|
||||
connections: NodeObjectInputTreeProps["connections"];
|
||||
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
|
||||
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
|
||||
className?: string;
|
||||
displayName?: string;
|
||||
}> = ({
|
||||
@@ -741,6 +743,7 @@ const NodeKeyValueInput: FC<{
|
||||
schema,
|
||||
connections,
|
||||
handleInputChange,
|
||||
handleInputClick,
|
||||
errors,
|
||||
className,
|
||||
displayName,
|
||||
@@ -761,7 +764,7 @@ const NodeKeyValueInput: FC<{
|
||||
}, [entries, schema.default, connections, nodeId, selfKey]);
|
||||
|
||||
const [keyValuePairs, setKeyValuePairs] = useState<
|
||||
{ key: string; value: string | number | null }[]
|
||||
{ key: string; value: any }[]
|
||||
>([]);
|
||||
|
||||
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 {
|
||||
return `${selfKey}_#_${key}`;
|
||||
}
|
||||
@@ -799,6 +790,11 @@ const NodeKeyValueInput: FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const propSchema =
|
||||
schema.additionalProperties && schema.additionalProperties.type
|
||||
? schema.additionalProperties
|
||||
: ({ type: "string" } as BlockIOSimpleTypeSubSchema);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className, keyValuePairs.length > 0 ? "flex flex-col" : "")}
|
||||
@@ -832,18 +828,24 @@ const NodeKeyValueInput: FC<{
|
||||
)
|
||||
}
|
||||
/>
|
||||
<LocalValuedInput
|
||||
type={isNumberType ? "number" : "text"}
|
||||
placeholder="Value"
|
||||
value={value ?? ""}
|
||||
onChange={(e) =>
|
||||
<NodeGenericInputField
|
||||
className="w-full"
|
||||
nodeId={nodeId}
|
||||
propKey={`${selfKey}_#_${key}`}
|
||||
propSchema={propSchema}
|
||||
currentValue={value}
|
||||
errors={errors}
|
||||
connections={connections}
|
||||
displayName={displayName || beautifyString(key)}
|
||||
handleInputChange={(_, newValue) =>
|
||||
updateKeyValuePairs(
|
||||
keyValuePairs.toSpliced(index, 1, {
|
||||
key: key,
|
||||
value: convertValueType(e.target.value),
|
||||
value: newValue,
|
||||
}),
|
||||
)
|
||||
}
|
||||
handleInputClick={handleInputClick}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user