feat: new tool mode dialog and UI (#7647)

* Add tools input to the backend

* Add tools type

* Add tools component to parameter render

* Added tools to langflow supported types

* Add tools modal

* Instantiate tools component in parameter render

* Removed div when there is not any visible actions

* Added margin

* Add ComboBoxItem and ListItem components for enhanced selection UI

* Update ComboBoxItem to display item description and enhance ToolsModal layout

* Refactor ToolsModal header styling for improved layout and icon padding

* Enhance ComboBoxItem layout and styling; update ToolsModal size and class for better responsiveness

* Changed display name

* Adds truncate for badges

* Adds custom styling for table used for Tools modal

* Added Tools modal with AgGrid

* Changed button

* made name and description editable

* Parse values for saving

* Add focused row without triggering checkbox click

* [autofix.ci] apply automated fixes

* Added types for tools modal

* added toolsTable with sidebar on toolsModal

* Added changes to work with MCP

* update component.py to add display name and display description in tool mode

* removed editing directly

* Fixed editing

* removed to upper case

* Make editing apply filters

* Adds design changes for MCP

* Adds new design and null check

* 📝 (frontend): add data-testid attribute to elements for testing purposes
🔧 (frontend): update data-testid attribute values for consistency and clarity in testing
 (frontend): update tests to use correct selectors and improve test coverage for editing tools functionality

*  (edit-tools.spec.ts): update test to use a more reliable method for checking visibility of an element

*  (edit-tools.spec.ts): add "@components" tag to the test to categorize it under components for better organization and filtering in test suites.

* Updated design with new design

* update padding

* send args to tools data

* Implemented showing arguments passed to LLM in tool mode

* add componentg name to description

* update package lock

* fixed tests

* fixed backend test

* fixed backend test

* fixed formatting

* Fixed frontend tests

* updated font sizes for badges on actions and styling on sortable list

* Update tool mode design

* added tooltips for info

* tool name update

* Update component_tool.py

* styling utils

* default values change

* fixed tools test

* fix format issues

---------

Co-authored-by: deon-sanchez <deon.sanchez@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>
This commit is contained in:
Lucas Oliveira 2025-04-28 20:24:48 -03:00 committed by GitHub
commit b1217f96fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1045 additions and 188 deletions

View file

@ -273,12 +273,14 @@ class ComposioBaseComponent(Component):
def configure_tools(self, toolset: ComposioToolSet) -> list[Tool]:
tools = toolset.get_tools(actions=self._actions_data.keys())
logger.info(f"Tools: {tools}")
configured_tools = []
for tool in tools:
# Set the sanitized name
tool.name = self._sanitized_names.get(tool.name, self._name_sanitizer.sub("-", tool.name))
display_name = self._sanitized_names.get(tool.name, self._name_sanitizer.sub("-", tool.name))
# Set the tags
tool.tags = [tool.name]
tool.metadata = {"display_name": display_name, "display_description": tool.description}
configured_tools.append(tool)
return configured_tools

View file

@ -42,19 +42,19 @@ def _get_input_type(input_: InputTypes):
def build_description(component: Component, output: Output) -> str:
if not output.required_inputs:
logger.warning(f"Output {output.name} does not have required inputs defined")
name = component.name or component.__class__.__name__
if output.required_inputs:
args = ", ".join(
sorted(
[
f"{input_name}: {_get_input_type(component._inputs[input_name])}"
f"{name}. {input_name}: {_get_input_type(component._inputs[input_name])}"
for input_name in output.required_inputs
]
)
)
else:
args = ""
return f"{output.method}({args}) - {component.description}"
return f"{name}. {output.method}({args}) - {component.description}"
async def send_message_noop(
@ -239,7 +239,7 @@ class ComponentToolkit:
else:
args_schema = create_input_schema(self.component.inputs)
name = f"{self.component.name or self.component.__class__.__name__ or ''}.{output.method}".strip(".")
name = f"{output.method}".strip(".")
formatted_name = _format_tool_name(name)
event_manager = self.component._event_manager
if asyncio.iscoroutinefunction(output_method):
@ -252,6 +252,10 @@ class ComponentToolkit:
handle_tool_error=True,
callbacks=callbacks,
tags=[formatted_name],
metadata={
"display_name": formatted_name,
"display_description": build_description(self.component, output),
},
)
)
else:
@ -264,6 +268,10 @@ class ComponentToolkit:
handle_tool_error=True,
callbacks=callbacks,
tags=[formatted_name],
metadata={
"display_name": formatted_name,
"display_description": build_description(self.component, output),
},
)
)
if len(tools) == 1 and (tool_name or tool_description):

View file

@ -394,6 +394,6 @@ class ComposioGmailAPIComponent(ComposioBaseComponent):
def set_default_tools(self):
self._default_tools = {
self.sanitize_action_name("GMAIL_SEND_EMAIL").replace(" ", "-"),
self.sanitize_action_name("GMAIL_FETCH_EMAILS").replace(" ", "-"),
"GMAIL_SEND_EMAIL",
"GMAIL_FETCH_EMAILS",
}

View file

@ -581,6 +581,6 @@ class ComposioSlackAPIComponent(ComposioBaseComponent):
def set_default_tools(self):
self._default_tools = {
self.sanitize_action_name("SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL").replace(" ", "-"),
self.sanitize_action_name("SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY").replace(" ", "-"),
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
}

View file

