Compare commits

..

17 Commits

Author SHA1 Message Date
Bentlybro
88532185bc format.... 2026-01-14 12:50:54 +00:00
Bentlybro
3a56343013 Add error field to ClaudeCodeBlock schema
Introduces an 'error' field to the ClaudeCodeBlock schema to store error messages when code execution fails.
2026-01-14 12:41:24 +00:00
Bentlybro
b02b5e0708 Handle timestamp command failure in ClaudeCodeBlock
Adds error handling for the timestamp command in ClaudeCodeBlock by raising a RuntimeError if the command fails, ensuring issues are surfaced when the timestamp cannot be captured.
2026-01-14 12:35:53 +00:00
Bentlybro
1239cb7a4d Fix race condition in timestamp capture for Claude Code
Adjusts the timestamp capture to use '1 second ago' instead of the current time to prevent race conditions with file creation during Claude Code execution.
2026-01-14 12:27:13 +00:00
Bentlybro
6bc05de917 Add sandbox_id field to ClaudeCodeBlock test cases
Introduces a 'sandbox_id' field with a value of None in the test cases for ClaudeCodeBlock, reflecting the use of dispose_sandbox=True in test_input.
2026-01-14 12:16:36 +00:00
Bentlybro
da04bcbea5 Filter extracted files by execution timestamp
Update ClaudeCodeBlock to only extract text files created or modified during the current execution by capturing a start timestamp and filtering files using the 'find' command with -newermt. This prevents returning files from previous runs and ensures more accurate file outputs.
2026-01-14 12:04:28 +00:00
Bentlybro
34c1644bd6 Improve sandbox_id handling and file extraction in ClaudeCodeBlock
Updated the Output schema to allow sandbox_id to be optional and clarified its behavior when the sandbox is disposed. Enhanced file extraction to clarify that all text files in the working directory are returned, not just those created or modified during execution. Adjusted logic to always yield sandbox_id (None if disposed) and improved documentation and comments for clarity.
2026-01-14 11:48:02 +00:00
Bentlybro
6220396cd7 Refactor imports and update credential descriptions
Moved json and uuid imports to the top of the file and updated credential field descriptions to use markdown links for better readability.
2026-01-14 11:32:11 +00:00
Bentlybro
75a92fc3f3 Always yield files in ClaudeCodeBlock output
Updated the ClaudeCodeBlock to always yield the 'files' key with an empty list if no files are present, ensuring consistent output that matches the Output schema.
2026-01-13 21:55:53 +00:00
Bentlybro
2c6353a6a2 Clarify timeout behavior and fix path slicing
Updated the timeout field description to specify that the timeout only applies when creating a new sandbox, not when reconnecting. Also fixed whitespace in relative path slicing for improved code clarity.
2026-01-13 21:49:28 +00:00
Bentlybro
21b70ae9ae Improve shell command safety and error handling
Use shlex.quote to safely escape shell arguments such as working_directory and session IDs, preventing shell injection vulnerabilities. Add explicit error handling for failed Claude command executions. Refine file listing to allow hidden files except for .git, and update comments for clarity.
2026-01-13 21:40:56 +00:00
Bentlybro
b007e02364 Fix credential title assignment and improve session validation
Corrects the assignment of the 'title' field in test credential inputs to use the actual title instead of type. Adds validation to require 'sandbox_id' when resuming a session with 'session_id', and improves error handling for setup command failures in ClaudeCodeBlock.
2026-01-13 21:21:54 +00:00
Bentlybro
bbc289894a Move ClaudeCodeBlock to separate module
Extracted the ClaudeCodeBlock implementation from code_executor.py into a new claude_code.py module for better separation of concerns and maintainability. Removed Anthropic test credentials from code_executor.py as they are now defined in claude_code.py.
2026-01-13 21:06:11 +00:00
Bentlybro
639ee5f073 Add conversation history support to ClaudeCodeBlock
Introduces a conversation_history input and output to enable restoring context when continuing a session in a new sandbox. Updates the code execution flow to maintain and return the full conversation history, and marks several schema fields as advanced for improved UI clarity.
2026-01-13 16:40:17 +00:00
Bentlybro
898781134d Add session and sandbox continuation to ClaudeCodeBlock
Introduces session_id and sandbox_id fields to support resuming previous conversations and reconnecting to existing sandboxes. Updates input/output schemas, command execution logic, and documentation to enable session continuation and sandbox reuse for Claude code execution.
2026-01-13 15:10:46 +00:00
Bentlybro
7a28db1649 Refactor ClaudeCodeBlock to return output files
Updated ClaudeCodeBlock to extract and return a list of files created or modified during code execution, replacing stdout and stderr logs in the output schema. Added FileOutput model, file extraction logic, and updated test mocks and method signatures accordingly.
2026-01-13 14:41:10 +00:00
Bentlybro
a916ea0f8f Add Claude Code block for Anthropic code execution
Introduces a new ClaudeCodeBlock that enables execution of coding tasks using Anthropic's Claude Code in an E2B sandbox. The block supports configuration of credentials, prompt, timeout, setup commands, and working directory, and handles sandbox lifecycle and output collection.
2026-01-13 14:28:13 +00:00
44 changed files with 802 additions and 2400 deletions

View File

@@ -0,0 +1,631 @@
import json
import shlex
import uuid
from typing import Literal, Optional
from e2b import AsyncSandbox as BaseAsyncSandbox
from pydantic import BaseModel, SecretStr
from backend.data.block import (
Block,
BlockCategory,
BlockOutput,
BlockSchemaInput,
BlockSchemaOutput,
)
from backend.data.model import (
APIKeyCredentials,
CredentialsField,
CredentialsMetaInput,
SchemaField,
)
from backend.integrations.providers import ProviderName
# Test credentials for E2B
TEST_E2B_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="e2b",
api_key=SecretStr("mock-e2b-api-key"),
title="Mock E2B API key",
expires_at=None,
)
TEST_E2B_CREDENTIALS_INPUT = {
"provider": TEST_E2B_CREDENTIALS.provider,
"id": TEST_E2B_CREDENTIALS.id,
"type": TEST_E2B_CREDENTIALS.type,
"title": TEST_E2B_CREDENTIALS.title,
}
# Test credentials for Anthropic
TEST_ANTHROPIC_CREDENTIALS = APIKeyCredentials(
id="2e568a2b-b2ea-475a-8564-9a676bf31c56",
provider="anthropic",
api_key=SecretStr("mock-anthropic-api-key"),
title="Mock Anthropic API key",
expires_at=None,
)
TEST_ANTHROPIC_CREDENTIALS_INPUT = {
"provider": TEST_ANTHROPIC_CREDENTIALS.provider,
"id": TEST_ANTHROPIC_CREDENTIALS.id,
"type": TEST_ANTHROPIC_CREDENTIALS.type,
"title": TEST_ANTHROPIC_CREDENTIALS.title,
}
class ClaudeCodeBlock(Block):
"""
Execute tasks using Claude Code (Anthropic's AI coding assistant) in an E2B sandbox.
Claude Code can create files, install tools, run commands, and perform complex
coding tasks autonomously within a secure sandbox environment.
"""
# Use base template - we'll install Claude Code ourselves for latest version
DEFAULT_TEMPLATE = "base"
class Input(BlockSchemaInput):
e2b_credentials: CredentialsMetaInput[
Literal[ProviderName.E2B], Literal["api_key"]
] = CredentialsField(
description=(
"API key for the E2B platform to create the sandbox. "
"Get one on the [e2b website](https://e2b.dev/docs)"
),
)
anthropic_credentials: CredentialsMetaInput[
Literal[ProviderName.ANTHROPIC], Literal["api_key"]
] = CredentialsField(
description=(
"API key for Anthropic to power Claude Code. "
"Get one at [Anthropic's website](https://console.anthropic.com)"
),
)
prompt: str = SchemaField(
description=(
"The task or instruction for Claude Code to execute. "
"Claude Code can create files, install packages, run commands, "
"and perform complex coding tasks."
),
placeholder="Create a hello world index.html file",
default="",
advanced=False,
)
timeout: int = SchemaField(
description=(
"Sandbox timeout in seconds. Claude Code tasks can take "
"a while, so set this appropriately for your task complexity. "
"Note: This only applies when creating a new sandbox. "
"When reconnecting to an existing sandbox via sandbox_id, "
"the original timeout is retained."
),
default=300, # 5 minutes default
advanced=True,
)
setup_commands: list[str] = SchemaField(
description=(
"Optional shell commands to run before executing Claude Code. "
"Useful for installing dependencies or setting up the environment."
),
default_factory=list,
advanced=True,
)
working_directory: str = SchemaField(
description="Working directory for Claude Code to operate in.",
default="/home/user",
advanced=True,
)
# Session/continuation support
session_id: str = SchemaField(
description=(
"Session ID to resume a previous conversation. "
"Leave empty for a new conversation. "
"Use the session_id from a previous run to continue that conversation."
),
default="",
advanced=True,
)
sandbox_id: str = SchemaField(
description=(
"Sandbox ID to reconnect to an existing sandbox. "
"Required when resuming a session (along with session_id). "
"Use the sandbox_id from a previous run where dispose_sandbox was False."
),
default="",
advanced=True,
)
conversation_history: str = SchemaField(
description=(
"Previous conversation history to continue from. "
"Use this to restore context on a fresh sandbox if the previous one timed out. "
"Pass the conversation_history output from a previous run."
),
default="",
advanced=True,
)
dispose_sandbox: bool = SchemaField(
description=(
"Whether to dispose of the sandbox immediately after execution. "
"Set to False if you want to continue the conversation later "
"(you'll need both sandbox_id and session_id from the output)."
),
default=True,
advanced=True,
)
class FileOutput(BaseModel):
"""A file extracted from the sandbox."""
path: str
relative_path: str # Path relative to working directory (for GitHub, etc.)
name: str
content: str
class Output(BlockSchemaOutput):
response: str = SchemaField(
description="The output/response from Claude Code execution"
)
files: list["ClaudeCodeBlock.FileOutput"] = SchemaField(
description=(
"List of text files created/modified by Claude Code during this execution. "
"Each file has 'path', 'relative_path', 'name', and 'content' fields."
)
)
conversation_history: str = SchemaField(
description=(
"Full conversation history including this turn. "
"Pass this to conversation_history input to continue on a fresh sandbox "
"if the previous sandbox timed out."
)
)
session_id: str = SchemaField(
description=(
"Session ID for this conversation. "
"Pass this back along with sandbox_id to continue the conversation."
)
)
sandbox_id: Optional[str] = SchemaField(
description=(
"ID of the sandbox instance. "
"Pass this back along with session_id to continue the conversation. "
"This is None if dispose_sandbox was True (sandbox was disposed)."
),
default=None,
)
error: str = SchemaField(description="Error message if execution failed")
def __init__(self):
super().__init__(
id="4e34f4a5-9b89-4326-ba77-2dd6750b7194",
description=(
"Execute tasks using Claude Code in an E2B sandbox. "
"Claude Code can create files, install tools, run commands, "
"and perform complex coding tasks autonomously."
),
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.AI},
input_schema=ClaudeCodeBlock.Input,
output_schema=ClaudeCodeBlock.Output,
test_credentials={
"e2b_credentials": TEST_E2B_CREDENTIALS,
"anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS,
},
test_input={
"e2b_credentials": TEST_E2B_CREDENTIALS_INPUT,
"anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS_INPUT,
"prompt": "Create a hello world HTML file",
"timeout": 300,
"setup_commands": [],
"working_directory": "/home/user",
"session_id": "",
"sandbox_id": "",
"conversation_history": "",
"dispose_sandbox": True,
},
test_output=[
("response", "Created index.html with hello world content"),
(
"files",
[
{
"path": "/home/user/index.html",
"relative_path": "index.html",
"name": "index.html",
"content": "<html>Hello World</html>",
}
],
),
(
"conversation_history",
"User: Create a hello world HTML file\n"
"Claude: Created index.html with hello world content",
),
("session_id", str),
("sandbox_id", None), # None because dispose_sandbox=True in test_input
],
test_mock={
"execute_claude_code": lambda *args, **kwargs: (
"Created index.html with hello world content", # response
[
ClaudeCodeBlock.FileOutput(
path="/home/user/index.html",
relative_path="index.html",
name="index.html",
content="<html>Hello World</html>",
)
], # files
"User: Create a hello world HTML file\n"
"Claude: Created index.html with hello world content", # conversation_history
"test-session-id", # session_id
"sandbox_id", # sandbox_id
),
},
)
async def execute_claude_code(
self,
e2b_api_key: str,
anthropic_api_key: str,
prompt: str,
timeout: int,
setup_commands: list[str],
working_directory: str,
session_id: str,
existing_sandbox_id: str,
conversation_history: str,
dispose_sandbox: bool,
) -> tuple[str, list["ClaudeCodeBlock.FileOutput"], str, str, str]:
"""
Execute Claude Code in an E2B sandbox.
Returns:
Tuple of (response, files, conversation_history, session_id, sandbox_id)
"""
# Validate that sandbox_id is provided when resuming a session
if session_id and not existing_sandbox_id:
raise ValueError(
"sandbox_id is required when resuming a session with session_id. "
"The session state is stored in the original sandbox. "
"If the sandbox has timed out, use conversation_history instead "
"to restore context on a fresh sandbox."
)
sandbox = None
try:
# Either reconnect to existing sandbox or create a new one
if existing_sandbox_id:
# Reconnect to existing sandbox for conversation continuation
sandbox = await BaseAsyncSandbox.connect(
sandbox_id=existing_sandbox_id,
api_key=e2b_api_key,
)
else:
# Create new sandbox
sandbox = await BaseAsyncSandbox.create(
template=self.DEFAULT_TEMPLATE,
api_key=e2b_api_key,
timeout=timeout,
envs={"ANTHROPIC_API_KEY": anthropic_api_key},
)
# Install Claude Code from npm (ensures we get the latest version)
install_result = await sandbox.commands.run(
"npm install -g @anthropic-ai/claude-code@latest",
timeout=120, # 2 min timeout for install
)
if install_result.exit_code != 0:
raise Exception(
f"Failed to install Claude Code: {install_result.stderr}"
)
# Run any user-provided setup commands
for cmd in setup_commands:
setup_result = await sandbox.commands.run(cmd)
if setup_result.exit_code != 0:
raise Exception(
f"Setup command failed: {cmd}\n"
f"Exit code: {setup_result.exit_code}\n"
f"Stdout: {setup_result.stdout}\n"
f"Stderr: {setup_result.stderr}"
)
# Generate or use provided session ID
current_session_id = session_id if session_id else str(uuid.uuid4())
# Build base Claude flags
base_flags = "-p --dangerously-skip-permissions --output-format json"
# Add conversation history context if provided (for fresh sandbox continuation)
history_flag = ""
if conversation_history and not session_id:
# Inject previous conversation as context via system prompt
# Use consistent escaping via _escape_prompt helper
escaped_history = self._escape_prompt(
f"Previous conversation context: {conversation_history}"
)
history_flag = f" --append-system-prompt {escaped_history}"
# Build Claude command based on whether we're resuming or starting new
# Use shlex.quote for working_directory and session IDs to prevent injection
safe_working_dir = shlex.quote(working_directory)
if session_id:
# Resuming existing session (sandbox still alive)
safe_session_id = shlex.quote(session_id)
claude_command = (
f"cd {safe_working_dir} && "
f"echo {self._escape_prompt(prompt)} | "
f"claude --resume {safe_session_id} {base_flags}"
)
else:
# New session with specific ID
safe_current_session_id = shlex.quote(current_session_id)
claude_command = (
f"cd {safe_working_dir} && "
f"echo {self._escape_prompt(prompt)} | "
f"claude --session-id {safe_current_session_id} {base_flags}{history_flag}"
)
# Capture timestamp before running Claude Code to filter files later
# Capture timestamp 1 second in the past to avoid race condition with file creation
timestamp_result = await sandbox.commands.run(
"date -u -d '1 second ago' +%Y-%m-%dT%H:%M:%S"
)
if timestamp_result.exit_code != 0:
raise RuntimeError(
f"Failed to capture timestamp: {timestamp_result.stderr}"
)
start_timestamp = (
timestamp_result.stdout.strip() if timestamp_result.stdout else None
)
result = await sandbox.commands.run(
claude_command,
timeout=0, # No command timeout - let sandbox timeout handle it
)
# Check for command failure
if result.exit_code != 0:
error_msg = result.stderr or result.stdout or "Unknown error"
raise Exception(
f"Claude Code command failed with exit code {result.exit_code}:\n"
f"{error_msg}"
)
raw_output = result.stdout or ""
sandbox_id = sandbox.sandbox_id
# Parse JSON output to extract response and build conversation history
response = ""
new_conversation_history = conversation_history or ""
try:
# The JSON output contains the result
output_data = json.loads(raw_output)
response = output_data.get("result", raw_output)
# Build conversation history entry
turn_entry = f"User: {prompt}\nClaude: {response}"
if new_conversation_history:
new_conversation_history = (
f"{new_conversation_history}\n\n{turn_entry}"
)
else:
new_conversation_history = turn_entry
except json.JSONDecodeError:
# If not valid JSON, use raw output
response = raw_output
turn_entry = f"User: {prompt}\nClaude: {response}"
if new_conversation_history:
new_conversation_history = (
f"{new_conversation_history}\n\n{turn_entry}"
)
else:
new_conversation_history = turn_entry
# Extract files created/modified during this run
files = await self._extract_files(
sandbox, working_directory, start_timestamp
)
return (
response,
files,
new_conversation_history,
current_session_id,
sandbox_id,
)
finally:
if dispose_sandbox and sandbox:
await sandbox.kill()
async def _extract_files(
self,
sandbox: BaseAsyncSandbox,
working_directory: str,
since_timestamp: str | None = None,
) -> list["ClaudeCodeBlock.FileOutput"]:
"""
Extract text files created/modified during this Claude Code execution.
Args:
sandbox: The E2B sandbox instance
working_directory: Directory to search for files
since_timestamp: ISO timestamp - only return files modified after this time
Returns:
List of FileOutput objects with path, relative_path, name, and content
"""
files: list[ClaudeCodeBlock.FileOutput] = []
# Text file extensions we can safely read as text
text_extensions = {
".txt",
".md",
".html",
".htm",
".css",
".js",
".ts",
".jsx",
".tsx",
".json",
".xml",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".py",
".rb",
".php",
".java",
".c",
".cpp",
".h",
".hpp",
".cs",
".go",
".rs",
".swift",
".kt",
".scala",
".sh",
".bash",
".zsh",
".sql",
".graphql",
".env",
".gitignore",
".dockerfile",
"Dockerfile",
".vue",
".svelte",
".astro",
".mdx",
".rst",
".tex",
".csv",
".log",
}
try:
# List files recursively using find command
# Exclude node_modules and .git directories, but allow hidden files
# like .env and .gitignore (they're filtered by text_extensions later)
# Filter by timestamp to only get files created/modified during this run
safe_working_dir = shlex.quote(working_directory)
timestamp_filter = ""
if since_timestamp:
timestamp_filter = f"-newermt {shlex.quote(since_timestamp)} "
find_result = await sandbox.commands.run(
f"find {safe_working_dir} -type f "
f"{timestamp_filter}"
f"-not -path '*/node_modules/*' "
f"-not -path '*/.git/*' "
f"2>/dev/null"
)
if find_result.stdout:
for file_path in find_result.stdout.strip().split("\n"):
if not file_path:
continue
# Check if it's a text file we can read
is_text = any(
file_path.endswith(ext) for ext in text_extensions
) or file_path.endswith("Dockerfile")
if is_text:
try:
content = await sandbox.files.read(file_path)
# Handle bytes or string
if isinstance(content, bytes):
content = content.decode("utf-8", errors="replace")
# Extract filename from path
file_name = file_path.split("/")[-1]
# Calculate relative path by stripping working directory
relative_path = file_path
if file_path.startswith(working_directory):
relative_path = file_path[len(working_directory) :]
# Remove leading slash if present
if relative_path.startswith("/"):
relative_path = relative_path[1:]
files.append(
ClaudeCodeBlock.FileOutput(
path=file_path,
relative_path=relative_path,
name=file_name,
content=content,
)
)
except Exception:
# Skip files that can't be read
pass
except Exception:
# If file extraction fails, return empty results
pass
return files
def _escape_prompt(self, prompt: str) -> str:
"""Escape the prompt for safe shell execution."""
# Use single quotes and escape any single quotes in the prompt
escaped = prompt.replace("'", "'\"'\"'")
return f"'{escaped}'"
async def run(
self,
input_data: Input,
*,
e2b_credentials: APIKeyCredentials,
anthropic_credentials: APIKeyCredentials,
**kwargs,
) -> BlockOutput:
try:
(
response,
files,
conversation_history,
session_id,
sandbox_id,
) = await self.execute_claude_code(
e2b_api_key=e2b_credentials.api_key.get_secret_value(),
anthropic_api_key=anthropic_credentials.api_key.get_secret_value(),
prompt=input_data.prompt,
timeout=input_data.timeout,
setup_commands=input_data.setup_commands,
working_directory=input_data.working_directory,
session_id=input_data.session_id,
existing_sandbox_id=input_data.sandbox_id,
conversation_history=input_data.conversation_history,
dispose_sandbox=input_data.dispose_sandbox,
)
yield "response", response
# Always yield files (empty list if none) to match Output schema
yield "files", [f.model_dump() for f in files]
# Always yield conversation_history so user can restore context on fresh sandbox
yield "conversation_history", conversation_history
# Always yield session_id so user can continue conversation
yield "session_id", session_id
# Always yield sandbox_id (None if disposed) to match Output schema
yield "sandbox_id", sandbox_id if not input_data.dispose_sandbox else None
except Exception as e:
yield "error", str(e)