@ -18,7 +18,6 @@ from pydantic import BaseModel, ValidationError
from langflow.base.tools.constants import (
TOOL_OUTPUT_DISPLAY_NAME,
TOOL_OUTPUT_NAME,
TOOL_TABLE_SCHEMA,
TOOLS_METADATA_INFO,
TOOLS_METADATA_INPUT_NAME,
)
@ -33,7 +32,6 @@ from langflow.schema.artifact import get_artifact_type, post_process_raw
from langflow.schema.data import Data
from langflow.schema.message import ErrorMessage, Message
from langflow.schema.properties import Source
from langflow.schema.table import FieldParserType, TableOptions
from langflow.services.tracing.schema import Log
from langflow.template.field.base import UNDEFINED, Input, Output
from langflow.template.frontend_node.custom_components import ComponentFrontendNode
@ -1174,10 +1172,14 @@ class Component(CustomComponent):
"description": tool.description,
"tags": tool.tags if hasattr(tool, "tags") and tool.tags else [tool.name],
"status": True, # Initialize all tools with status True
"display_name": tool.metadata.get("display_name", tool.name),
"display_description": tool.metadata.get("display_description", tool.description),
"args": tool.args,
# "args_schema": tool.args_schema,
}
for tool in tools
]
# print(tool_data)
if hasattr(self, TOOLS_METADATA_INPUT_NAME):
old_tags = self._extract_tools_tags(self.tools_metadata)
new_tags = self._extract_tools_tags(tool_data)
@ -1203,35 +1205,16 @@ class Component(CustomComponent):
self.tools_metadata = tool_data
try:
from langflow.io import TableInput
from langflow.io import ToolsInput
except ImportError as e:
msg = "Failed to import TableInput from langflow.io"
msg = "Failed to import ToolsInput from langflow.io"
raise ImportError(msg) from e
return TableInput(
return ToolsInput(
name=TOOLS_METADATA_INPUT_NAME,
display_name="Edit tools",
real_time_refresh=True,
table_schema=TOOL_TABLE_SCHEMA,
display_name="Actions",
info=TOOLS_METADATA_INFO,
value=tool_data,
table_icon="Hammer",
trigger_icon="Hammer",
trigger_text="",
table_options=TableOptions(
block_add=True,
block_delete=True,
block_edit=True, # Allow editing for status toggle
block_sort=True,
block_filter=True,
block_hide=True,
block_select=True,
hide_options=True,
field_parsers={
"name": [FieldParserType.SNAKE_CASE, FieldParserType.NO_BLANK],
"commands": FieldParserType.COMMANDS,
},
description=TOOLS_METADATA_INFO,
),
)
def get_project_name(self):

View file

@ -242,7 +242,7 @@ class ParameterHandler:
params[field_name] = val
case str():
params[field_name] = bool(val)
case "table":
case "table" | "tools":
if isinstance(val, list) and all(isinstance(item, dict) for item in val):
params[field_name] = pd.DataFrame(val)
else:

View file

@ -28,6 +28,7 @@ from .inputs import (
StrInput,
TabInput,
TableInput,
ToolsInput,
)
__all__ = [
@ -62,4 +63,5 @@ __all__ = [
"StrInput",
"TabInput",
"TableInput",
"ToolsInput",
]

View file

@ -36,6 +36,7 @@ class FieldTypes(str, Enum):
SLIDER = "slider"
TAB = "tab"
QUERY = "query"
TOOLS = "tools"
SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)]

View file