View File

@@ -81,16 +81,18 @@ export const RunInputDialog = ({
Inputs Inputs
</Text> </Text>
</div> </div>
<FormRenderer <div className="px-2">
jsonSchema={inputSchema as RJSFSchema} <FormRenderer
handleChange={(v) => handleInputChange(v.formData)} jsonSchema={inputSchema as RJSFSchema}
uiSchema={uiSchema} handleChange={(v) => handleInputChange(v.formData)}
initialValues={{}} uiSchema={uiSchema}
formContext={{ initialValues={{}}
showHandles: false, formContext={{
size: "large", showHandles: false,
}} size: "large",
/> }}
/>
</div>
</div> </div>
)} )}

View File

@@ -3,7 +3,6 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
import { import {
useGetV1GetExecutionDetails, useGetV1GetExecutionDetails,
useGetV1GetSpecificGraph, useGetV1GetSpecificGraph,
useGetV1ListUserGraphs,
} from "@/app/api/__generated__/endpoints/graphs/graphs"; } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo"; import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { GraphModel } from "@/app/api/__generated__/models/graphModel"; import { GraphModel } from "@/app/api/__generated__/models/graphModel";
@@ -18,7 +17,6 @@ import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore"; import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { useHistoryStore } from "../../../stores/historyStore"; import { useHistoryStore } from "../../../stores/historyStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { okData } from "@/app/api/helpers";
export const useFlow = () => { export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false); const [isLocked, setIsLocked] = useState(false);
@@ -38,9 +36,6 @@ export const useFlow = () => {
const setGraphExecutionStatus = useGraphStore( const setGraphExecutionStatus = useGraphStore(
useShallow((state) => state.setGraphExecutionStatus), useShallow((state) => state.setGraphExecutionStatus),
); );
const setAvailableSubGraphs = useGraphStore(
useShallow((state) => state.setAvailableSubGraphs),
);
const updateEdgeBeads = useEdgeStore( const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads), useShallow((state) => state.updateEdgeBeads),
); );
@@ -67,11 +62,6 @@ export const useFlow = () => {
}, },
); );
// Fetch all available graphs for sub-agent update detection
const { data: availableGraphs } = useGetV1ListUserGraphs({
query: { select: okData },
});
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph( const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
flowID ?? "", flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {}, flowVersion !== null ? { version: flowVersion } : {},
@@ -126,18 +116,10 @@ export const useFlow = () => {
} }
}, [graph]); }, [graph]);
// Update available sub-graphs in store for sub-agent update detection
useEffect(() => {
if (availableGraphs) {
setAvailableSubGraphs(availableGraphs);
}
}, [availableGraphs, setAvailableSubGraphs]);
// adding nodes // adding nodes
useEffect(() => { useEffect(() => {
if (customNodes.length > 0) { if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]); useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState();
addNodes(customNodes); addNodes(customNodes);
// Sync hardcoded values with handle IDs. // Sync hardcoded values with handle IDs.
@@ -221,7 +203,6 @@ export const useFlow = () => {
useEffect(() => { useEffect(() => {
return () => { return () => {
useNodeStore.getState().setNodes([]); useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState();
useEdgeStore.getState().setEdges([]); useEdgeStore.getState().setEdges([]);
useGraphStore.getState().reset(); useGraphStore.getState().reset();
useEdgeStore.getState().resetEdgeBeads(); useEdgeStore.getState().resetEdgeBeads();

View File

@@ -8,7 +8,6 @@ import {
getBezierPath, getBezierPath,
} from "@xyflow/react"; } from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { XIcon } from "@phosphor-icons/react"; import { XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NodeExecutionResult } from "@/lib/autogpt-server-api"; import { NodeExecutionResult } from "@/lib/autogpt-server-api";
@@ -36,8 +35,6 @@ const CustomEdge = ({
selected, selected,
}: EdgeProps<CustomEdge>) => { }: EdgeProps<CustomEdge>) => {
const removeConnection = useEdgeStore((state) => state.removeEdge); const removeConnection = useEdgeStore((state) => state.removeEdge);
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [edgePath, labelX, labelY] = getBezierPath({ const [edgePath, labelX, labelY] = getBezierPath({
@@ -53,12 +50,6 @@ const CustomEdge = ({
const beadUp = data?.beadUp ?? 0; const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0; const beadDown = data?.beadDown ?? 0;
const handleRemoveEdge = () => {
removeConnection(id);
// Note: broken edge tracking is cleaned up automatically by useSubAgentUpdateState
// when it detects the edge no longer exists
};
return ( return (
<> <>
<BaseEdge <BaseEdge
@@ -66,11 +57,9 @@ const CustomEdge = ({
markerEnd={markerEnd} markerEnd={markerEnd}
className={cn( className={cn(
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]", isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
isBroken selected
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]" ? "stroke-zinc-800"
: selected : "stroke-zinc-500/50 hover:stroke-zinc-500",
? "stroke-zinc-800"
: "stroke-zinc-500/50 hover:stroke-zinc-500",
)} )}
/> />
<JSBeads <JSBeads
@@ -81,16 +70,12 @@ const CustomEdge = ({
/> />
<EdgeLabelRenderer> <EdgeLabelRenderer>
<Button <Button
onClick={handleRemoveEdge} onClick={() => removeConnection(id)}
className={cn( className={cn(
"absolute h-fit min-w-0 p-1 transition-opacity", "absolute h-fit min-w-0 p-1 transition-opacity",
isBroken isHovered ? "opacity-100" : "opacity-0",
? "bg-red-500 opacity-100 hover:bg-red-600"
: isHovered
? "opacity-100"
: "opacity-0",
)} )}
variant={isBroken ? "primary" : "secondary"} variant="secondary"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: "all", pointerEvents: "all",

View File

@@ -3,7 +3,6 @@ import { Handle, Position } from "@xyflow/react";
import { useEdgeStore } from "../../../stores/edgeStore"; import { useEdgeStore } from "../../../stores/edgeStore";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers"; import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useNodeStore } from "../../../stores/nodeStore";
const InputNodeHandle = ({ const InputNodeHandle = ({
handleId, handleId,
@@ -16,9 +15,6 @@ const InputNodeHandle = ({
const isInputConnected = useEdgeStore((state) => const isInputConnected = useEdgeStore((state) =>
state.isInputConnected(nodeId ?? "", cleanedHandleId), state.isInputConnected(nodeId ?? "", cleanedHandleId),
); );
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanedHandleId),
);
return ( return (
<Handle <Handle
@@ -31,10 +27,7 @@ const InputNodeHandle = ({
<CircleIcon <CircleIcon
size={16} size={16}
weight={isInputConnected ? "fill" : "duotone"} weight={isInputConnected ? "fill" : "duotone"}
className={cn( className={"text-gray-400 opacity-100"}
"text-gray-400 opacity-100",
isInputBroken && "text-red-500",
)}
/> />
</div> </div>
</Handle> </Handle>
@@ -45,17 +38,14 @@ const OutputNodeHandle = ({
field_name, field_name,
nodeId, nodeId,
hexColor, hexColor,
isBroken,
}: { }: {
field_name: string; field_name: string;
nodeId: string; nodeId: string;
hexColor: string; hexColor: string;
isBroken: boolean;
}) => { }) => {
const isOutputConnected = useEdgeStore((state) => const isOutputConnected = useEdgeStore((state) =>
state.isOutputConnected(nodeId, field_name), state.isOutputConnected(nodeId, field_name),
); );
return ( return (
<Handle <Handle
type={"source"} type={"source"}
@@ -68,10 +58,7 @@ const OutputNodeHandle = ({
size={16} size={16}
weight={"duotone"} weight={"duotone"}
color={isOutputConnected ? hexColor : "gray"} color={isOutputConnected ? hexColor : "gray"}
className={cn( className={cn("text-gray-400 opacity-100")}
"text-gray-400 opacity-100",
isBroken && "text-red-500",
)}
/> />
</div> </div>
</Handle> </Handle>

View File

@@ -20,8 +20,6 @@ import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
import { NodeRightClickMenu } from "./components/NodeRightClickMenu"; import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
import { StickyNoteBlock } from "./components/StickyNoteBlock"; import { StickyNoteBlock } from "./components/StickyNoteBlock";
import { WebhookDisclaimer } from "./components/WebhookDisclaimer"; import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
import { useCustomNode } from "./useCustomNode";
export type CustomNodeData = { export type CustomNodeData = {
hardcodedValues: { hardcodedValues: {
@@ -47,10 +45,6 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo( export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
({ data, id: nodeId, selected }) => { ({ data, id: nodeId, selected }) => {
const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
const isAgent = data.uiType === BlockUIType.AGENT;
if (data.uiType === BlockUIType.NOTE) { if (data.uiType === BlockUIType.NOTE) {
return ( return (
<StickyNoteBlock data={data} selected={selected} nodeId={nodeId} /> <StickyNoteBlock data={data} selected={selected} nodeId={nodeId} />
@@ -69,6 +63,16 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
const isAyrshare = data.uiType === BlockUIType.AYRSHARE; const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
const inputSchema =
data.uiType === BlockUIType.AGENT
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
const outputSchema =
data.uiType === BlockUIType.AGENT
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
const hasConfigErrors = const hasConfigErrors =
data.errors && data.errors &&
Object.values(data.errors).some( Object.values(data.errors).some(
@@ -83,11 +87,12 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
const hasErrors = hasConfigErrors || hasOutputError; const hasErrors = hasConfigErrors || hasOutputError;
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
const node = ( const node = (
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}> <NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
<div className="rounded-xlarge bg-white"> <div className="rounded-xlarge bg-white">
<NodeHeader data={data} nodeId={nodeId} /> <NodeHeader data={data} nodeId={nodeId} />
{isAgent && <SubAgentUpdateFeature nodeID={nodeId} nodeData={data} />}
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />} {isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
{isAyrshare && <AyrshareConnectButton />} {isAyrshare && <AyrshareConnectButton />}
<FormCreator <FormCreator

View File

@@ -1,118 +0,0 @@
import React from "react";
import { ArrowUpIcon, WarningIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { cn, beautifyString } from "@/lib/utils";
import { CustomNodeData } from "../../CustomNode";
import { useSubAgentUpdateState } from "./useSubAgentUpdateState";
import { IncompatibleUpdateDialog } from "./components/IncompatibleUpdateDialog";
import { ResolutionModeBar } from "./components/ResolutionModeBar";
/**
* Inline component for the update bar that can be placed after the header.
* Use this inside the node content where you want the bar to appear.
*/
type SubAgentUpdateFeatureProps = {
nodeID: string;
nodeData: CustomNodeData;
};
export function SubAgentUpdateFeature({
nodeID,
nodeData,
}: SubAgentUpdateFeatureProps) {
const {
updateInfo,
isInResolutionMode,
handleUpdateClick,
showIncompatibilityDialog,
setShowIncompatibilityDialog,
handleConfirmIncompatibleUpdate,
} = useSubAgentUpdateState({ nodeID: nodeID, nodeData: nodeData });
const agentName = nodeData.title || "Agent";
if (!updateInfo.hasUpdate && !isInResolutionMode) {
return null;
}
return (
<>
{isInResolutionMode ? (
<ResolutionModeBar incompatibilities={updateInfo.incompatibilities} />
) : (
<SubAgentUpdateAvailableBar
currentVersion={updateInfo.currentVersion}
latestVersion={updateInfo.latestVersion}
isCompatible={updateInfo.isCompatible}
onUpdate={handleUpdateClick}
/>
)}
{/* Incompatibility dialog - rendered here since this component owns the state */}
{updateInfo.incompatibilities && (
<IncompatibleUpdateDialog
isOpen={showIncompatibilityDialog}
onClose={() => setShowIncompatibilityDialog(false)}
onConfirm={handleConfirmIncompatibleUpdate}
currentVersion={updateInfo.currentVersion}
latestVersion={updateInfo.latestVersion}
agentName={beautifyString(agentName)}
incompatibilities={updateInfo.incompatibilities}
/>
)}
</>
);
}
type SubAgentUpdateAvailableBarProps = {
currentVersion: number;
latestVersion: number;
isCompatible: boolean;
onUpdate: () => void;
};
function SubAgentUpdateAvailableBar({
currentVersion,
latestVersion,
isCompatible,
onUpdate,
}: SubAgentUpdateAvailableBarProps): React.ReactElement {
return (
<div className="flex items-center justify-between gap-2 rounded-t-xl bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
<div className="flex items-center gap-2">
<ArrowUpIcon className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-blue-700 dark:text-blue-300">
Update available (v{currentVersion} v{latestVersion})
</span>
{!isCompatible && (
<Tooltip>
<TooltipTrigger asChild>
<WarningIcon className="h-4 w-4 text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-medium">Incompatible changes detected</p>
<p className="text-xs text-gray-400">
Click Update to see details
</p>
</TooltipContent>
</Tooltip>
)}
</div>
<Button
size="small"
variant={isCompatible ? "primary" : "outline"}
onClick={onUpdate}
className={cn(
"h-7 text-xs",
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
)}
>
Update
</Button>
</div>
);
}

View File

@@ -1,274 +0,0 @@
import React from "react";
import {
WarningIcon,
XCircleIcon,
PlusCircleIcon,
} from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
type IncompatibleUpdateDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
currentVersion: number;
latestVersion: number;
agentName: string;
incompatibilities: IncompatibilityInfo;
};
export function IncompatibleUpdateDialog({
isOpen,
onClose,
onConfirm,
currentVersion,
latestVersion,
agentName,
incompatibilities,
}: IncompatibleUpdateDialogProps) {
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
const hasNewInputs = incompatibilities.newInputs.length > 0;
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
const hasInputChanges = hasMissingInputs || hasNewInputs;
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
return (
<Dialog
title={
<div className="flex items-center gap-2">
<WarningIcon className="h-5 w-5 text-amber-500" weight="fill" />
Incompatible Update
</div>
}
controlled={{
isOpen,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "32rem" }}
>
<Dialog.Content>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Updating <strong>{beautifyString(agentName)}</strong> from v
{currentVersion} to v{latestVersion} will break some connections.
</p>
{/* Input changes - two column layout */}
{hasInputChanges && (
<TwoColumnSection
title="Input Changes"
leftIcon={
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
}
leftTitle="Removed"
leftItems={incompatibilities.missingInputs}
rightIcon={
<PlusCircleIcon
className="h-4 w-4 text-green-500"
weight="fill"
/>
}
rightTitle="Added"
rightItems={incompatibilities.newInputs}
/>
)}
{/* Output changes - two column layout */}
{hasOutputChanges && (
<TwoColumnSection
title="Output Changes"
leftIcon={
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
}
leftTitle="Removed"
leftItems={incompatibilities.missingOutputs}
rightIcon={
<PlusCircleIcon
className="h-4 w-4 text-green-500"
weight="fill"
/>
}
rightTitle="Added"
rightItems={incompatibilities.newOutputs}
/>
)}
{hasTypeMismatches && (
<SingleColumnSection
icon={
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
}
title="Type Changed"
description="These connected inputs have a different type:"
items={incompatibilities.inputTypeMismatches.map(
(m) => `${m.name} (${m.oldType}${m.newType})`,
)}
/>
)}
{hasNewRequired && (
<SingleColumnSection
icon={
<PlusCircleIcon
className="h-4 w-4 text-amber-500"
weight="fill"
/>
}
title="New Required Inputs"
description="These inputs are now required:"
items={incompatibilities.newRequiredInputs}
/>
)}
<Alert variant="warning">
<AlertDescription>
If you proceed, you&apos;ll need to remove the broken connections
before you can save or run your agent.
</AlertDescription>
</Alert>
<Dialog.Footer>
<Button variant="ghost" size="small" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={onConfirm}
className="border-amber-700 bg-amber-600 hover:bg-amber-700"
>
Update Anyway
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
);
}
type TwoColumnSectionProps = {
title: string;
leftIcon: React.ReactNode;
leftTitle: string;
leftItems: string[];
rightIcon: React.ReactNode;
rightTitle: string;
rightItems: string[];
};
function TwoColumnSection({
title,
leftIcon,
leftTitle,
leftItems,
rightIcon,
rightTitle,
rightItems,
}: TwoColumnSectionProps) {
return (
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
<span className="font-medium">{title}</span>
<div className="mt-2 grid grid-cols-2 items-start gap-4">
{/* Left column - Breaking changes */}
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
{leftIcon}
<span>{leftTitle}</span>
</div>
<ul className="mt-1.5 space-y-1">
{leftItems.length > 0 ? (
leftItems.map((item) => (
<li
key={item}
className="text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
{item}
</code>
</li>
))
) : (
<li className="text-sm italic text-gray-400 dark:text-gray-500">
None
</li>
)}
</ul>
</div>
{/* Right column - Possible solutions */}
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
{rightIcon}
<span>{rightTitle}</span>
</div>
<ul className="mt-1.5 space-y-1">
{rightItems.length > 0 ? (
rightItems.map((item) => (
<li
key={item}
className="text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
{item}
</code>
</li>
))
) : (
<li className="text-sm italic text-gray-400 dark:text-gray-500">
None
</li>
)}
</ul>
</div>
</div>
</div>
);
}
type SingleColumnSectionProps = {
icon: React.ReactNode;
title: string;
description: string;
items: string[];
};
function SingleColumnSection({
icon,
title,
description,
items,
}: SingleColumnSectionProps) {
return (
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center gap-2">
{icon}
<span className="font-medium">{title}</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<ul className="mt-2 space-y-1">
{items.map((item) => (
<li
key={item}
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
{item}
</code>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import React from "react";
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
type ResolutionModeBarProps = {
incompatibilities: IncompatibilityInfo | null;
};
export function ResolutionModeBar({
incompatibilities,
}: ResolutionModeBarProps): React.ReactElement {
const renderIncompatibilities = () => {
if (!incompatibilities) return <span>No incompatibilities</span>;
const sections: React.ReactNode[] = [];
if (incompatibilities.missingInputs.length > 0) {
sections.push(
<div key="missing-inputs" className="mb-1">
<span className="font-semibold">Missing inputs: </span>
{incompatibilities.missingInputs.map((name, i) => (
<React.Fragment key={name}>
<code className="font-mono">{name}</code>
{i < incompatibilities.missingInputs.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
if (incompatibilities.missingOutputs.length > 0) {
sections.push(
<div key="missing-outputs" className="mb-1">
<span className="font-semibold">Missing outputs: </span>
{incompatibilities.missingOutputs.map((name, i) => (
<React.Fragment key={name}>
<code className="font-mono">{name}</code>
{i < incompatibilities.missingOutputs.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
if (incompatibilities.newRequiredInputs.length > 0) {
sections.push(
<div key="new-required" className="mb-1">
<span className="font-semibold">New required inputs: </span>
{incompatibilities.newRequiredInputs.map((name, i) => (
<React.Fragment key={name}>
<code className="font-mono">{name}</code>
{i < incompatibilities.newRequiredInputs.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
if (incompatibilities.inputTypeMismatches.length > 0) {
sections.push(
<div key="type-mismatches" className="mb-1">
<span className="font-semibold">Type changed: </span>
{incompatibilities.inputTypeMismatches.map((m, i) => (
<React.Fragment key={m.name}>
<code className="font-mono">{m.name}</code>
<span className="text-gray-400">
{" "}
({m.oldType} {m.newType})
</span>
{i < incompatibilities.inputTypeMismatches.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
return <>{sections}</>;
};
return (
<div className="flex items-center justify-between gap-2 rounded-t-xl bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
<div className="flex items-center gap-2">
<WarningIcon className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-700 dark:text-amber-300">
Remove incompatible connections
</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-4 w-4 cursor-help text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-sm">
<p className="mb-2 font-semibold">Incompatible changes:</p>
<div className="text-xs">{renderIncompatibilities()}</div>
<p className="mt-2 text-xs text-gray-400">
{(incompatibilities?.newRequiredInputs.length ?? 0) > 0
? "Replace / delete"
: "Delete"}{" "}
the red connections to continue
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}

View File

@@ -1,194 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import {
useNodeStore,
NodeResolutionData,
} from "@/app/(platform)/build/stores/nodeStore";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import {
useSubAgentUpdate,
createUpdatedAgentNodeInputs,
getBrokenEdgeIDs,
} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { CustomNodeData } from "../../CustomNode";
// Stable empty set to avoid creating new references in selectors
const EMPTY_SET: Set<string> = new Set();
type UseSubAgentUpdateParams = {
nodeID: string;
nodeData: CustomNodeData;
};
export function useSubAgentUpdateState({
nodeID,
nodeData,
}: UseSubAgentUpdateParams) {
const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
useState(false);
// Get store actions
const updateNodeData = useNodeStore(
useShallow((state) => state.updateNodeData),
);
const setNodeResolutionMode = useNodeStore(
useShallow((state) => state.setNodeResolutionMode),
);
const isNodeInResolutionMode = useNodeStore(
useShallow((state) => state.isNodeInResolutionMode),
);
const setBrokenEdgeIDs = useNodeStore(
useShallow((state) => state.setBrokenEdgeIDs),
);
// Get this node's broken edge IDs from the per-node map
// Use EMPTY_SET as fallback to maintain referential stability
const brokenEdgeIDs = useNodeStore(
(state) => state.brokenEdgeIDs.get(nodeID) || EMPTY_SET,
);
const getNodeResolutionData = useNodeStore(
useShallow((state) => state.getNodeResolutionData),
);
const connectedEdges = useEdgeStore(
useShallow((state) => state.getNodeEdges(nodeID)),
);
const availableSubGraphs = useGraphStore(
useShallow((state) => state.availableSubGraphs),
);
// Extract agent-specific data
const graphID = nodeData.hardcodedValues?.graph_id as string | undefined;
const graphVersion = nodeData.hardcodedValues?.graph_version as
| number
| undefined;
const currentInputSchema = nodeData.hardcodedValues?.input_schema as
| GraphInputSchema
| undefined;
const currentOutputSchema = nodeData.hardcodedValues?.output_schema as
| GraphOutputSchema
| undefined;
// Use the sub-agent update hook
const updateInfo = useSubAgentUpdate(
nodeID,
graphID,
graphVersion,
currentInputSchema,
currentOutputSchema,
connectedEdges,
availableSubGraphs,
);
const isInResolutionMode = isNodeInResolutionMode(nodeID);
// Handle update button click
const handleUpdateClick = useCallback(() => {
if (!updateInfo.hasUpdate || !updateInfo.latestGraph) return;
if (updateInfo.isCompatible) {
// Compatible update - apply directly
const newHardcodedValues = createUpdatedAgentNodeInputs(
nodeData.hardcodedValues,
updateInfo.latestGraph,
);
updateNodeData(nodeID, { hardcodedValues: newHardcodedValues });
} else {
// Incompatible update - show dialog
setShowIncompatibilityDialog(true);
}
}, [
updateInfo.hasUpdate,
updateInfo.latestGraph,
updateInfo.isCompatible,
nodeData.hardcodedValues,
updateNodeData,
nodeID,
]);
// Handle confirming an incompatible update
function handleConfirmIncompatibleUpdate() {
if (!updateInfo.latestGraph || !updateInfo.incompatibilities) return;
const latestGraph = updateInfo.latestGraph;
// Get the new schemas from the latest graph version
const newInputSchema =
(latestGraph.input_schema as Record<string, unknown>) || {};
const newOutputSchema =
(latestGraph.output_schema as Record<string, unknown>) || {};
// Create the updated hardcoded values but DON'T apply them yet
// We'll apply them when resolution is complete
const pendingHardcodedValues = createUpdatedAgentNodeInputs(
nodeData.hardcodedValues,
latestGraph,
);
// Get broken edge IDs and store them for this node
const brokenIds = getBrokenEdgeIDs(
connectedEdges,
updateInfo.incompatibilities,
nodeID,
);
setBrokenEdgeIDs(nodeID, brokenIds);
// Enter resolution mode with both old and new schemas
// DON'T apply the update yet - keep old schema so connections remain visible
const resolutionData: NodeResolutionData = {
incompatibilities: updateInfo.incompatibilities,
pendingUpdate: {
input_schema: newInputSchema,
output_schema: newOutputSchema,
},
currentSchema: {
input_schema: (currentInputSchema as Record<string, unknown>) || {},
output_schema: (currentOutputSchema as Record<string, unknown>) || {},
},
pendingHardcodedValues,
};
setNodeResolutionMode(nodeID, true, resolutionData);
setShowIncompatibilityDialog(false);
}
// Check if resolution is complete (all broken edges removed)
const resolutionData = getNodeResolutionData(nodeID);
// Auto-check resolution on edge changes
useEffect(() => {
if (!isInResolutionMode) return;
// Check if any broken edges still exist
const remainingBroken = Array.from(brokenEdgeIDs).filter((edgeId) =>
connectedEdges.some((e) => e.id === edgeId),
);
if (remainingBroken.length === 0) {
// Resolution complete - now apply the pending update
if (resolutionData?.pendingHardcodedValues) {
updateNodeData(nodeID, {
hardcodedValues: resolutionData.pendingHardcodedValues,
});
}
// setNodeResolutionMode will clean up this node's broken edges automatically
setNodeResolutionMode(nodeID, false);
}
}, [
isInResolutionMode,
brokenEdgeIDs,
connectedEdges,
resolutionData,
nodeID,
]);
return {
updateInfo,
isInResolutionMode,
resolutionData,
showIncompatibilityDialog,
setShowIncompatibilityDialog,
handleUpdateClick,
handleConfirmIncompatibleUpdate,
};
}

View File

@@ -1,6 +1,4 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore";
import { RJSFSchema } from "@rjsf/utils";
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = { export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
INCOMPLETE: "ring-slate-300 bg-slate-300", INCOMPLETE: "ring-slate-300 bg-slate-300",
@@ -11,48 +9,3 @@ export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
TERMINATED: "ring-orange-300 bg-orange-300 ", TERMINATED: "ring-orange-300 bg-orange-300 ",
FAILED: "ring-red-300 bg-red-300", FAILED: "ring-red-300 bg-red-300",
}; };
/**
* Merges schemas during resolution mode to include removed inputs/outputs
* that still have connections, so users can see and delete them.
*/
export function mergeSchemaForResolution(
currentSchema: Record<string, unknown>,
newSchema: Record<string, unknown>,
resolutionData: NodeResolutionData,
type: "input" | "output",
): Record<string, unknown> {
const newProps = (newSchema.properties as RJSFSchema) || {};
const currentProps = (currentSchema.properties as RJSFSchema) || {};
const mergedProps = { ...newProps };
const incomp = resolutionData.incompatibilities;
if (type === "input") {
// Add back missing inputs that have connections
incomp.missingInputs.forEach((inputName: string) => {
if (currentProps[inputName]) {
mergedProps[inputName] = currentProps[inputName];
}
});
// Add back inputs with type mismatches (keep old type so connection works visually)
incomp.inputTypeMismatches.forEach(
(mismatch: { name: string; oldType: string; newType: string }) => {
if (currentProps[mismatch.name]) {
mergedProps[mismatch.name] = currentProps[mismatch.name];
}
},
);
} else {
// Add back missing outputs that have connections
incomp.missingOutputs.forEach((outputName: string) => {
if (currentProps[outputName]) {
mergedProps[outputName] = currentProps[outputName];
}
});
}
return {
...newSchema,
properties: mergedProps,
};
}

View File

@@ -1,58 +0,0 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { CustomNodeData } from "./CustomNode";
import { BlockUIType } from "../../../types";
import { useMemo } from "react";
import { mergeSchemaForResolution } from "./helpers";
export const useCustomNode = ({
data,
nodeId,
}: {
data: CustomNodeData;
nodeId: string;
}) => {
const isInResolutionMode = useNodeStore((state) =>
state.nodesInResolutionMode.has(nodeId),
);
const resolutionData = useNodeStore((state) =>
state.nodeResolutionData.get(nodeId),
);
const isAgent = data.uiType === BlockUIType.AGENT;
const currentInputSchema = isAgent
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
const currentOutputSchema = isAgent
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
const inputSchema = useMemo(() => {
if (isAgent && isInResolutionMode && resolutionData) {
return mergeSchemaForResolution(
resolutionData.currentSchema.input_schema,
resolutionData.pendingUpdate.input_schema,
resolutionData,
"input",
);
}
return currentInputSchema;
}, [isAgent, isInResolutionMode, resolutionData, currentInputSchema]);
const outputSchema = useMemo(() => {
if (isAgent && isInResolutionMode && resolutionData) {
return mergeSchemaForResolution(
resolutionData.currentSchema.output_schema,
resolutionData.pendingUpdate.output_schema,
resolutionData,
"output",
);
}
return currentOutputSchema;
}, [isAgent, isInResolutionMode, resolutionData, currentOutputSchema]);
return {
inputSchema,
outputSchema,
};
};

View File

@@ -5,16 +5,20 @@ import { useNodeStore } from "../../../stores/nodeStore";
import { BlockUIType } from "../../types"; import { BlockUIType } from "../../types";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer"; import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
interface FormCreatorProps { export const FormCreator = React.memo(
jsonSchema: RJSFSchema; ({
nodeId: string; jsonSchema,
uiType: BlockUIType; nodeId,
showHandles?: boolean; uiType,
className?: string; showHandles = true,
} className,
}: {
export const FormCreator: React.FC<FormCreatorProps> = React.memo( jsonSchema: RJSFSchema;
({ jsonSchema, nodeId, uiType, showHandles = true, className }) => { nodeId: string;
uiType: BlockUIType;
showHandles?: boolean;
className?: string;
}) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData); const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore( const getHardCodedValues = useNodeStore(

View File

@@ -14,8 +14,6 @@ import {
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { getTypeDisplayInfo } from "./helpers"; import { getTypeDisplayInfo } from "./helpers";
import { BlockUIType } from "../../types"; import { BlockUIType } from "../../types";
import { cn } from "@/lib/utils";
import { useBrokenOutputs } from "./useBrokenOutputs";
export const OutputHandler = ({ export const OutputHandler = ({
outputSchema, outputSchema,
@@ -29,9 +27,6 @@ export const OutputHandler = ({
const { isOutputConnected } = useEdgeStore(); const { isOutputConnected } = useEdgeStore();
const properties = outputSchema?.properties || {}; const properties = outputSchema?.properties || {};
const [isOutputVisible, setIsOutputVisible] = useState(true); const [isOutputVisible, setIsOutputVisible] = useState(true);
const brokenOutputs = useBrokenOutputs(nodeId);
console.log("brokenOutputs", brokenOutputs);
const showHandles = uiType !== BlockUIType.OUTPUT; const showHandles = uiType !== BlockUIType.OUTPUT;
@@ -49,7 +44,6 @@ export const OutputHandler = ({
const shouldShow = isConnected || isOutputVisible; const shouldShow = isConnected || isOutputVisible;
const { displayType, colorClass, hexColor } = const { displayType, colorClass, hexColor } =
getTypeDisplayInfo(fieldSchema); getTypeDisplayInfo(fieldSchema);
const isBroken = brokenOutputs.has(fullKey);
return shouldShow ? ( return shouldShow ? (
<div key={fullKey} className="flex flex-col items-end gap-2"> <div key={fullKey} className="flex flex-col items-end gap-2">
@@ -70,29 +64,15 @@ export const OutputHandler = ({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
<Text <Text variant="body" className="text-slate-700">
variant="body"
className={cn(
"text-slate-700",
isBroken && "text-red-500 line-through",
)}
>
{fieldTitle} {fieldTitle}
</Text> </Text>
<Text <Text variant="small" as="span" className={colorClass}>
variant="small"
as="span"
className={cn(
colorClass,
isBroken && "!text-red-500 line-through",
)}
>
({displayType}) ({displayType})
</Text> </Text>
{showHandles && ( {showHandles && (
<OutputNodeHandle <OutputNodeHandle
isBroken={isBroken}
field_name={fullKey} field_name={fullKey}
nodeId={nodeId} nodeId={nodeId}
hexColor={hexColor} hexColor={hexColor}

View File

@@ -1,23 +0,0 @@
import { useMemo } from "react";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
/**
* Hook to get the set of broken output names for a node in resolution mode.
*/
export function useBrokenOutputs(nodeID: string): Set<string> {
// Subscribe to the actual state values, not just methods
const isInResolution = useNodeStore((state) =>
state.nodesInResolutionMode.has(nodeID),
);
const resolutionData = useNodeStore((state) =>
state.nodeResolutionData.get(nodeID),
);
return useMemo(() => {
if (!isInResolution || !resolutionData) {
return new Set<string>();
}
return new Set(resolutionData.incompatibilities.missingOutputs);
}, [isInResolution, resolutionData]);
}

View File

@@ -25,7 +25,7 @@ export const RightSidebar = () => {
> >
<div className="mb-4"> <div className="mb-4">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200"> <h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
Graph Debug Panel Flow Debug Panel
</h2> </h2>
</div> </div>
@@ -65,7 +65,7 @@ export const RightSidebar = () => {
{l.source_id}[{l.source_name}] {l.sink_id}[{l.sink_name}] {l.source_id}[{l.source_name}] {l.sink_id}[{l.sink_name}]
</div> </div>
<div className="mt-1 text-slate-500 dark:text-slate-400"> <div className="mt-1 text-slate-500 dark:text-slate-400">
edge.id: {l.id} edge_id: {l.id}
</div> </div>
</div> </div>
))} ))}

View File

@@ -12,14 +12,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/__legacy__/ui/popover"; } from "@/components/__legacy__/ui/popover";
import { import { Block, BlockUIType, SpecialBlockID } from "@/lib/autogpt-server-api";
Block,
BlockIORootSchema,
BlockUIType,
GraphInputSchema,
GraphOutputSchema,
SpecialBlockID,
} from "@/lib/autogpt-server-api";
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons"; import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
import { IconToyBrick } from "@/components/__legacy__/ui/icons"; import { IconToyBrick } from "@/components/__legacy__/ui/icons";
import { getPrimaryCategoryColor } from "@/lib/utils"; import { getPrimaryCategoryColor } from "@/lib/utils";
@@ -31,10 +24,8 @@ import {
import { GraphMeta } from "@/lib/autogpt-server-api"; import { GraphMeta } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler"; import jaro from "jaro-winkler";
type _Block = Omit<Block, "inputSchema" | "outputSchema"> & { type _Block = Block & {
uiKey?: string; uiKey?: string;
inputSchema: BlockIORootSchema | GraphInputSchema;
outputSchema: BlockIORootSchema | GraphOutputSchema;
hardcodedValues?: Record<string, any>; hardcodedValues?: Record<string, any>;
_cached?: { _cached?: {
blockName: string; blockName: string;

View File

@@ -2,7 +2,7 @@ import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/__legacy__/ui/button"; import { Button } from "@/components/__legacy__/ui/button";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { ClockIcon, WarningIcon } from "@phosphor-icons/react"; import { ClockIcon } from "@phosphor-icons/react";
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons"; import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
interface Props { interface Props {
@@ -13,7 +13,6 @@ interface Props {
isRunning: boolean; isRunning: boolean;
isDisabled: boolean; isDisabled: boolean;
className?: string; className?: string;
resolutionModeActive?: boolean;
} }
export const BuildActionBar: React.FC<Props> = ({ export const BuildActionBar: React.FC<Props> = ({
@@ -24,30 +23,9 @@ export const BuildActionBar: React.FC<Props> = ({
isRunning, isRunning,
isDisabled, isDisabled,
className, className,
resolutionModeActive = false,
}) => { }) => {
const buttonClasses = const buttonClasses =
"flex items-center gap-2 text-sm font-medium md:text-lg"; "flex items-center gap-2 text-sm font-medium md:text-lg";
// Show resolution mode message instead of action buttons
if (resolutionModeActive) {
return (
<div
className={cn(
"flex w-fit select-none items-center justify-center p-4",
className,
)}
>
<div className="flex items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900/30">
<WarningIcon className="size-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
Remove incompatible connections to continue
</span>
</div>
</div>
);
}
return ( return (
<div <div
className={cn( className={cn(

View File

@@ -60,16 +60,10 @@ export function CustomEdge({
targetY - 5, targetY - 5,
); );
const { deleteElements } = useReactFlow<Node, CustomEdge>(); const { deleteElements } = useReactFlow<Node, CustomEdge>();
const builderContext = useContext(BuilderContext); const { visualizeBeads } = useContext(BuilderContext) ?? {
const { visualizeBeads } = builderContext ?? {
visualizeBeads: "no", visualizeBeads: "no",
}; };
// Check if this edge is broken (during resolution mode)
const isBroken =
builderContext?.resolutionMode?.active &&
builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
const onEdgeRemoveClick = () => { const onEdgeRemoveClick = () => {
deleteElements({ edges: [{ id }] }); deleteElements({ edges: [{ id }] });
}; };
@@ -177,27 +171,12 @@ export function CustomEdge({
const middle = getPointForT(0.5); const middle = getPointForT(0.5);
// Determine edge color - red for broken edges
const baseColor = data?.edgeColor ?? "#555555";
const edgeColor = isBroken ? "#ef4444" : baseColor;
// Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
const strokeColor = isBroken
? `${edgeColor}99`
: selected
? edgeColor
: `${edgeColor}80`;
return ( return (
<> <>
<BaseEdge <BaseEdge
path={svgPath} path={svgPath}
markerEnd={markerEnd} markerEnd={markerEnd}
style={{ className={`data-sentry-unmask transition-all duration-200 ${data?.isStatic ? "[stroke-dasharray:5_3]" : "[stroke-dasharray:0]"} [stroke-width:${data?.isStatic ? 2.5 : 2}px] hover:[stroke-width:${data?.isStatic ? 3.5 : 3}px] ${selected ? `[stroke:${data?.edgeColor ?? "#555555"}]` : `[stroke:${data?.edgeColor ?? "#555555"}80] hover:[stroke:${data?.edgeColor ?? "#555555"}]`}`}
stroke: strokeColor,
strokeWidth: data?.isStatic ? 2.5 : 2,
strokeDasharray: data?.isStatic ? "5 3" : undefined,
}}
className="data-sentry-unmask transition-all duration-200"
/> />
<path <path
d={svgPath} d={svgPath}

View File

@@ -18,8 +18,6 @@ import {
BlockIOSubSchema, BlockIOSubSchema,
BlockUIType, BlockUIType,
Category, Category,
GraphInputSchema,
GraphOutputSchema,
NodeExecutionResult, NodeExecutionResult,
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { import {
@@ -64,21 +62,14 @@ import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs";
import NodeOutputs from "../NodeOutputs"; import NodeOutputs from "../NodeOutputs";
import OutputModalComponent from "../OutputModalComponent"; import OutputModalComponent from "../OutputModalComponent";
import "./customnode.css"; import "./customnode.css";
import { SubAgentUpdateBar } from "./SubAgentUpdateBar";
import { IncompatibilityDialog } from "./IncompatibilityDialog";
import {
useSubAgentUpdate,
createUpdatedAgentNodeInputs,
getBrokenEdgeIDs,
} from "../../../hooks/useSubAgentUpdate";
export type ConnectedEdge = { export type ConnectionData = Array<{
id: string; edge_id: string;
source: string; source: string;
sourceHandle: string; sourceHandle: string;
target: string; target: string;
targetHandle: string; targetHandle: string;
}; }>;
export type CustomNodeData = { export type CustomNodeData = {
blockType: string; blockType: string;
@@ -89,7 +80,7 @@ export type CustomNodeData = {
inputSchema: BlockIORootSchema; inputSchema: BlockIORootSchema;
outputSchema: BlockIORootSchema; outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any }; hardcodedValues: { [key: string]: any };
connections: ConnectedEdge[]; connections: ConnectionData;
isOutputOpen: boolean; isOutputOpen: boolean;
status?: NodeExecutionResult["status"]; status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions /** executionResults contains outputs across multiple executions
@@ -136,199 +127,20 @@ export const CustomNode = React.memo(
let subGraphID = ""; let subGraphID = "";
if (data.uiType === BlockUIType.AGENT) {
// Display the graph's schema instead AgentExecutorBlock's schema.
data.inputSchema = data.hardcodedValues?.input_schema || {};
data.outputSchema = data.hardcodedValues?.output_schema || {};
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
}
if (!builderContext) { if (!builderContext) {
throw new Error( throw new Error(
"BuilderContext consumer must be inside FlowEditor component", "BuilderContext consumer must be inside FlowEditor component",
); );
} }
const { const { libraryAgent, setIsAnyModalOpen, getNextNodeId } = builderContext;
libraryAgent,
setIsAnyModalOpen,
getNextNodeId,
availableFlows,
resolutionMode,
enterResolutionMode,
} = builderContext;
// Check if this node is in resolution mode (moved up for schema merge logic)
const isInResolutionMode =
resolutionMode.active && resolutionMode.nodeId === id;
if (data.uiType === BlockUIType.AGENT) {
// Display the graph's schema instead AgentExecutorBlock's schema.
const currentInputSchema = data.hardcodedValues?.input_schema || {};
const currentOutputSchema = data.hardcodedValues?.output_schema || {};
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
// During resolution mode, merge old connected inputs/outputs with new schema
if (isInResolutionMode && resolutionMode.pendingUpdate) {
const newInputSchema =
(resolutionMode.pendingUpdate.input_schema as BlockIORootSchema) ||
{};
const newOutputSchema =
(resolutionMode.pendingUpdate.output_schema as BlockIORootSchema) ||
{};
// Merge input schemas: start with new schema, add old connected inputs that are missing
const mergedInputProps = { ...newInputSchema.properties };
const incomp = resolutionMode.incompatibilities;
if (incomp && currentInputSchema.properties) {
// Add back missing inputs that have connections (so user can see/delete them)
incomp.missingInputs.forEach((inputName) => {
if (currentInputSchema.properties[inputName]) {
mergedInputProps[inputName] =
currentInputSchema.properties[inputName];
}
});
// Add back inputs with type mismatches (keep old type so connection still works visually)
incomp.inputTypeMismatches.forEach((mismatch) => {
if (currentInputSchema.properties[mismatch.name]) {
mergedInputProps[mismatch.name] =
currentInputSchema.properties[mismatch.name];
}
});
}
// Merge output schemas: start with new schema, add old connected outputs that are missing
const mergedOutputProps = { ...newOutputSchema.properties };
if (incomp && currentOutputSchema.properties) {
incomp.missingOutputs.forEach((outputName) => {
if (currentOutputSchema.properties[outputName]) {
mergedOutputProps[outputName] =
currentOutputSchema.properties[outputName];
}
});
}
data.inputSchema = {
...newInputSchema,
properties: mergedInputProps,
};
data.outputSchema = {
...newOutputSchema,
properties: mergedOutputProps,
};
} else {
data.inputSchema = currentInputSchema;
data.outputSchema = currentOutputSchema;
}
}
const setHardcodedValues = useCallback(
(values: any) => {
updateNodeData(id, { hardcodedValues: values });
},
[id, updateNodeData],
);
// Sub-agent update detection
const isAgentBlock = data.uiType === BlockUIType.AGENT;
const graphId = isAgentBlock ? data.hardcodedValues?.graph_id : undefined;
const graphVersion = isAgentBlock
? data.hardcodedValues?.graph_version
: undefined;
const subAgentUpdate = useSubAgentUpdate(
id,
graphId,
graphVersion,
isAgentBlock
? (data.hardcodedValues?.input_schema as GraphInputSchema)
: undefined,
isAgentBlock
? (data.hardcodedValues?.output_schema as GraphOutputSchema)
: undefined,
data.connections,
availableFlows,
);
const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
useState(false);
// Helper to check if a handle is broken (for resolution mode)
const isInputHandleBroken = useCallback(
(handleName: string): boolean => {
if (!isInResolutionMode || !resolutionMode.incompatibilities) {
return false;
}
const incomp = resolutionMode.incompatibilities;
return (
incomp.missingInputs.includes(handleName) ||
incomp.inputTypeMismatches.some((m) => m.name === handleName)
);
},
[isInResolutionMode, resolutionMode.incompatibilities],
);
const isOutputHandleBroken = useCallback(
(handleName: string): boolean => {
if (!isInResolutionMode || !resolutionMode.incompatibilities) {
return false;
}
return resolutionMode.incompatibilities.missingOutputs.includes(
handleName,
);
},
[isInResolutionMode, resolutionMode.incompatibilities],
);
// Handle update button click
const handleUpdateClick = useCallback(() => {
if (!subAgentUpdate.latestGraph) return;
if (subAgentUpdate.isCompatible) {
// Compatible update - directly apply
const updatedValues = createUpdatedAgentNodeInputs(
data.hardcodedValues,
subAgentUpdate.latestGraph,
);
setHardcodedValues(updatedValues);
toast({
title: "Agent updated",
description: `Updated to version ${subAgentUpdate.latestVersion}`,
});
} else {
// Incompatible update - show dialog
setShowIncompatibilityDialog(true);
}
}, [subAgentUpdate, data.hardcodedValues, setHardcodedValues]);
// Handle confirm incompatible update
const handleConfirmIncompatibleUpdate = useCallback(() => {
if (!subAgentUpdate.latestGraph || !subAgentUpdate.incompatibilities) {
return;
}
// Create the updated values but DON'T apply them yet
const updatedValues = createUpdatedAgentNodeInputs(
data.hardcodedValues,
subAgentUpdate.latestGraph,
);
// Get broken edge IDs
const brokenEdgeIds = getBrokenEdgeIDs(
data.connections,
subAgentUpdate.incompatibilities,
id,
);
// Enter resolution mode with pending update (don't apply schema yet)
enterResolutionMode(
id,
subAgentUpdate.incompatibilities,
brokenEdgeIds,
updatedValues,
);
setShowIncompatibilityDialog(false);
}, [
subAgentUpdate,
data.hardcodedValues,
data.connections,
id,
enterResolutionMode,
]);
useEffect(() => { useEffect(() => {
if (data.executionResults || data.status) { if (data.executionResults || data.status) {
@@ -344,6 +156,13 @@ export const CustomNode = React.memo(
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen); setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]); }, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
const setHardcodedValues = useCallback(
(values: any) => {
updateNodeData(id, { hardcodedValues: values });
},
[id, updateNodeData],
);
const handleTitleEdit = useCallback(() => { const handleTitleEdit = useCallback(() => {
setIsEditingTitle(true); setIsEditingTitle(true);
setTimeout(() => { setTimeout(() => {
@@ -436,7 +255,6 @@ export const CustomNode = React.memo(
isConnected={isOutputHandleConnected(propKey)} isConnected={isOutputHandleConnected(propKey)}
schema={fieldSchema} schema={fieldSchema}
side="right" side="right"
isBroken={isOutputHandleBroken(propKey)}
/> />
{"properties" in fieldSchema && {"properties" in fieldSchema &&
renderHandles( renderHandles(
@@ -567,7 +385,6 @@ export const CustomNode = React.memo(
isRequired={isRequired} isRequired={isRequired}
schema={propSchema} schema={propSchema}
side="left" side="left"
isBroken={isInputHandleBroken(propKey)}
/> />
) : ( ) : (
propKey !== "credentials" && propKey !== "credentials" &&
@@ -1056,22 +873,6 @@ export const CustomNode = React.memo(
<ContextMenuContent /> <ContextMenuContent />
</div> </div>
{/* Sub-agent Update Bar - shown below header */}
{isAgentBlock && (subAgentUpdate.hasUpdate || isInResolutionMode) && (
<SubAgentUpdateBar
currentVersion={subAgentUpdate.currentVersion}
latestVersion={subAgentUpdate.latestVersion}
isCompatible={subAgentUpdate.isCompatible}
incompatibilities={
isInResolutionMode
? resolutionMode.incompatibilities
: subAgentUpdate.incompatibilities
}
onUpdate={handleUpdateClick}
isInResolutionMode={isInResolutionMode}
/>
)}
{/* Body */} {/* Body */}
<div className="mx-5 my-6 rounded-b-xl"> <div className="mx-5 my-6 rounded-b-xl">
{/* Input Handles */} {/* Input Handles */}
@@ -1243,24 +1044,9 @@ export const CustomNode = React.memo(
); );
return ( return (
<> <ContextMenu.Root>
<ContextMenu.Root> <ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger> </ContextMenu.Root>
</ContextMenu.Root>
{/* Incompatibility Dialog for sub-agent updates */}
{isAgentBlock && subAgentUpdate.incompatibilities && (
<IncompatibilityDialog
isOpen={showIncompatibilityDialog}
onClose={() => setShowIncompatibilityDialog(false)}
onConfirm={handleConfirmIncompatibleUpdate}
currentVersion={subAgentUpdate.currentVersion}
latestVersion={subAgentUpdate.latestVersion}
agentName={data.blockType || "Agent"}
incompatibilities={subAgentUpdate.incompatibilities}
/>
)}
</>
); );
}, },
(prevProps, nextProps) => { (prevProps, nextProps) => {

View File

@@ -1,244 +0,0 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { Button } from "@/components/__legacy__/ui/button";
import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { beautifyString } from "@/lib/utils";
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
interface IncompatibilityDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
currentVersion: number;
latestVersion: number;
agentName: string;
incompatibilities: IncompatibilityInfo;
}
export const IncompatibilityDialog: React.FC<IncompatibilityDialogProps> = ({
isOpen,
onClose,
onConfirm,
currentVersion,
latestVersion,
agentName,
incompatibilities,
}) => {
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
const hasNewInputs = incompatibilities.newInputs.length > 0;
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
const hasInputChanges = hasMissingInputs || hasNewInputs;
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Incompatible Update
</DialogTitle>
<DialogDescription>
Updating <strong>{beautifyString(agentName)}</strong> from v
{currentVersion} to v{latestVersion} will break some connections.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Input changes - two column layout */}
{hasInputChanges && (
<TwoColumnSection
title="Input Changes"
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
leftTitle="Removed"
leftItems={incompatibilities.missingInputs}
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
rightTitle="Added"
rightItems={incompatibilities.newInputs}
/>
)}
{/* Output changes - two column layout */}
{hasOutputChanges && (
<TwoColumnSection
title="Output Changes"
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
leftTitle="Removed"
leftItems={incompatibilities.missingOutputs}
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
rightTitle="Added"
rightItems={incompatibilities.newOutputs}
/>
)}
{hasTypeMismatches && (
<SingleColumnSection
icon={<XCircle className="h-4 w-4 text-red-500" />}
title="Type Changed"
description="These connected inputs have a different type:"
items={incompatibilities.inputTypeMismatches.map(
(m) => `${m.name} (${m.oldType}${m.newType})`,
)}
/>
)}
{hasNewRequired && (
<SingleColumnSection
icon={<PlusCircle className="h-4 w-4 text-amber-500" />}
title="New Required Inputs"
description="These inputs are now required:"
items={incompatibilities.newRequiredInputs}
/>
)}
</div>
<Alert variant="warning">
<AlertDescription>
If you proceed, you&apos;ll need to remove the broken connections
before you can save or run your agent.
</AlertDescription>
</Alert>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
className="bg-amber-600 hover:bg-amber-700"
>
Update Anyway
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface TwoColumnSectionProps {
title: string;
leftIcon: React.ReactNode;
leftTitle: string;
leftItems: string[];
rightIcon: React.ReactNode;
rightTitle: string;
rightItems: string[];
}
const TwoColumnSection: React.FC<TwoColumnSectionProps> = ({
title,
leftIcon,
leftTitle,
leftItems,
rightIcon,
rightTitle,
rightItems,
}) => (
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
<span className="font-medium">{title}</span>
<div className="mt-2 grid grid-cols-2 items-start gap-4">
{/* Left column - Breaking changes */}
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
{leftIcon}
<span>{leftTitle}</span>
</div>
<ul className="mt-1.5 space-y-1">
{leftItems.length > 0 ? (
leftItems.map((item) => (
<li
key={item}
className="text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
{item}
</code>
</li>
))
) : (
<li className="text-sm italic text-gray-400 dark:text-gray-500">
None
</li>
)}
</ul>
</div>
{/* Right column - Possible solutions */}
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
{rightIcon}
<span>{rightTitle}</span>
</div>
<ul className="mt-1.5 space-y-1">
{rightItems.length > 0 ? (
rightItems.map((item) => (
<li
key={item}
className="text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
{item}
</code>
</li>
))
) : (
<li className="text-sm italic text-gray-400 dark:text-gray-500">
None
</li>
)}
</ul>
</div>
</div>
</div>
);
interface SingleColumnSectionProps {
icon: React.ReactNode;
title: string;
description: string;
items: string[];
}
const SingleColumnSection: React.FC<SingleColumnSectionProps> = ({
icon,
title,
description,
items,
}) => (
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center gap-2">
{icon}
<span className="font-medium">{title}</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<ul className="mt-2 space-y-1">
{items.map((item) => (
<li
key={item}
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
{item}
</code>
</li>
))}
</ul>
</div>
);
export default IncompatibilityDialog;

View File

@@ -1,130 +0,0 @@
import React from "react";
import { Button } from "@/components/__legacy__/ui/button";
import { ArrowUp, AlertTriangle, Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { cn } from "@/lib/utils";
interface SubAgentUpdateBarProps {
currentVersion: number;
latestVersion: number;
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
onUpdate: () => void;
isInResolutionMode?: boolean;
}
export const SubAgentUpdateBar: React.FC<SubAgentUpdateBarProps> = ({
currentVersion,
latestVersion,
isCompatible,
incompatibilities,
onUpdate,
isInResolutionMode = false,
}) => {
if (isInResolutionMode) {
return <ResolutionModeBar incompatibilities={incompatibilities} />;
}
return (
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
<div className="flex items-center gap-2">
<ArrowUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-blue-700 dark:text-blue-300">
Update available (v{currentVersion} v{latestVersion})
</span>
{!isCompatible && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-4 w-4 text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-medium">Incompatible changes detected</p>
<p className="text-xs text-gray-400">
Click Update to see details
</p>
</TooltipContent>
</Tooltip>
)}
</div>
<Button
size="sm"
variant={isCompatible ? "default" : "outline"}
onClick={onUpdate}
className={cn(
"h-7 text-xs",
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
)}
>
Update
</Button>
</div>
);
};
interface ResolutionModeBarProps {
incompatibilities: IncompatibilityInfo | null;
}
const ResolutionModeBar: React.FC<ResolutionModeBarProps> = ({
incompatibilities,
}) => {
const formatIncompatibilities = () => {
if (!incompatibilities) return "No incompatibilities";
const items: string[] = [];
if (incompatibilities.missingInputs.length > 0) {
items.push(
`Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
);
}
if (incompatibilities.missingOutputs.length > 0) {
items.push(
`Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
);
}
if (incompatibilities.newRequiredInputs.length > 0) {
items.push(
`New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
);
}
if (incompatibilities.inputTypeMismatches.length > 0) {
const mismatches = incompatibilities.inputTypeMismatches
.map((m) => `${m.name} (${m.oldType}${m.newType})`)
.join(", ");
items.push(`Type changed: ${mismatches}`);
}
return items.join("\n");
};
return (
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-700 dark:text-amber-300">
Remove incompatible connections
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 cursor-help text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-line">
<p className="font-medium">Incompatible changes:</p>
<p className="mt-1 text-xs">{formatIncompatibilities()}</p>
<p className="mt-2 text-xs text-gray-400">
Delete the red connections to continue
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
};
export default SubAgentUpdateBar;

View File

@@ -26,17 +26,15 @@ import {
applyNodeChanges, applyNodeChanges,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { ConnectedEdge, CustomNode } from "../CustomNode/CustomNode"; import { CustomNode } from "../CustomNode/CustomNode";
import "./flow.css"; import "./flow.css";
import { import {
BlockUIType, BlockUIType,
formatEdgeID, formatEdgeID,
GraphExecutionID, GraphExecutionID,
GraphID, GraphID,
GraphMeta,
LibraryAgent, LibraryAgent,
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { Key, storage } from "@/services/storage/local-storage"; import { Key, storage } from "@/services/storage/local-storage";
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils"; import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
import { history } from "../history"; import { history } from "../history";
@@ -74,30 +72,12 @@ import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block // It helps to prevent spamming the history with small movements especially when pressing on a input in a block
const MINIMUM_MOVE_BEFORE_LOG = 50; const MINIMUM_MOVE_BEFORE_LOG = 50;
export type ResolutionModeState = {
active: boolean;
nodeId: string | null;
incompatibilities: IncompatibilityInfo | null;
brokenEdgeIds: string[];
pendingUpdate: Record<string, unknown> | null; // The hardcoded values to apply after resolution
};
type BuilderContextType = { type BuilderContextType = {
libraryAgent: LibraryAgent | null; libraryAgent: LibraryAgent | null;
visualizeBeads: "no" | "static" | "animate"; visualizeBeads: "no" | "static" | "animate";
setIsAnyModalOpen: (isOpen: boolean) => void; setIsAnyModalOpen: (isOpen: boolean) => void;
getNextNodeId: () => string; getNextNodeId: () => string;
getNodeTitle: (nodeID: string) => string | null; getNodeTitle: (nodeID: string) => string | null;
availableFlows: GraphMeta[];
resolutionMode: ResolutionModeState;
enterResolutionMode: (
nodeId: string,
incompatibilities: IncompatibilityInfo,
brokenEdgeIds: string[],
pendingUpdate: Record<string, unknown>,
) => void;
exitResolutionMode: () => void;
applyPendingUpdate: () => void;
}; };
export type NodeDimension = { export type NodeDimension = {
@@ -192,92 +172,6 @@ const FlowEditor: React.FC<{
// It stores the dimension of all nodes with position as well // It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({}); const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
// Resolution mode state for sub-agent incompatible updates
const [resolutionMode, setResolutionMode] = useState<ResolutionModeState>({
active: false,
nodeId: null,
incompatibilities: null,
brokenEdgeIds: [],
pendingUpdate: null,
});
const enterResolutionMode = useCallback(
(
nodeId: string,
incompatibilities: IncompatibilityInfo,
brokenEdgeIds: string[],
pendingUpdate: Record<string, unknown>,
) => {
setResolutionMode({
active: true,
nodeId,
incompatibilities,
brokenEdgeIds,
pendingUpdate,
});
},
[],
);
const exitResolutionMode = useCallback(() => {
setResolutionMode({
active: false,
nodeId: null,
incompatibilities: null,
brokenEdgeIds: [],
pendingUpdate: null,
});
}, []);
// Apply pending update after resolution mode completes
const applyPendingUpdate = useCallback(() => {
if (!resolutionMode.nodeId || !resolutionMode.pendingUpdate) return;
const node = nodes.find((n) => n.id === resolutionMode.nodeId);
if (node) {
const pendingUpdate = resolutionMode.pendingUpdate as {
[key: string]: any;
};
setNodes((nds) =>
nds.map((n) =>
n.id === resolutionMode.nodeId
? { ...n, data: { ...n.data, hardcodedValues: pendingUpdate } }
: n,
),
);
}
exitResolutionMode();
toast({
title: "Update complete",
description: "Agent has been updated to the new version.",
});
}, [resolutionMode, nodes, setNodes, exitResolutionMode, toast]);
// Check if all broken edges have been removed and auto-apply pending update
useEffect(() => {
if (!resolutionMode.active || resolutionMode.brokenEdgeIds.length === 0) {
return;
}
const currentEdgeIds = new Set(edges.map((e) => e.id));
const remainingBrokenEdges = resolutionMode.brokenEdgeIds.filter((id) =>
currentEdgeIds.has(id),
);
if (remainingBrokenEdges.length === 0) {
// All broken edges have been removed, apply pending update
applyPendingUpdate();
} else if (
remainingBrokenEdges.length !== resolutionMode.brokenEdgeIds.length
) {
// Update the list of broken edges
setResolutionMode((prev) => ({
...prev,
brokenEdgeIds: remainingBrokenEdges,
}));
}
}, [edges, resolutionMode, applyPendingUpdate]);
// Set page title with or without graph name // Set page title with or without graph name
useEffect(() => { useEffect(() => {
document.title = savedAgent document.title = savedAgent
@@ -537,19 +431,17 @@ const FlowEditor: React.FC<{
...node.data.connections.filter( ...node.data.connections.filter(
(conn) => (conn) =>
!removedEdges.some( !removedEdges.some(
(removedEdge) => removedEdge.id === conn.id, (removedEdge) => removedEdge.id === conn.edge_id,
), ),
), ),
// Add node connections for added edges // Add node connections for added edges
...addedEdges.map( ...addedEdges.map((addedEdge) => ({
(addedEdge): ConnectedEdge => ({ edge_id: addedEdge.item.id,
id: addedEdge.item.id, source: addedEdge.item.source,
source: addedEdge.item.source, target: addedEdge.item.target,
target: addedEdge.item.target, sourceHandle: addedEdge.item.sourceHandle!,
sourceHandle: addedEdge.item.sourceHandle!, targetHandle: addedEdge.item.targetHandle!,
targetHandle: addedEdge.item.targetHandle!, })),
}),
),
], ],
}, },
})); }));
@@ -575,15 +467,13 @@ const FlowEditor: React.FC<{
data: { data: {
...node.data, ...node.data,
connections: [ connections: [
...replaceEdges.map( ...replaceEdges.map((replaceEdge) => ({
(replaceEdge): ConnectedEdge => ({ edge_id: replaceEdge.item.id,
id: replaceEdge.item.id, source: replaceEdge.item.source,
source: replaceEdge.item.source, target: replaceEdge.item.target,
target: replaceEdge.item.target, sourceHandle: replaceEdge.item.sourceHandle!,
sourceHandle: replaceEdge.item.sourceHandle!, targetHandle: replaceEdge.item.targetHandle!,
targetHandle: replaceEdge.item.targetHandle!, })),
}),
),
], ],
}, },
})), })),
@@ -1000,23 +890,8 @@ const FlowEditor: React.FC<{
setIsAnyModalOpen, setIsAnyModalOpen,
getNextNodeId, getNextNodeId,
getNodeTitle, getNodeTitle,
availableFlows,
resolutionMode,
enterResolutionMode,
exitResolutionMode,
applyPendingUpdate,
}), }),
[ [libraryAgent, visualizeBeads, getNextNodeId, getNodeTitle],
libraryAgent,
visualizeBeads,
getNextNodeId,
getNodeTitle,
availableFlows,
resolutionMode,
enterResolutionMode,
applyPendingUpdate,
exitResolutionMode,
],
); );
return ( return (
@@ -1116,7 +991,6 @@ const FlowEditor: React.FC<{
onClickScheduleButton={handleScheduleButton} onClickScheduleButton={handleScheduleButton}
isDisabled={!savedAgent} isDisabled={!savedAgent}
isRunning={isRunning} isRunning={isRunning}
resolutionModeActive={resolutionMode.active}
/> />
) : ( ) : (
<Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none"> <Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none">

View File

@@ -1,11 +1,6 @@
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types"; import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
import { import { cn } from "@/lib/utils";
cn, import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
beautifyString,
getTypeBgColor,
getTypeTextColor,
getEffectiveType,
} from "@/lib/utils";
import { FC, memo, useCallback } from "react"; import { FC, memo, useCallback } from "react";
import { Handle, Position } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
@@ -18,7 +13,6 @@ type HandleProps = {
side: "left" | "right"; side: "left" | "right";
title?: string; title?: string;
className?: string; className?: string;
isBroken?: boolean;
}; };
// Move the constant out of the component to avoid re-creation on every render. // Move the constant out of the component to avoid re-creation on every render.
@@ -33,23 +27,18 @@ const TYPE_NAME: Record<string, string> = {
}; };
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily. // Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> = const Dot: FC<{ isConnected: boolean; type?: string }> = memo(
memo(({ isConnected, type, isBroken }) => { ({ isConnected, type }) => {
const color = isBroken const color = isConnected
? "border-red-500 bg-red-100 dark:bg-red-900/30" ? getTypeBgColor(type || "any")
: isConnected : "border-gray-300 dark:border-gray-600";
? getTypeBgColor(type || "any")
: "border-gray-300 dark:border-gray-600";
return ( return (
<div <div
className={cn( className={`${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700`}
"m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700",
color,
isBroken && "opacity-50",
)}
/> />
); );
}); },
);
Dot.displayName = "Dot"; Dot.displayName = "Dot";
const NodeHandle: FC<HandleProps> = ({ const NodeHandle: FC<HandleProps> = ({
@@ -60,34 +49,24 @@ const NodeHandle: FC<HandleProps> = ({
side, side,
title, title,
className, className,
isBroken = false,
}) => { }) => {
// Extract effective type from schema (handles anyOf/oneOf/allOf wrappers) const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${
const effectiveType = getEffectiveType(schema);
const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
side === "left" ? "text-left" : "text-right" side === "left" ? "text-left" : "text-right"
}`; }`;
const label = ( const label = (
<div className={cn("flex flex-grow flex-row", isBroken && "opacity-50")}> <div className="flex flex-grow flex-row">
<span <span
className={cn( className={cn(
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100", "data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
className, className,
isBroken && "text-red-500 line-through",
)} )}
> >
{title || schema.title || beautifyString(keyName.toLowerCase())} {title || schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""} {isRequired ? "*" : ""}
</span> </span>
<span <span className={`${typeClass} data-sentry-unmask flex items-end`}>
className={cn( ({TYPE_NAME[schema.type as keyof typeof TYPE_NAME] || "any"})
`${typeClass} data-sentry-unmask flex items-end`,
isBroken && "text-red-400",
)}
>
({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
</span> </span>
</div> </div>
); );
@@ -105,7 +84,7 @@ const NodeHandle: FC<HandleProps> = ({
return ( return (
<div <div
key={keyName} key={keyName}
className={cn("handle-container", isBroken && "pointer-events-none")} className="handle-container"
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<Handle <Handle
@@ -113,15 +92,10 @@ const NodeHandle: FC<HandleProps> = ({
data-testid={`input-handle-${keyName}`} data-testid={`input-handle-${keyName}`}
position={Position.Left} position={Position.Left}
id={keyName} id={keyName}
className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")} className="group -ml-[38px]"
isConnectable={!isBroken}
> >
<div className="pointer-events-none flex items-center"> <div className="pointer-events-none flex items-center">
<Dot <Dot isConnected={isConnected} type={schema.type} />
isConnected={isConnected}
type={effectiveType}
isBroken={isBroken}
/>
{label} {label}
</div> </div>
</Handle> </Handle>
@@ -132,10 +106,7 @@ const NodeHandle: FC<HandleProps> = ({
return ( return (
<div <div
key={keyName} key={keyName}
className={cn( className="handle-container justify-end"
"handle-container justify-end",
isBroken && "pointer-events-none",
)}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<Handle <Handle
@@ -143,16 +114,11 @@ const NodeHandle: FC<HandleProps> = ({
data-testid={`output-handle-${keyName}`} data-testid={`output-handle-${keyName}`}
position={Position.Right} position={Position.Right}
id={keyName} id={keyName}
className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")} className="group -mr-[38px]"
isConnectable={!isBroken}
> >
<div className="pointer-events-none flex items-center"> <div className="pointer-events-none flex items-center">
{label} {label}
<Dot <Dot isConnected={isConnected} type={schema.type} />
isConnected={isConnected}
type={effectiveType}
isBroken={isBroken}
/>
</div> </div>
</Handle> </Handle>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { import {
ConnectedEdge, ConnectionData,
CustomNodeData, CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode"; } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput"; import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
@@ -65,7 +65,7 @@ type NodeObjectInputTreeProps = {
selfKey?: string; selfKey?: string;
schema: BlockIORootSchema | BlockIOObjectSubSchema; schema: BlockIORootSchema | BlockIOObjectSubSchema;
object?: { [key: string]: any }; object?: { [key: string]: any };
connections: ConnectedEdge[]; connections: ConnectionData;
handleInputClick: (key: string) => void; handleInputClick: (key: string) => void;
handleInputChange: (key: string, value: any) => void; handleInputChange: (key: string, value: any) => void;
errors: { [key: string]: string | undefined }; errors: { [key: string]: string | undefined };
@@ -585,7 +585,7 @@ const NodeOneOfDiscriminatorField: FC<{
currentValue?: any; currentValue?: any;
defaultValue?: any; defaultValue?: any;
errors: { [key: string]: string | undefined }; errors: { [key: string]: string | undefined };
connections: ConnectedEdge[]; connections: ConnectionData;
handleInputChange: (key: string, value: any) => void; handleInputChange: (key: string, value: any) => void;
handleInputClick: (key: string) => void; handleInputClick: (key: string) => void;
className?: string; className?: string;

View File

@@ -1,16 +1,15 @@
import { FC, useCallback, useEffect, useState } from "react"; import { FC, useCallback, useEffect, useState } from "react";
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle"; import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
import type { import {
BlockIOTableSubSchema, BlockIOTableSubSchema,
TableCellValue, TableCellValue,
TableRow, TableRow,
} from "@/lib/autogpt-server-api/types"; } from "@/lib/autogpt-server-api/types";
import type { ConnectedEdge } from "./CustomNode/CustomNode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlusIcon, XIcon } from "@phosphor-icons/react"; import { PlusIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "../../../../../components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input"; import { Input } from "../../../../../components/atoms/Input/Input";
interface NodeTableInputProps { interface NodeTableInputProps {
/** Unique identifier for the node in the builder graph */ /** Unique identifier for the node in the builder graph */
@@ -26,7 +25,13 @@ interface NodeTableInputProps {
/** Validation errors mapped by field key */ /** Validation errors mapped by field key */
errors: { [key: string]: string | undefined }; errors: { [key: string]: string | undefined };
/** Graph connections between nodes in the builder */ /** Graph connections between nodes in the builder */
connections: ConnectedEdge[]; connections: {
edge_id: string;
source: string;
sourceHandle: string;
target: string;
targetHandle: string;
}[];
/** Callback when table data changes */ /** Callback when table data changes */
handleInputChange: (key: string, value: TableRow[]) => void; handleInputChange: (key: string, value: TableRow[]) => void;
/** Callback when input field is clicked (for builder selection) */ /** Callback when input field is clicked (for builder selection) */

View File

@@ -1,7 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Node, Edge, useReactFlow } from "@xyflow/react"; import { Node, Edge, useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage"; import { Key, storage } from "@/services/storage/local-storage";
import { ConnectedEdge } from "./CustomNode/CustomNode";
interface CopyableData { interface CopyableData {
nodes: Node[]; nodes: Node[];
@@ -112,15 +111,13 @@ export function useCopyPaste(getNextNodeId: () => string) {
(edge: Edge) => (edge: Edge) =>
edge.source === node.id || edge.target === node.id, edge.source === node.id || edge.target === node.id,
) )
.map( .map((edge: Edge) => ({
(edge: Edge): ConnectedEdge => ({ edge_id: edge.id,
id: edge.id, source: edge.source,
source: edge.source, target: edge.target,
target: edge.target, sourceHandle: edge.sourceHandle,
sourceHandle: edge.sourceHandle!, targetHandle: edge.targetHandle,
targetHandle: edge.targetHandle!, }));
}),
);
return { return {
...node, ...node,

View File

@@ -1,104 +0,0 @@
import { GraphInputSchema } from "@/lib/autogpt-server-api";
import { GraphMetaLike, IncompatibilityInfo } from "./types";
// Helper type for schema properties - the generated types are too loose
type SchemaProperties = Record<string, GraphInputSchema["properties"][string]>;
type SchemaRequired = string[];
// Helper to safely extract schema properties
export function getSchemaProperties(schema: unknown): SchemaProperties {
if (
schema &&
typeof schema === "object" &&
"properties" in schema &&
typeof schema.properties === "object" &&
schema.properties !== null
) {
return schema.properties as SchemaProperties;
}
return {};
}
export function getSchemaRequired(schema: unknown): SchemaRequired {
if (
schema &&
typeof schema === "object" &&
"required" in schema &&
Array.isArray(schema.required)
) {
return schema.required as SchemaRequired;
}
return [];
}
/**
* Creates the updated agent node inputs for a sub-agent node
*/
export function createUpdatedAgentNodeInputs(
currentInputs: Record<string, unknown>,
latestSubGraphVersion: GraphMetaLike,
): Record<string, unknown> {
return {
...currentInputs,
graph_version: latestSubGraphVersion.version,
input_schema: latestSubGraphVersion.input_schema,
output_schema: latestSubGraphVersion.output_schema,
};
}
/** Generic edge type that works with both builders:
* - New builder uses CustomEdge with (formally) optional handles
* - Legacy builder uses ConnectedEdge type with required handles */
export type EdgeLike = {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
};
/**
* Determines which edges are broken after an incompatible update.
* Works with both legacy ConnectedEdge and new CustomEdge.
*/
export function getBrokenEdgeIDs(
connections: EdgeLike[],
incompatibilities: IncompatibilityInfo,
nodeID: string,
): string[] {
const brokenEdgeIDs: string[] = [];
const typeMismatchInputNames = new Set(
incompatibilities.inputTypeMismatches.map((m) => m.name),
);
connections.forEach((conn) => {
// Check if this connection uses a missing input (node is target)
if (
conn.target === nodeID &&
conn.targetHandle &&
incompatibilities.missingInputs.includes(conn.targetHandle)
) {
brokenEdgeIDs.push(conn.id);
}
// Check if this connection uses an input with a type mismatch (node is target)
if (
conn.target === nodeID &&
conn.targetHandle &&
typeMismatchInputNames.has(conn.targetHandle)
) {
brokenEdgeIDs.push(conn.id);
}
// Check if this connection uses a missing output (node is source)
if (
conn.source === nodeID &&
conn.sourceHandle &&
incompatibilities.missingOutputs.includes(conn.sourceHandle)
) {
brokenEdgeIDs.push(conn.id);
}
});
return brokenEdgeIDs;
}

View File

@@ -1,2 +0,0 @@
export { useSubAgentUpdate } from "./useSubAgentUpdate";
export { createUpdatedAgentNodeInputs, getBrokenEdgeIDs } from "./helpers";

View File

@@ -1,27 +0,0 @@
import type { GraphMeta as LegacyGraphMeta } from "@/lib/autogpt-server-api";
import type { GraphMeta as GeneratedGraphMeta } from "@/app/api/__generated__/models/graphMeta";
export type SubAgentUpdateInfo<T extends GraphMetaLike = GraphMetaLike> = {
hasUpdate: boolean;
currentVersion: number;
latestVersion: number;
latestGraph: T | null;
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
};
// Union type for GraphMeta that works with both legacy and new builder
export type GraphMetaLike = LegacyGraphMeta | GeneratedGraphMeta;
export type IncompatibilityInfo = {
missingInputs: string[]; // Connected inputs that no longer exist
missingOutputs: string[]; // Connected outputs that no longer exist
newInputs: string[]; // Inputs that exist in new version but not in current
newOutputs: string[]; // Outputs that exist in new version but not in current
newRequiredInputs: string[]; // New required inputs not in current version or not required
inputTypeMismatches: Array<{
name: string;
oldType: string;
newType: string;
}>; // Connected inputs where the type has changed
};

View File

@@ -1,160 +0,0 @@
import { useMemo } from "react";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { getEffectiveType } from "@/lib/utils";
import { EdgeLike, getSchemaProperties, getSchemaRequired } from "./helpers";
import {
GraphMetaLike,
IncompatibilityInfo,
SubAgentUpdateInfo,
} from "./types";
/**
* Checks if a newer version of a sub-agent is available and determines compatibility
*/
export function useSubAgentUpdate<T extends GraphMetaLike>(
nodeID: string,
graphID: string | undefined,
graphVersion: number | undefined,
currentInputSchema: GraphInputSchema | undefined,
currentOutputSchema: GraphOutputSchema | undefined,
connections: EdgeLike[],
availableGraphs: T[],
): SubAgentUpdateInfo<T> {
// Find the latest version of the same graph
const latestGraph = useMemo(() => {
if (!graphID) return null;
return availableGraphs.find((graph) => graph.id === graphID) || null;
}, [graphID, availableGraphs]);
// Check if there's an update available
const hasUpdate = useMemo(() => {
if (!latestGraph || graphVersion === undefined) return false;
return latestGraph.version! > graphVersion;
}, [latestGraph, graphVersion]);
// Get connected input and output handles for this specific node
const connectedHandles = useMemo(() => {
const inputHandles = new Set<string>();
const outputHandles = new Set<string>();
connections.forEach((conn) => {
// If this node is the target, the targetHandle is an input on this node
if (conn.target === nodeID && conn.targetHandle) {
inputHandles.add(conn.targetHandle);
}
// If this node is the source, the sourceHandle is an output on this node
if (conn.source === nodeID && conn.sourceHandle) {
outputHandles.add(conn.sourceHandle);
}
});
return { inputHandles, outputHandles };
}, [connections, nodeID]);
// Check schema compatibility
const compatibilityResult = useMemo((): {
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
} => {
if (!hasUpdate || !latestGraph) {
return { isCompatible: true, incompatibilities: null };
}
const newInputProps = getSchemaProperties(latestGraph.input_schema);
const newOutputProps = getSchemaProperties(latestGraph.output_schema);
const newRequiredInputs = getSchemaRequired(latestGraph.input_schema);
const currentInputProps = getSchemaProperties(currentInputSchema);
const currentOutputProps = getSchemaProperties(currentOutputSchema);
const currentRequiredInputs = getSchemaRequired(currentInputSchema);
const incompatibilities: IncompatibilityInfo = {
missingInputs: [],
missingOutputs: [],
newInputs: [],
newOutputs: [],
newRequiredInputs: [],
inputTypeMismatches: [],
};
// Check for missing connected inputs and type mismatches
connectedHandles.inputHandles.forEach((inputHandle) => {
if (!(inputHandle in newInputProps)) {
incompatibilities.missingInputs.push(inputHandle);
} else {
// Check for type mismatch on connected inputs
const currentProp = currentInputProps[inputHandle];
const newProp = newInputProps[inputHandle];
const currentType = getEffectiveType(currentProp);
const newType = getEffectiveType(newProp);
if (currentType && newType && currentType !== newType) {
incompatibilities.inputTypeMismatches.push({
name: inputHandle,
oldType: currentType,
newType: newType,
});
}
}
});
// Check for missing connected outputs
connectedHandles.outputHandles.forEach((outputHandle) => {
if (!(outputHandle in newOutputProps)) {
incompatibilities.missingOutputs.push(outputHandle);
}
});
// Check for new required inputs that didn't exist or weren't required before
newRequiredInputs.forEach((requiredInput) => {
const existedBefore = requiredInput in currentInputProps;
const wasRequiredBefore = currentRequiredInputs.includes(
requiredInput as string,
);
if (!existedBefore || !wasRequiredBefore) {
incompatibilities.newRequiredInputs.push(requiredInput as string);
}
});
// Check for new inputs that don't exist in the current version
Object.keys(newInputProps).forEach((inputName) => {
if (!(inputName in currentInputProps)) {
incompatibilities.newInputs.push(inputName);
}
});
// Check for new outputs that don't exist in the current version
Object.keys(newOutputProps).forEach((outputName) => {
if (!(outputName in currentOutputProps)) {
incompatibilities.newOutputs.push(outputName);
}
});
const hasIncompatibilities =
incompatibilities.missingInputs.length > 0 ||
incompatibilities.missingOutputs.length > 0 ||
incompatibilities.newRequiredInputs.length > 0 ||
incompatibilities.inputTypeMismatches.length > 0;
return {
isCompatible: !hasIncompatibilities,
incompatibilities: hasIncompatibilities ? incompatibilities : null,
};
}, [
hasUpdate,
latestGraph,
currentInputSchema,
currentOutputSchema,
connectedHandles,
]);
return {
hasUpdate,
currentVersion: graphVersion || 0,
latestVersion: latestGraph?.version || 0,
latestGraph,
isCompatible: compatibilityResult.isCompatible,
incompatibilities: compatibilityResult.incompatibilities,
};
}

View File

@@ -1,6 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
interface GraphStore { interface GraphStore {
graphExecutionStatus: AgentExecutionStatus | undefined; graphExecutionStatus: AgentExecutionStatus | undefined;
@@ -18,10 +17,6 @@ interface GraphStore {
outputSchema: Record<string, any> | null, outputSchema: Record<string, any> | null,
) => void; ) => void;
// Available graphs; used for sub-graph updates
availableSubGraphs: GraphMeta[];
setAvailableSubGraphs: (graphs: GraphMeta[]) => void;
hasInputs: () => boolean; hasInputs: () => boolean;
hasCredentials: () => boolean; hasCredentials: () => boolean;
hasOutputs: () => boolean; hasOutputs: () => boolean;
@@ -34,7 +29,6 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
inputSchema: null, inputSchema: null,
credentialsInputSchema: null, credentialsInputSchema: null,
outputSchema: null, outputSchema: null,
availableSubGraphs: [],
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => { setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
set({ set({
@@ -52,8 +46,6 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) => setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
set({ inputSchema, credentialsInputSchema, outputSchema }), set({ inputSchema, credentialsInputSchema, outputSchema }),
setAvailableSubGraphs: (graphs) => set({ availableSubGraphs: graphs }),
hasOutputs: () => { hasOutputs: () => {
const { outputSchema } = get(); const { outputSchema } = get();
return Object.keys(outputSchema?.properties ?? {}).length > 0; return Object.keys(outputSchema?.properties ?? {}).length > 0;

View File

@@ -17,25 +17,6 @@ import {
ensurePathExists, ensurePathExists,
parseHandleIdToPath, parseHandleIdToPath,
} from "@/components/renderers/InputRenderer/helpers"; } from "@/components/renderers/InputRenderer/helpers";
import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types";
// Resolution mode data stored per node
export type NodeResolutionData = {
incompatibilities: IncompatibilityInfo;
// The NEW schema from the update (what we're updating TO)
pendingUpdate: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
// The OLD schema before the update (what we're updating FROM)
// Needed to merge and show removed inputs during resolution
currentSchema: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
// The full updated hardcoded values to apply when resolution completes
pendingHardcodedValues: Record<string, unknown>;
};
// Minimum movement (in pixels) required before logging position change to history // Minimum movement (in pixels) required before logging position change to history
// Prevents spamming history with small movements when clicking on inputs inside blocks // Prevents spamming history with small movements when clicking on inputs inside blocks
@@ -84,32 +65,12 @@ type NodeStore = {
backendId: string, backendId: string,
errors: { [key: string]: string }, errors: { [key: string]: string },
) => void; ) => void;
clearAllNodeErrors: () => void; // Add this
syncHardcodedValuesWithHandleIds: (nodeId: string) => void; syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
// Credentials optional helpers
setCredentialsOptional: (nodeId: string, optional: boolean) => void; setCredentialsOptional: (nodeId: string, optional: boolean) => void;
clearAllNodeErrors: () => void;
nodesInResolutionMode: Set<string>;
brokenEdgeIDs: Map<string, Set<string>>;
nodeResolutionData: Map<string, NodeResolutionData>;
setNodeResolutionMode: (
nodeID: string,
inResolution: boolean,
resolutionData?: NodeResolutionData,
) => void;
isNodeInResolutionMode: (nodeID: string) => boolean;
getNodeResolutionData: (nodeID: string) => NodeResolutionData | undefined;
setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => void;
removeBrokenEdgeID: (nodeID: string, edgeID: string) => void;
isEdgeBroken: (edgeID: string) => boolean;
clearResolutionState: () => void;
isInputBroken: (nodeID: string, handleID: string) => boolean;
getInputTypeMismatch: (
nodeID: string,
handleID: string,
) => string | undefined;
}; };
export const useNodeStore = create<NodeStore>((set, get) => ({ export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -413,99 +374,4 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
useHistoryStore.getState().pushState(newState); useHistoryStore.getState().pushState(newState);
}, },
// Sub-agent resolution mode state
nodesInResolutionMode: new Set<string>(),
brokenEdgeIDs: new Map<string, Set<string>>(),
nodeResolutionData: new Map<string, NodeResolutionData>(),
setNodeResolutionMode: (
nodeID: string,
inResolution: boolean,
resolutionData?: NodeResolutionData,
) => {
set((state) => {
const newNodesSet = new Set(state.nodesInResolutionMode);
const newResolutionDataMap = new Map(state.nodeResolutionData);
const newBrokenEdgeIDs = new Map(state.brokenEdgeIDs);
if (inResolution) {
newNodesSet.add(nodeID);
if (resolutionData) {
newResolutionDataMap.set(nodeID, resolutionData);
}
} else {
newNodesSet.delete(nodeID);
newResolutionDataMap.delete(nodeID);
newBrokenEdgeIDs.delete(nodeID); // Clean up broken edges when exiting resolution mode
}
return {
nodesInResolutionMode: newNodesSet,
nodeResolutionData: newResolutionDataMap,
brokenEdgeIDs: newBrokenEdgeIDs,
};
});
},
isNodeInResolutionMode: (nodeID: string) => {
return get().nodesInResolutionMode.has(nodeID);
},
getNodeResolutionData: (nodeID: string) => {
return get().nodeResolutionData.get(nodeID);
},
setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => {
set((state) => {
const newMap = new Map(state.brokenEdgeIDs);
newMap.set(nodeID, new Set(edgeIDs));
return { brokenEdgeIDs: newMap };
});
},
removeBrokenEdgeID: (nodeID: string, edgeID: string) => {
set((state) => {
const newMap = new Map(state.brokenEdgeIDs);
const nodeSet = new Set(newMap.get(nodeID) || []);
nodeSet.delete(edgeID);
newMap.set(nodeID, nodeSet);
return { brokenEdgeIDs: newMap };
});
},
isEdgeBroken: (edgeID: string) => {
// Check across all nodes
const brokenEdgeIDs = get().brokenEdgeIDs;
for (const edgeSet of brokenEdgeIDs.values()) {
if (edgeSet.has(edgeID)) {
return true;
}
}
return false;
},
clearResolutionState: () => {
set({
nodesInResolutionMode: new Set<string>(),
brokenEdgeIDs: new Map<string, Set<string>>(),
nodeResolutionData: new Map<string, NodeResolutionData>(),
});
},
// Helper functions for input renderers
isInputBroken: (nodeID: string, handleID: string) => {
const resolutionData = get().nodeResolutionData.get(nodeID);
if (!resolutionData) return false;
return resolutionData.incompatibilities.missingInputs.includes(handleID);
},
getInputTypeMismatch: (nodeID: string, handleID: string) => {
const resolutionData = get().nodeResolutionData.get(nodeID);
if (!resolutionData) return undefined;
const mismatch = resolutionData.incompatibilities.inputTypeMismatches.find(
(m) => m.name === handleID,
);
return mismatch?.newType;
},
})); }));

View File

@@ -18,7 +18,7 @@ function ErrorPageContent() {
) { ) {
window.location.href = "/login"; window.location.href = "/login";
} else { } else {
window.document.location.reload(); window.location.href = "/marketplace";
} }
} }

View File

@@ -29,7 +29,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
href: "/profile/dashboard", href: "/profile/dashboard",
icon: <StorefrontIcon className="size-5" />, icon: <StorefrontIcon className="size-5" />,
}, },
...(isPaymentEnabled ...(isPaymentEnabled || true
? [ ? [
{ {
text: "Billing", text: "Billing",

View File

@@ -32,7 +32,7 @@ export const extendedButtonVariants = cva(
), ),
}, },
size: { size: {
small: "px-3 py-2 text-sm gap-1.5 h-[2.25rem] min-w-[5.5rem]", small: "px-3 py-2 text-sm gap-1.5 h-[2.25rem]",
large: "px-4 py-3 text-sm gap-2 h-[3.25rem]", large: "px-4 py-3 text-sm gap-2 h-[3.25rem]",
icon: "p-3 !min-w-0", icon: "p-3 !min-w-0",
}, },

View File

@@ -30,6 +30,8 @@ export const FormRenderer = ({
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema); return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]); }, [preprocessedSchema, uiSchema]);
console.log("preprocessedSchema", preprocessedSchema);
return ( return (
<div className={"mb-6 mt-4"}> <div className={"mb-6 mt-4"}>
<Form <Form

View File

@@ -2,12 +2,10 @@ import { FieldProps, getUiOptions, getWidget } from "@rjsf/utils";
import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle"; import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { useAnyOfField } from "./useAnyOfField"; import { useAnyOfField } from "./useAnyOfField";
import { cleanUpHandleId, getHandleId, updateUiOption } from "../../helpers"; import { getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants"; import { ANY_OF_FLAG } from "../../constants";
import { findCustomFieldId } from "../../registry"; import { findCustomFieldId } from "../../registry";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { cn } from "@/lib/utils";
export const AnyOfField = (props: FieldProps) => { export const AnyOfField = (props: FieldProps) => {
const { registry, schema } = props; const { registry, schema } = props;
@@ -23,8 +21,6 @@ export const AnyOfField = (props: FieldProps) => {
field_id, field_id,
} = useAnyOfField(props); } = useAnyOfField(props);
const isInputBroken = useNodeStore((state) => state.isInputBroken);
const parentCustomFieldId = findCustomFieldId(schema); const parentCustomFieldId = findCustomFieldId(schema);
if (parentCustomFieldId) { if (parentCustomFieldId) {
return null; return null;
@@ -47,7 +43,6 @@ export const AnyOfField = (props: FieldProps) => {
}); });
const isHandleConnected = isInputConnected(nodeId, handleId); const isHandleConnected = isInputConnected(nodeId, handleId);
const isAnyOfInputBroken = isInputBroken(nodeId, cleanUpHandleId(handleId));
// Now anyOf can render - custom fields if the option schema matches a custom field // Now anyOf can render - custom fields if the option schema matches a custom field
const optionCustomFieldId = optionSchema const optionCustomFieldId = optionSchema
@@ -83,11 +78,7 @@ export const AnyOfField = (props: FieldProps) => {
registry={registry} registry={registry}
placeholder={props.placeholder} placeholder={props.placeholder}
autocomplete={props.autocomplete} autocomplete={props.autocomplete}
className={cn( className="-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium"
"-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium",
isAnyOfInputBroken &&
"border-red-500 bg-red-100 text-red-600 line-through",
)}
autofocus={props.autofocus} autofocus={props.autofocus}
label="" label=""
hideLabel={true} hideLabel={true}
@@ -102,7 +93,7 @@ export const AnyOfField = (props: FieldProps) => {
selector={selector} selector={selector}
uiSchema={updatedUiSchema} uiSchema={updatedUiSchema}
/> />
{!isHandleConnected && !isAnyOfInputBroken && optionsSchemaField} {!isHandleConnected && optionsSchemaField}
</div> </div>
); );
}; };

View File

@@ -13,7 +13,6 @@ import { Text } from "@/components/atoms/Text/Text";
import { isOptionalType } from "../../../utils/schema-utils"; import { isOptionalType } from "../../../utils/schema-utils";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
interface customFieldProps extends FieldProps { interface customFieldProps extends FieldProps {
selector: JSX.Element; selector: JSX.Element;
@@ -52,13 +51,6 @@ export const AnyOfFieldTitle = (props: customFieldProps) => {
shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected; shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected;
const shoudlShowType = isHandleConnected || (isOptional && type); const shoudlShowType = isHandleConnected || (isOptional && type);
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
const inputMismatch = useNodeStore((state) =>
state.getInputTypeMismatch(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TitleFieldTemplate <TitleFieldTemplate
@@ -70,16 +62,8 @@ export const AnyOfFieldTitle = (props: customFieldProps) => {
uiSchema={uiSchema} uiSchema={uiSchema}
/> />
{shoudlShowType && ( {shoudlShowType && (
<Text <Text variant="small" className={cn("text-zinc-700", colorClass)}>
variant="small" {isOptional ? `(${displayType})` : "(any)"}
className={cn(
"text-zinc-700",
isInputBroken && "line-through",
colorClass,
inputMismatch && "rounded-md bg-red-100 px-1 !text-red-500",
)}
>
{isOptional ? `(${inputMismatch || displayType})` : "(any)"}
</Text> </Text>
)} )}
{shouldShowSelector && selector} {shouldShowSelector && selector}

View File

@@ -9,9 +9,8 @@ import { Text } from "@/components/atoms/Text/Text";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { isAnyOfSchema } from "../../utils/schema-utils"; import { isAnyOfSchema } from "../../utils/schema-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cleanUpHandleId, isArrayItem } from "../../helpers"; import { isArrayItem } from "../../helpers";
import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle"; import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
export default function TitleField(props: TitleFieldProps) { export default function TitleField(props: TitleFieldProps) {
const { id, title, required, schema, registry, uiSchema } = props; const { id, title, required, schema, registry, uiSchema } = props;
@@ -27,11 +26,6 @@ export default function TitleField(props: TitleFieldProps) {
const smallText = isArrayItemFlag || additional; const smallText = isArrayItemFlag || additional;
const showHandle = uiOptions.showHandles ?? showHandles; const showHandle = uiOptions.showHandles ?? showHandles;
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{showHandle !== false && ( {showHandle !== false && (
@@ -40,11 +34,7 @@ export default function TitleField(props: TitleFieldProps) {
<Text <Text
variant={isArrayItemFlag ? "small" : "body"} variant={isArrayItemFlag ? "small" : "body"}
id={id} id={id}
className={cn( className={cn("line-clamp-1", smallText && "text-sm text-zinc-700")}
"line-clamp-1",
smallText && "text-sm text-zinc-700",
isInputBroken && "text-red-500 line-through",
)}
> >
{title} {title}
</Text> </Text>
@@ -54,7 +44,7 @@ export default function TitleField(props: TitleFieldProps) {
{!isAnyOf && ( {!isAnyOf && (
<Text <Text
variant="small" variant="small"
className={cn("ml-2", isInputBroken && "line-through", colorClass)} className={cn("ml-2", colorClass)}
id={description_id} id={description_id}
> >
({displayType}) ({displayType})

View File

@@ -30,8 +30,6 @@ export function updateUiOption<T extends Record<string, any>>(
} }
export const cleanUpHandleId = (handleId: string) => { export const cleanUpHandleId = (handleId: string) => {
if (!handleId) return "";
let newHandleId = handleId; let newHandleId = handleId;
if (handleId.includes(ANY_OF_FLAG)) { if (handleId.includes(ANY_OF_FLAG)) {
newHandleId = newHandleId.replace(ANY_OF_FLAG, ""); newHandleId = newHandleId.replace(ANY_OF_FLAG, "");

View File

@@ -233,14 +233,13 @@ export default function useAgentGraph(
title: `${block.name} ${node.id}`, title: `${block.name} ${node.id}`,
inputSchema: block.inputSchema, inputSchema: block.inputSchema,
outputSchema: block.outputSchema, outputSchema: block.outputSchema,
isOutputStatic: block.staticOutput,
hardcodedValues: node.input_default, hardcodedValues: node.input_default,
uiType: block.uiType, uiType: block.uiType,
metadata: metadata, metadata: metadata,
connections: graph.links connections: graph.links
.filter((l) => [l.source_id, l.sink_id].includes(node.id)) .filter((l) => [l.source_id, l.sink_id].includes(node.id))
.map((link) => ({ .map((link) => ({
id: formatEdgeID(link), edge_id: formatEdgeID(link),
source: link.source_id, source: link.source_id,
sourceHandle: link.source_name, sourceHandle: link.source_name,
target: link.sink_id, target: link.sink_id,

View File

@@ -245,8 +245,8 @@ export type BlockIONullSubSchema = BlockIOSubSchemaMeta & {
// At the time of writing, combined schemas only occur on the first nested level in a // At the time of writing, combined schemas only occur on the first nested level in a
// block schema. It is typed this way to make the use of these objects less tedious. // block schema. It is typed this way to make the use of these objects less tedious.
type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & { type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & {
type?: never; type: never;
const?: never; const: never;
} & ( } & (
| { | {
allOf: [BlockIOSimpleTypeSubSchema]; allOf: [BlockIOSimpleTypeSubSchema];
@@ -368,8 +368,8 @@ export type GraphMeta = {
recommended_schedule_cron: string | null; recommended_schedule_cron: string | null;
forked_from_id?: GraphID | null; forked_from_id?: GraphID | null;
forked_from_version?: number | null; forked_from_version?: number | null;
input_schema: GraphInputSchema; input_schema: GraphIOSchema;
output_schema: GraphOutputSchema; output_schema: GraphIOSchema;
credentials_input_schema: CredentialsInputSchema; credentials_input_schema: CredentialsInputSchema;
} & ( } & (
| { | {
@@ -385,51 +385,19 @@ export type GraphMeta = {
export type GraphID = Brand<string, "GraphID">; export type GraphID = Brand<string, "GraphID">;
/* Derived from backend/data/graph.py:Graph._generate_schema() */ /* Derived from backend/data/graph.py:Graph._generate_schema() */
export type GraphInputSchema = { export type GraphIOSchema = {
type: "object"; type: "object";
properties: Record<string, GraphInputSubSchema>; properties: Record<string, GraphIOSubSchema>;
required: (keyof GraphInputSchema["properties"])[]; required: (keyof BlockIORootSchema["properties"])[];
}; };
export type GraphInputSubSchema = GraphOutputSubSchema & export type GraphIOSubSchema = Omit<
( BlockIOSubSchemaMeta,
| { type?: never; default: any | null } // AgentInputBlock (generic Any type) "placeholder" | "depends_on" | "hidden"
| { type: "string"; format: "short-text"; default: string | null } // AgentShortTextInputBlock > & {
| { type: "string"; format: "long-text"; default: string | null } // AgentLongTextInputBlock type: never; // bodge to avoid type checking hell; doesn't exist at runtime
| { type: "integer"; default: number | null } // AgentNumberInputBlock default?: string;
| { type: "string"; format: "date"; default: string | null } // AgentDateInputBlock
| { type: "string"; format: "time"; default: string | null } // AgentTimeInputBlock
| { type: "string"; format: "file"; default: string | null } // AgentFileInputBlock
| { type: "string"; enum: string[]; default: string | null } // AgentDropdownInputBlock
| { type: "boolean"; default: boolean } // AgentToggleInputBlock
| {
// AgentTableInputBlock
type: "array";
format: "table";
items: {
type: "object";
properties: Record<string, { type: "string" }>;
};
default: Array<Record<string, string>> | null;
}
| {
// AgentGoogleDriveFileInputBlock
type: "object";
format: "google-drive-picker";
google_drive_picker_config?: GoogleDrivePickerConfig;
default: GoogleDriveFile | null;
}
);
export type GraphOutputSchema = {
type: "object";
properties: Record<string, GraphOutputSubSchema>;
required: (keyof GraphOutputSchema["properties"])[];
};
export type GraphOutputSubSchema = {
// TODO: typed outputs based on the incoming edges?
title: string;
description?: string;
advanced: boolean;
secret: boolean; secret: boolean;
metadata?: any;
}; };
export type CredentialsInputSchema = { export type CredentialsInputSchema = {
@@ -472,8 +440,8 @@ export type GraphUpdateable = Omit<
is_active?: boolean; is_active?: boolean;
nodes: NodeCreatable[]; nodes: NodeCreatable[];
links: LinkCreatable[]; links: LinkCreatable[];
input_schema?: GraphInputSchema; input_schema?: GraphIOSchema;
output_schema?: GraphOutputSchema; output_schema?: GraphIOSchema;
}; };
export type GraphCreatable = _GraphCreatableInner & { export type GraphCreatable = _GraphCreatableInner & {
@@ -529,8 +497,8 @@ export type LibraryAgent = {
name: string; name: string;
description: string; description: string;
instructions?: string | null; instructions?: string | null;
input_schema: GraphInputSchema; input_schema: GraphIOSchema;
output_schema: GraphOutputSchema; output_schema: GraphIOSchema;
credentials_input_schema: CredentialsInputSchema; credentials_input_schema: CredentialsInputSchema;
new_output: boolean; new_output: boolean;
can_access_graph: boolean; can_access_graph: boolean;

View File

@@ -6,10 +6,7 @@ import { NodeDimension } from "@/app/(platform)/build/components/legacy-builder/
import { import {
BlockIOObjectSubSchema, BlockIOObjectSubSchema,
BlockIORootSchema, BlockIORootSchema,
BlockIOSubSchema,
Category, Category,
GraphInputSubSchema,
GraphOutputSubSchema,
} from "@/lib/autogpt-server-api/types"; } from "@/lib/autogpt-server-api/types";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -79,8 +76,8 @@ export function getTypeBgColor(type: string | null): string {
); );
} }
export function getTypeColor(type: string | undefined): string { export function getTypeColor(type: string | null): string {
if (!type) return "#6b7280"; if (type === null) return "#6b7280";
return ( return (
{ {
string: "#22c55e", string: "#22c55e",
@@ -91,59 +88,11 @@ export function getTypeColor(type: string | undefined): string {
array: "#6366f1", array: "#6366f1",
null: "#6b7280", null: "#6b7280",
any: "#6b7280", any: "#6b7280",
"": "#6b7280",
}[type] || "#6b7280" }[type] || "#6b7280"
); );
} }
/**
* Extracts the effective type from a JSON schema, handling anyOf/oneOf/allOf wrappers.
* Returns the first non-null type found in the schema structure.
*/
export function getEffectiveType(
schema:
| BlockIOSubSchema
| GraphInputSubSchema
| GraphOutputSubSchema
| null
| undefined,
): string | undefined {
if (!schema) return undefined;
// Direct type property
if ("type" in schema && schema.type) {
return String(schema.type);
}
// Handle allOf - typically a single-item wrapper
if (
"allOf" in schema &&
Array.isArray(schema.allOf) &&
schema.allOf.length > 0
) {
return getEffectiveType(schema.allOf[0]);
}
// Handle anyOf - e.g. [{ type: "string" }, { type: "null" }]
if ("anyOf" in schema && Array.isArray(schema.anyOf)) {
for (const item of schema.anyOf) {
if ("type" in item && item.type !== "null") {
return String(item.type);
}
}
}
// Handle oneOf
if ("oneOf" in schema && Array.isArray(schema.oneOf)) {
for (const item of schema.oneOf) {
if ("type" in item && item.type !== "null") {
return String(item.type);
}
}
}
return undefined;
}
export function beautifyString(name: string): string { export function beautifyString(name: string): string {
// Regular expression to identify places to split, considering acronyms // Regular expression to identify places to split, considering acronyms
const result = name const result = name