@ -87,6 +87,21 @@ class HandleInput(BaseInputMixin, ListableInputMixin, MetadataTraceMixin):
field_type: SerializableFieldTypes = FieldTypes.OTHER
class ToolsInput(BaseInputMixin, ListableInputMixin, MetadataTraceMixin, ToolModeMixin):
"""Represents an Input that contains a list of tools to activate, deactivate, or edit.
Attributes:
field_type (SerializableFieldTypes): The field type of the input.
value (list[dict]): The value of the input.
"""
field_type: SerializableFieldTypes = FieldTypes.TOOLS
value: list[dict] = Field(default_factory=list)
is_list: bool = True
real_time_refresh: bool = True
class DataInput(HandleInput, InputTraceMixin, ListableInputMixin, ToolModeMixin):
"""Represents an Input that has a Handle that receives a Data object.
@ -644,6 +659,7 @@ InputTypes: TypeAlias = (
| MultilineInput
| MultilineSecretInput
| NestedDictInput
| ToolsInput
| PromptInput
| CodeInput
| SecretStrInput

View file

@ -25,6 +25,7 @@ from langflow.inputs import (
StrInput,
TabInput,
TableInput,
ToolsInput,
)
from langflow.template import Output
@ -57,4 +58,5 @@ __all__ = [
"StrInput",
"TabInput",
"TableInput",
"ToolsInput",
]

View file

@ -69,6 +69,7 @@ DIRECT_TYPES = [
"auth",
"connect",
"query",
"tools",
]

View file

@ -15,7 +15,7 @@ def test_component_tool():
calculator_component = CalculatorToolComponent()
component_toolkit = ComponentToolkit(component=calculator_component)
component_tool = component_toolkit.get_tools()[0]
assert component_tool.name == "CalculatorTool-run_model"
assert component_tool.name == "run_model"
assert issubclass(component_tool.args_schema, BaseModel)
# TODO: fix this
# assert component_tool.args_schema.model_json_schema()["properties"] == {

View file

@ -122,7 +122,7 @@ def test_component_inputs_toolkit():
component = AllInputsComponent()
component_toolkit = ComponentToolkit(component=component)
component_tool = component_toolkit.get_tools()[0]
assert component_tool.name == "AllInputsComponent-build_output"
assert component_tool.name == "build_output"
assert issubclass(component_tool.args_schema, BaseModel)
properties = component_tool.args_schema.model_json_schema()["properties"]

View file

@ -1,12 +1,12 @@
{
"name": "langflow",
"version": "1.3.3",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langflow",
"version": "1.3.3",
"version": "1.3.4",
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
"@headlessui/react": "^2.0.4",

View file

@ -0,0 +1,64 @@
import { Button } from "@/components/ui/button";
import { useState } from "react";
interface ComboBoxItemProps {
item: any;
}
const ComboBoxItem = ({ item }: ComboBoxItemProps) => {
const [isChecked, setIsChecked] = useState(false);
return (
<div className="flex flex-row pb-1">
<div className="inline-flex justify-start">
<label
className="relative flex cursor-pointer"
htmlFor={`check-${item?.name}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => setIsChecked(!isChecked)}
className="peer h-5 w-5 cursor-pointer appearance-none rounded border border-border border-muted-foreground shadow transition-all hover:shadow-md"
id={`check-${item?.name}`}
/>
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform text-black opacity-0 peer-checked:bg-primary peer-checked:opacity-100">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
stroke="currentColor"
stroke-width="1"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
</span>
</label>
<Button
unstyled
className="mx-2 flex w-full justify-start rounded-md hover:bg-accent"
>
<label
className="ml-2 flex w-72 cursor-pointer text-sm font-bold text-primary"
htmlFor={`check-${item?.name}`}
>
<span className="truncate">{item?.name}</span>
</label>
<label
className="flex w-72 cursor-pointer text-sm text-muted-foreground"
htmlFor={`check-${item?.name}`}
>
<span className="truncate">{item?.description}</span>
</label>
</Button>
</div>
</div>
);
};
export default ComboBoxItem;

View file

@ -0,0 +1,118 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/utils";
import { useEffect, useRef, useState } from "react";
const ListItem = ({
item,
isSelected,
onClick,
className,
onMouseEnter,
onMouseLeave,
isFocused,
isKeyboardNavActive,
dataTestId,
}: {
item: any;
isSelected: boolean;
onClick: () => void;
className?: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
isFocused: boolean;
isKeyboardNavActive: boolean;
dataTestId: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
const itemRef = useRef<HTMLButtonElement>(null);
// Clear hover state when keyboard navigation is active
useEffect(() => {
if (isKeyboardNavActive) {
setIsHovered(false);
}
}, [isKeyboardNavActive]);
// Scroll into view when focused by keyboard
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.scrollIntoView({ block: "nearest" });
}
}, [isFocused]);
return (
<Button
ref={itemRef}
key={item.id}
data-testid={dataTestId}
unstyled
size="sm"
className={cn(
"group flex w-full rounded-md px-2 py-0.5",
!isKeyboardNavActive && "hover:bg-muted", // Only apply hover styles when not in keyboard nav
!item.metaData && "py-2.5",
isFocused && "bg-muted",
className,
)}
onClick={onClick}
onMouseEnter={() => {
if (!isKeyboardNavActive) {
setIsHovered(true);
onMouseEnter();
}
}}
onMouseLeave={() => {
setIsHovered(false);
onMouseLeave();
}}
// Disable pointer events during keyboard navigation
style={{ pointerEvents: isKeyboardNavActive ? "none" : "auto" }}
>
<div className="flex w-full items-center gap-2">
{item.icon && (
<div>
<ForwardedIconComponent name={item.icon} className="mr-2 h-4 w-4" />
</div>
)}
<div className="flex w-full flex-col truncate">
<div className="flex w-full truncate text-mmd font-semibold">
<span className="truncate">{item.name}</span>
</div>
{"metaData" in item && item.metaData && (
<div className="flex w-full truncate text-mmd text-gray-500">
<span className="truncate">{item.metaData}</span>
</div>
)}
</div>
{isHovered || isFocused ? (
<div className="ml-auto flex items-center justify-start rounded-md">
<div className="flex items-center pr-1.5 text-mmd font-semibold text-muted-foreground">
Select
</div>
<div className="flex items-center justify-center rounded-md">
<ForwardedIconComponent
name="corner-down-left"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</div>
</div>
) : (
// Always show the check icon when selected, regardless of hover/focus state
isSelected && (
<ForwardedIconComponent
name="check"
className={cn(
"ml-auto flex h-4 w-4",
item.link === "validated" && "text-green-500",
)}
/>
)
)}
</div>
</Button>
);
};
export default ListItem;

View file

@ -1,12 +1,11 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import SearchBarComponent from "@/components/core/parameterRenderComponent/components/searchBarComponent";
import { InputProps } from "@/components/core/parameterRenderComponent/types";
import { Button } from "@/components/ui/button";
import { DialogHeader } from "@/components/ui/dialog";
import { Dialog, DialogContent } from "@/components/ui/dialog-with-no-close";
import { cn, testIdCase } from "@/utils/utils";
import { testIdCase } from "@/utils/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ListItem from "./ListItem";
// Update interface with better types
interface ListSelectionComponentProps {
@ -20,118 +19,6 @@ interface ListSelectionComponentProps {
limit?: number;
}
const ListItem = ({
item,
isSelected,
onClick,
className,
onMouseEnter,
onMouseLeave,
isFocused,
isKeyboardNavActive,
dataTestId,
}: {
item: any;
isSelected: boolean;
onClick: () => void;
className?: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
isFocused: boolean;
isKeyboardNavActive: boolean;
dataTestId: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
const itemRef = useRef<HTMLButtonElement>(null);
// Clear hover state when keyboard navigation is active
useEffect(() => {
if (isKeyboardNavActive) {
setIsHovered(false);
}
}, [isKeyboardNavActive]);
// Scroll into view when focused by keyboard
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.scrollIntoView({ block: "nearest" });
}
}, [isFocused]);
return (
<Button
ref={itemRef}
key={item.id}
data-testid={dataTestId}
unstyled
size="sm"
className={cn(
"group flex w-full rounded-md px-2 py-0.5",
!isKeyboardNavActive && "hover:bg-muted", // Only apply hover styles when not in keyboard nav
!item.metaData && "py-2.5",
isFocused && "bg-muted",
className,
)}
onClick={onClick}
onMouseEnter={() => {
if (!isKeyboardNavActive) {
setIsHovered(true);
onMouseEnter();
}
}}
onMouseLeave={() => {
setIsHovered(false);
onMouseLeave();
}}
// Disable pointer events during keyboard navigation
style={{ pointerEvents: isKeyboardNavActive ? "none" : "auto" }}
>
<div className="flex w-full items-center gap-2">
{item.icon && (
<div>
<ForwardedIconComponent name={item.icon} className="mr-2 h-4 w-4" />
</div>
)}
<div className="flex w-full flex-col truncate">
<div className="flex w-full truncate text-mmd font-semibold">
<span className="truncate">{item.name}</span>
</div>
{"metaData" in item && item.metaData && (
<div className="flex w-full truncate text-mmd text-gray-500">
<span className="truncate">{item.metaData}</span>
</div>
)}
</div>
{isHovered || isFocused ? (
<div className="ml-auto flex items-center justify-start rounded-md">
<div className="flex items-center pr-1.5 text-mmd font-semibold text-muted-foreground">
Select
</div>
<div className="flex items-center justify-center rounded-md">
<ForwardedIconComponent
name="corner-down-left"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</div>
</div>
) : (
// Always show the check icon when selected, regardless of hover/focus state
isSelected && (
<ForwardedIconComponent
name="check"
className={cn(
"ml-auto flex h-4 w-4",
item.link === "validated" && "text-green-500",
)}
/>
)
)}
</div>
</Button>
);
};
const ListSelectionComponent = ({
open,
onClose,
@ -286,7 +173,7 @@ const ListSelectionComponent = ({
name={nodeClass?.icon || "unknown"}
className="h-[18px] w-[18px] text-muted-foreground"
/>
<div className="text-mmd font-semibold">
<div className="text-[13px] font-semibold">
{nodeClass?.display_name}
</div>
</div>

View file

@ -78,7 +78,11 @@ const RenderInputParameters = ({
(templateField: string, idx) => {
const template = data.node?.template[templateField];
if (!template?.show || template?.advanced) {
if (
!template?.show ||
template?.advanced ||
(template?.tool_mode && isToolMode)
) {
return null;
}

View file

@ -0,0 +1,142 @@
import { ICON_STROKE_WIDTH } from "@/constants/constants";
import ToolsModal from "@/modals/toolsModal";
import { cn, testIdCase } from "@/utils/utils";
import { useState } from "react";
import { ForwardedIconComponent } from "../../../../common/genericIconComponent";
import { Badge } from "../../../../ui/badge";
import { Button } from "../../../../ui/button";
import { Skeleton } from "../../../../ui/skeleton";
import { InputProps, ToolsComponentType } from "../../types";
export default function ToolsComponent({
description,
value,
editNode = false,
id = "",
handleOnNewValue,
isAction = false,
button_description,
title,
icon,
disabled = false,
template,
}: InputProps<any[] | undefined, ToolsComponentType>): JSX.Element {
const [isModalOpen, setIsModalOpen] = useState(false);
const actions = value
?.filter((action) => action.status === true)
.map((action) => {
return {
name: action.name,
tags: action.tags,
description: action.description,
};
});
const visibleActionsQt = isAction ? 20 : 4;
const visibleActions = actions?.slice(0, visibleActionsQt) || [];
const remainingCount = actions
? Math.max(0, actions.length - visibleActionsQt)
: 0;
return (
<div
className={cn(
"flex w-full items-center",
disabled && "cursor-not-allowed",
)}
>
{value && (
<ToolsModal
open={isModalOpen}
setOpen={setIsModalOpen}
isAction={isAction}
description={description}
template={template}
rows={value}
handleOnNewValue={handleOnNewValue}
title={title}
icon={icon}
/>
)}
<div
className="relative flex w-full items-center gap-3"
data-testid={"div-" + id}
>
{(visibleActions.length > 0 || isAction) && (
<Button
variant={"ghost"}
disabled={!value || disabled}
size={"iconMd"}
className={cn(
"absolute -top-8 right-0 font-semibold text-muted-foreground group-hover:text-primary",
)}
data-testid="button_open_actions"
onClick={() => setIsModalOpen(true)}
>
<ForwardedIconComponent
name="Settings2"
className="icon-size"
strokeWidth={ICON_STROKE_WIDTH}
/>
{button_description}
</Button>
)}
{!value ? (
<div className="flex w-full flex-wrap gap-1 overflow-hidden py-1.5">
{[...Array(4)].map((_, index) => (
<Skeleton key={index} className="h-6 w-20 rounded-full" />
))}
</div>
) : visibleActions.length > 0 ? (
<div className="flex w-full flex-wrap gap-1 overflow-hidden py-1.5">
{visibleActions.map((action, index) => (
<Badge
key={index}
variant="secondaryStatic"
size="sq"
className="truncate font-normal"
data-testid={testIdCase(`tool_${action.name}`)}
>
<span className="truncate text-xxs font-medium">
{action.name.toUpperCase()}
</span>
</Badge>
))}
{remainingCount > 0 && (
<span className="ml-1 self-center text-xs font-normal text-muted-foreground">
+{remainingCount} more
</span>
)}
</div>
) : (
visibleActions.length === 0 &&
isAction && (
<div className="mt-2 flex w-full flex-col items-center gap-2 rounded-md border border-dashed p-8">
<span className="text-sm text-muted-foreground">
No actions added to this server
</span>
<Button size={"sm"} onClick={() => setIsModalOpen(true)}>
<span>Add actions</span>
</Button>
</div>
)
)}
{visibleActions.length === 0 && !isAction && value && (
<Button
disabled={disabled}
size={editNode ? "xs" : "default"}
className={
"w-full " +
(disabled ? "pointer-events-none cursor-not-allowed" : "")
}
onClick={() => setIsModalOpen(true)}
>
<span>Select actions</span>
</Button>
)}
</div>
</div>
);
}

View file

@ -32,27 +32,27 @@ const SortableListItem = memo(
}) => (
<li
className={cn(
"inline-flex h-12 w-full items-center gap-2 text-sm font-medium text-gray-800",
"inline-flex h-12 w-full items-center gap-2 text-sm font-medium",
limit === 1 ? "h-6 rounded-md bg-muted" : "group cursor-grab",
)}
>
{limit !== 1 && (
<ForwardedIconComponent
name="grid-horizontal"
className="h-5 w-5 fill-gray-300 text-gray-300"
name="GridHorizontal"
className="h-5 w-5 text-muted-foreground"
/>
)}
<div className="flex w-full items-center gap-x-2">
{limit !== 1 && (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-400 text-center text-white">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-border text-center text-mmd text-primary">
{index + 1}
</div>
)}
<span
className={cn(
"truncate text-muted-foreground",
"truncate text-xxs font-medium text-muted-foreground",
limit === 1 ? "max-w-56 pl-2" : "max-w-48",
)}
>
@ -61,22 +61,16 @@ const SortableListItem = memo(
</div>
<Button
size="icon"
variant={limit !== 1 ? "outline" : "ghost"}
variant={"ghost"}
className={cn(
"ml-auto h-6 w-6 opacity-0 transition-opacity duration-200",
"ml-auto h-6 w-6 text-muted-foreground opacity-0 transition-opacity duration-200",
limit === 1
? "group pr-1 opacity-100"
: "hover:border hover:border-destructive hover:bg-transparent hover:opacity-100",
? "group pr-1 opacity-100 hover:text-foreground"
: "hover:text-destructive group-hover:opacity-100",
)}
onClick={onRemove}
>
<ForwardedIconComponent
name="x"
className={cn(
"h-6 w-6 text-red-500",
limit === 1 && "text-gray-500 group-hover:text-input",
)}
/>
<ForwardedIconComponent name="x" className={cn("h-6 w-6")} />
</Button>
</li>
),

View file

@ -6,6 +6,7 @@ import TabComponent from "@/components/core/parameterRenderComponent/components/
import { TEXT_FIELD_TYPES } from "@/constants/constants";
import { APIClassType, InputFieldType } from "@/types/api";
import { useMemo } from "react";
import ToolsComponent from "./components/ToolsComponent";
import ConnectionComponent from "./components/connectionComponent";
import DictComponent from "./components/dictComponent";
import { EmptyParameterComponent } from "./components/emptyParameterComponent";
@ -203,6 +204,16 @@ export function ParameterRenderComponent({
table_icon={templateData?.table_icon}
/>
);
case "tools":
return (
<ToolsComponent
{...baseInputProps}
description={templateData.info || "Add or edit data"}
title={nodeClass?.display_name ?? "Tools"}
icon={nodeClass?.icon ?? ""}
template={nodeClass?.template}
/>
);
case "slider":
return (
<SliderComponent

View file

@ -1,5 +1,10 @@
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APIClassType, InputFieldType, TableOptionsTypeAPI } from "@/types/api";
import {
APIClassType,
APITemplateType,
InputFieldType,
TableOptionsTypeAPI,
} from "@/types/api";
import { RangeSpecType } from "@/types/components";
import { ColumnField } from "@/types/utils/functions";
@ -40,6 +45,15 @@ export type TableComponentType = {
table_icon?: string;
};
export type ToolsComponentType = {
description: string;
title: string;
icon?: string;
button_description?: string;
isAction?: boolean;
template?: APITemplateType;
};
export type FloatComponentType = {
rangeSpec: RangeSpecType;
};

View file

@ -659,6 +659,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
"connect",
"auth",
"query",
"tools",
]);
export const FLEX_VIEW_TYPES = ["bool"];

View file

@ -0,0 +1,386 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import TableComponent from "@/components/core/parameterRenderComponent/components/tableComponent";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
useSidebar,
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APITemplateType } from "@/types/api";
import { parseString } from "@/utils/stringManipulation";
import { ColDef } from "ag-grid-community";
import { AgGridReact } from "ag-grid-react";
import { cloneDeep } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
export default function ToolsTable({
rows,
data,
setData,
isAction,
open,
handleOnNewValue,
template,
}: {
rows: any[];
data: any[];
template?: APITemplateType;
setData: (data: any[]) => void;
open: boolean;
handleOnNewValue: handleOnNewValueType;
isAction: boolean;
}) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<any[] | null>(null);
const agGrid = useRef<AgGridReact>(null);
const [focusedRow, setFocusedRow] = useState<any | null>(null);
const [sidebarName, setSidebarName] = useState<string>("");
const [sidebarDescription, setSidebarDescription] = useState<string>("");
const { setOpen: setSidebarOpen } = useSidebar();
const getRowId = useMemo(() => {
return (params: any) => params.data.display_name ?? params.data.name;
}, []);
useEffect(() => {
const initialData = cloneDeep(rows);
setData(initialData);
const filter = initialData.filter((row) => row.status === true);
setSelectedRows(filter);
}, [rows, open]);
useEffect(() => {
const initialData = cloneDeep(rows);
const filter = initialData.filter((row) => row.status === true);
if (agGrid.current) {
agGrid.current?.api?.forEachNode((node) => {
if (
filter.some(
(row) =>
(row.display_name ?? row.name) ===
(node.data.display_name ?? node.data.name),
)
) {
node.setSelected(true);
} else {
node.setSelected(false);
}
});
}
}, [agGrid.current]);
useEffect(() => {
if (!open && selectedRows) {
handleOnNewValue({
value: data.map((row) => {
const processedValue = (
row.name !== ""
? parseString(row.name, ["snake_case", "no_blank", "lowercase"])
: parseString(row.display_name, [
"snake_case",
"no_blank",
"lowercase",
])
).slice(0, 46);
const processedDescription =
row.description !== "" ? row.description : row.display_description;
return selectedRows?.some(
(selected) =>
(selected.display_name ?? selected.name) ===
(row.display_name ?? row.name),
)
? {
...row,
status: true,
name: processedValue,
description: processedDescription,
}
: {
...row,
status: false,
name: processedValue,
description: processedDescription,
};
}),
});
}
}, [open]);
useEffect(() => {
if (focusedRow) {
setSidebarName(focusedRow.name);
setSidebarDescription(focusedRow.description);
} else {
setSidebarName("");
setSidebarDescription("");
}
}, [focusedRow]);
const columnDefs: ColDef[] = [
{
field: isAction ? "display_name" : "name",
headerName: isAction ? "Flow Name" : "Name",
flex: 1,
valueGetter: (params) =>
!isAction
? parseString(
params.data.display_name !== ""
? params.data.display_name
: params.data.name,
["space_case"],
)
: params.data.display_name,
},
{
field: "description",
headerName: "Description",
flex: 2,
cellClass: "text-muted-foreground",
},
{
field: isAction ? "name" : "tags",
headerName: isAction ? "Action" : "Slug",
flex: 1,
resizable: false,
valueGetter: (params) =>
isAction
? params.data.name !== ""
? parseString(params.data.name, [
"snake_case",
"no_blank",
"uppercase",
])
: parseString(params.data.display_name, [
"snake_case",
"no_blank",
"uppercase",
])
: parseString(params.data.tags.join(", "), [
"snake_case",
"uppercase",
]),
cellClass: "text-muted-foreground",
},
{
field: "tags",
headerName: "Tags",
flex: 1,
hide: true,
},
];
const handleSelectionChanged = (event) => {
if (open) {
const selectedData = event.api.getSelectedRows();
setSelectedRows(selectedData);
}
};
const handleSidebarInputChange = (
field: "name" | "description",
value: string,
) => {
if (!focusedRow) return;
const originalName = focusedRow.display_name;
setFocusedRow((prev) => (prev ? { ...prev, [field]: value } : null));
if (agGrid.current) {
const updatedRow = { ...focusedRow, [field]: value };
agGrid.current.api.applyTransaction({
update: [updatedRow],
});
const updatedData = data.map((row) =>
(row.display_name ?? row.name) === originalName ? updatedRow : row,
);
setData(updatedData);
}
};
const actionArgs = useMemo(() => {
return Object.entries(focusedRow?.args ?? {}).map(
([key, value]: [string, any]) => ({
display_name: value.title,
name: key,
description: value.description ?? null,
}),
);
}, [focusedRow]);
return (
<>
<main className="flex h-full w-full flex-1 flex-col gap-2 overflow-hidden py-4">
<div className="flex-none px-4">
<Input
icon="Search"
placeholder="Search actions..."
inputClassName="h-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex-1 overflow-auto">
<TableComponent
columnDefs={columnDefs}
rowData={data}
quickFilterText={searchQuery}
ref={agGrid}
rowSelection="multiple"
suppressRowClickSelection={true}
className="ag-tool-mode h-full w-full overflow-visible"
headerHeight={32}
rowHeight={32}
onSelectionChanged={handleSelectionChanged}
tableOptions={{
block_hide: true,
}}
onRowClicked={(event) => {
setFocusedRow(event.data);
setSidebarOpen(true);
}}
getRowId={getRowId}
/>
</div>
</main>
<Sidebar
side="right"
className="flex h-full flex-col overflow-auto border-l border-border"
>
<SidebarHeader className="flex-none px-4 py-4">
{focusedRow &&
(isAction ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label
className="text-mmd font-medium"
htmlFor="sidebar-name-input"
>
Name
</label>
<Input
id="sidebar-name-input"
value={sidebarName}
onChange={(e) => {
setSidebarName(e.target.value);
handleSidebarInputChange("name", e.target.value);
}}
maxLength={46}
placeholder="Edit name..."
data-testid="input_update_name"
/>
<div className="text-xs text-muted-foreground">
{isAction
? "Used as the function name when this flow is exposed to clients."
: "Used as the function name when this tool is exposed to the agent."}
</div>
</div>
<div className="flex flex-col gap-2">
<label
className="text-mmd font-medium"
htmlFor="sidebar-desc-input"
>
Description
</label>
<Textarea
id="sidebar-desc-input"
value={sidebarDescription}
onChange={(e) => {
setSidebarDescription(e.target.value);
handleSidebarInputChange("description", e.target.value);
}}
placeholder="Edit description..."
className="h-24"
data-testid="input_update_description"
/>
<div className="text-xs text-muted-foreground">
{isAction
? "This is the description for the action exposed to the clients."
: "This is the description for the tool exposed to the agents."}
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-1" data-testid="sidebar_header">
<h3
className="text-base font-medium"
data-testid="sidebar_header_name"
>
{parseString(focusedRow?.display_name || focusedRow?.name, [
"space_case",
])}
</h3>
<p
className="text-mmd text-muted-foreground"
data-testid="sidebar_header_description"
>
{focusedRow?.display_description ?? focusedRow?.description}
</p>
</div>
))}
</SidebarHeader>
{!isAction && <Separator />}
<SidebarContent className="flex flex-1 flex-col gap-0 overflow-visible px-2">
{focusedRow && (
<div className="flex h-full flex-col gap-4">
<SidebarGroup className="flex-1">
<SidebarGroupContent className="h-full pb-4">
<div className="flex h-full flex-col gap-4">
{actionArgs.length > 0 && (
<div className="flex flex-col gap-1.5">
<h3 className="mt-2 text-base font-medium">
Parameters
</h3>
<p className="text-mmd text-muted-foreground">
Manage inputs for this action
</p>
</div>
)}
{actionArgs.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<label className="flex text-sm font-medium">
{field.display_name}
{field.description && (
<ShadTooltip content={field.description}>
<div className="flex items-center text-sm font-medium hover:cursor-help">
<ForwardedIconComponent
name="info"
className="ml-1.5 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
</div>
</ShadTooltip>
)}
</label>
<Input
id="sidebar-desc-input"
disabled
placeholder="Input controlled by the agent"
onChange={(e) => {}}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
</div>
)}
</SidebarContent>
</Sidebar>
</>
);
}

View file

@ -0,0 +1,89 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { SidebarProvider } from "@/components/ui/sidebar";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APITemplateType } from "@/types/api";
import { AgGridReact } from "ag-grid-react";
import { cloneDeep } from "lodash";
import { ForwardedRef, forwardRef, useState } from "react";
import BaseModal from "../baseModal";
import ToolsTable from "./components/toolsTable";
interface ToolsModalProps {
open: boolean;
setOpen: (open: boolean) => void;
description: string;
rows: {
name: string;
tags: string[];
description: string;
status: boolean;
}[];
handleOnNewValue: handleOnNewValueType;
title: string;
icon?: string;
isAction?: boolean;
template?: APITemplateType;
}
const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
(
{
description,
rows,
handleOnNewValue,
template,
title,
icon,
open,
isAction = false,
setOpen,
...props
}: ToolsModalProps,
ref: ForwardedRef<AgGridReact>,
) => {
const handleSetOpen = (newOpen: boolean) => {
if (setOpen) {
setOpen(newOpen);
}
};
const [data, setData] = useState<any[]>(cloneDeep(rows));
return (
<BaseModal
open={open}
size="templates"
className="flex max-h-[50vh] gap-0 p-0"
setOpen={(newOpen) => {
handleSetOpen(newOpen);
}}
>
<BaseModal.Header>
<div className="flex w-full flex-row items-center border-b border-border px-4 py-3">
{icon && (
<ForwardedIconComponent name={icon} className="mr-2 h-6 w-6" />
)}
<div>{title}</div>
</div>
</BaseModal.Header>
<BaseModal.Content overflowHidden className="flex flex-col p-0">
<div className="flex h-full">
<SidebarProvider width="20rem" defaultOpen={false}>
<ToolsTable
rows={rows}
isAction={isAction}
template={template}
data={data}
setData={setData}
open={open}
handleOnNewValue={handleOnNewValue}
/>
</SidebarProvider>
</div>
</BaseModal.Content>
</BaseModal>
);
},
);
export default ToolsModal;

View file

@ -14,6 +14,7 @@
--ag-panel-background-color: hsl(var(--accent)) !important;
--ag-row-hover-color: hsl(var(--accent)) !important;
--ag-header-height: 2.5rem !important;
--ag-checkbox-checked-color: hsl(var(--primary)) !important;
}
.ag-theme-shadcn .ag-paging-panel {
@ -33,7 +34,6 @@
.ag-cell-wrapper > *:not(.ag-cell-value):not(.ag-group-value) {
align-items: self-start;
position: relative;
top: 2px;
}
.ag-cell-wrapper > *:not(.ag-cell-value):not(.ag-group-value) {
@ -99,3 +99,46 @@
box-shadow: none !important;
outline: none !important;
}
.ag-tool-mode .ag-root-wrapper {
border: none !important;
}
.ag-tool-mode .ag-row {
border-bottom: none !important;
}
.ag-tool-mode .ag-header {
margin-bottom: 0rem !important;
border-bottom: 0 !important;
}
.ag-tool-mode .ag-header-cell {
padding: 0rem 1rem !important;
margin: 1px;
}
.ag-tool-mode .ag-ltr .ag-header-select-all {
margin-right: var(--ag-cell-widget-spacing);
}
.ag-tool-mode .ag-paging-panel {
border-top: none !important;
}
.ag-tool-mode .ag-cell-focus:not(.ag-cell-inline-editing) {
border: 1px solid transparent !important;
box-shadow: none !important;
outline: none !important;
}
.ag-tool-mode .ag-cell {
padding: 0rem 1rem !important;
}
.ag-tool-mode .ag-row-focus {
background-color: hsl(var(--accent)) !important;
}
.ag-tool-mode .ag-row-selected:not(.ag-row-focus)::before {
background-color: hsl(var(--background)) !important;
}

View file

@ -324,6 +324,7 @@ export type FieldParserType =
| "uppercase"
| "no_blank"
| "valid_csv"
| "space_case"
| "commands";
export type TableOptionsTypeAPI = {

View file

@ -1,3 +1,4 @@
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { ReactFlowJsonObject } from "@xyflow/react";
import { ReactElement, ReactNode } from "react";
import { InputOutput } from "../../constants/enums";
@ -676,6 +677,21 @@ export type textModalPropsType = {
setOpen?: (open: boolean) => void;
onCloseModal?: () => void;
};
export interface ToolsModalProps {
open: boolean;
setOpen: (open: boolean) => void;
description: string;
rows: {
name: string;
tags: string[];
description: string;
status: boolean;
}[];
handleOnNewValue: handleOnNewValueType;
title: string;
icon: string;
}
export type queryModalPropsType = {
setValue: (value: string) => void;
value: string;

View file

@ -3,7 +3,7 @@ import React from "react";
import { FieldParserType } from "../types/api";
function toSnakeCase(str: string): string {
return str.trim().replace(/\s+/g, "_");
return str.trim().replace(/[-\s]+/g, "_");
}
function toCamelCase(str: string): string {
@ -35,6 +35,15 @@ function toUpperCase(str: string): string {
return str?.toUpperCase();
}
function toSpaceCase(str: string): string {
return str
.trim()
.replace(/[_\s-]+/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
function noBlank(str: string): string {
const trim = str.trim();
if (trim === "") {
@ -101,6 +110,9 @@ export function parseString(
case "no_blank":
result = noBlank(result);
break;
case "space_case":
result = toSpaceCase(result);
break;
case "valid_csv":
result = validCsv(result);
break;

View file

@ -35,26 +35,46 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.locator('[data-testid="icon-Hammer"]').nth(1).waitFor({
await page.locator('[data-testid="icon-Hammer"]').nth(0).waitFor({
timeout: 3000,
state: "visible",
});
await page.getByTestId("icon-Hammer").nth(1).click();
await page.waitForSelector("text=actions", { timeout: 30000 });
await page.waitForSelector("text=edit tools", { timeout: 30000 });
await page.getByTestId("button_open_actions").click();
await page.waitForSelector("text=API Request", { timeout: 30000 });
const rowsCount = await page.getByRole("gridcell").count();
expect(rowsCount).toBeGreaterThan(3);
expect(await page.getByRole("switch").nth(0).isChecked()).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(0).isChecked(),
).toBe(true);
await page.getByRole("switch").nth(0).click();
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(true);
expect(await page.getByRole("switch").nth(0).isChecked()).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(true);
await page.getByText("Save").last().click();
await page.locator('input[data-ref="eInput"]').nth(0).click();
await page.waitForTimeout(500);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(false);
await page.getByText("Close").last().click();
await page.waitForSelector(
'[data-testid="generic-node-title-arrangement"]',
@ -63,14 +83,54 @@ test(
},
);
await page.waitForTimeout(500);
await page.waitForSelector('[data-testid="div-tools_tools_metadata"]', {
timeout: 3000,
});
await page.getByTestId("icon-Hammer").nth(1).click();
expect(
await page
.locator('[data-testid="div-tools_tools_metadata"]')
.isVisible(),
).toBe(true);
await page.waitForSelector("text=edit tools", { timeout: 30000 });
await page.getByTestId("div-tools_tools_metadata").click();
await page.waitForTimeout(500);
expect(await page.getByRole("switch").nth(0).isChecked()).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(false);
expect(
await page.locator('input[data-ref="eInput"]').nth(4).isChecked(),
).toBe(false);
await page.locator('input[data-ref="eInput"]').nth(3).click();
await page.waitForTimeout(500);
expect(
await page.locator('input[data-ref="eInput"]').nth(3).isChecked(),
).toBe(true);
await page.getByRole("gridcell").nth(0).click();
expect(
await page.locator('[data-testid="sidebar_header_name"]').isVisible(),
).toBe(true);
expect(
await page
.locator('[data-testid="sidebar_header_description"]')
.isVisible(),
).toBe(true);
await page.waitForTimeout(500);
await page.getByText("Close").last().click();
expect(
await page.locator('[data-testid="tool_make_requests"]').isVisible(),
).toBe(true);
},
);