From 44254206b8bdd35d7de09582d7bf9a1fa34a83b1 Mon Sep 17 00:00:00 2001 From: Rodrigo Nader Date: Thu, 20 Mar 2025 19:46:19 -0300 Subject: [PATCH] feat: enhance APIRequestComponent with new output methods (#7148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ (Research Translation Loop.spec.ts): Increase timeout value by a factor of 3 for better reliability in waiting for element to appear ✨ (chatInputOutputUser-shard-1.spec.ts): Increase timeout value by a factor of 3 for better reliability in waiting for element to appear * 🐛 (typescript_test.yml): adjust the maximum shard count to 10 to prevent exceeding the limit and optimize test execution. * 🐛 (chatInputOutputUser-shard-1.spec.ts): increase timeout for waiting for "built successfully" text to improve test reliability * ⬆️ (typescript_test.yml): increase maximum shard count to 15 for better test distribution ♻️ (Portfolio Website Code Generator.spec.ts): refactor test assertions to improve readability and maintainability * 🐛 (typescript_test.yml): adjust the maximum shard count to 10 to prevent exceeding the limit of parallel test executions * 🔧 (typescript_test.yml): Increase maximum shard count to 15 for better test distribution efficiency 🐛 (chatInputOutputUser-shard-1.spec.ts): Update timeout values for page element waits to prevent premature failures due to timing issues * templates adjustments * travel planning fix * Update Travel Planning Agents.json * fix templates * ♻️ (Youtube Analysis.spec.ts): remove unused imports and cleanup code for better readability and maintainability * json fix * fix: update simple agent template (#7081) * Update Simple Agent.json * Update Simple Agent.json * feat: update search agent template agent component (#7082) * update agent component with the latest changes * Update Search agent.json * Update Search agent.json * feat: enhance APIRequestComponent with new output methods - Refactored output methods to include `as_data` and `as_message` for better data handling. - Updated existing output method from `make_requests` to `as_data` for consistency. - Improved metadata handling by merging dictionary results and storing non-dict results as 'data'. - Added detailed docstrings for new methods to clarify their functionality. * added the updated test class * [autofix.ci] apply automated fixes * 📝 (ContentBlockDisplay.tsx): wrap headerIcon element in a span with data-testid attribute for better accessibility 📝 (DurationDisplay.tsx): add data-testid attribute to the duration display element for testing purposes 📝 (Simple Agent.spec.ts, Social Media Agent.spec.ts, generalBugs-shard-9.spec.ts): update test assertions to improve readability and accuracy 📝 (chatInputOutput.spec.ts): add a skip test annotation and a todo comment for further investigation * ✨ (typescript_test.yml): Add support for a new development suite in the test workflow ✨ (frontend): Add support for a new development suite in multiple test files * 🔧 (.github/workflows/typescript_test.yml): ensure that the SUITES variable is valid JSON format to prevent errors and improve reliability * ✨ (inputFileComponent/index.tsx): Refactor InputFileComponent to wrap Button component in a div for better structure and readability 🔧 (Vector Store.spec.ts): Add initialGPTsetup function to set up GPT environment variables for tests 🔧 (Vector Store.spec.ts): Refactor test to use initialGPTsetup function and improve readability 🔧 (add-new-api-keys.ts): Refactor addNewApiKeys function to handle multiple openai_api_key inputs 🔧 (remove-old-api-keys.ts): Refactor removeOldApiKeys function to click on the correct remove-icon-badge element * ✨ Add support for running tests in serial mode with a delay between each test 🔧 Configure tests to run with a 3-second delay between each test run 🔧 Add a 7-second delay before starting tests in userSettings.spec.ts 🔧 Add a 3-second delay before the second event delivery mode test in withEventDeliveryModes.ts * 📝 (userSettings.spec.ts): remove unnecessary console log message to improve test readability and maintainability * 📝 (deploy-dropdown.tsx): Add data-testid attribute to shareable-playground element ✨ (index.tsx): Add useGetTypes hook to fetch types data when component is fetched 🔧 (publish-flow.spec.ts): Increase timeout for page.waitForSelector and page.waitForTimeout 🔧 (Vector Store.spec.ts): Refactor code to wait for dropdown to appear and be visible 🔧 (files-page.spec.ts): Refactor tests to run serially with a delay between each test and add comments to improve readability * ✨ Add useGetTypes hook to fetch types data and support caching with checkCache option 🔧 Refactor useGetTypes hook to accept options object with checkCache property 🔧 Refactor useGetTypes hook to conditionally return cached data if available 🔧 Refactor useGetTypes hook to fetch types data with force_refresh query parameter 🔧 Refactor useGetTypes hook to handle errors and set types data 🔧 Refactor useGetTypes hook to improve query function and options handling 🔧 Refactor useGetTypes hook to optimize query function and options handling 🔧 Refactor useGetTypes hook to improve caching logic and error handling 🔧 Refactor useGetTypes hook to enhance caching mechanism and error handling 🔧 Refactor useGetTypes hook to improve data fetching and error handling 🔧 Refactor useGetTypes hook to optimize data fetching and error handling * ✨ (Vector Store.spec.ts): Remove unnecessary loadingOptions check and expectation ♻️ (withEventDeliveryModes.ts): Refactor withEventDeliveryModes function to accept a timeout parameter for better flexibility * ⚡️ (Vector Store.spec.ts): increase timeout for page.waitForTimeout from 2000ms to 10000ms to improve test stability and reliability * update pokedex agent template * ✨ (publish-flow.spec.ts): Remove unnecessary development tag from test description ✨ (Simple Agent.spec.ts, Social Media Agent.spec.ts): Remove unnecessary development tag from test description ✨ (Vector Store.spec.ts): Change withEventDeliveryModes to test for better test organization 🔧 (chatInputOutput.spec.ts): Refactor test description and remove unnecessary development tag ✨ (files-page.spec.ts): Remove unnecessary development tag from test descriptions ✨ (userSettings.spec.ts): Remove unnecessary development tag from test description * formatting json * ✨ (Vector Store.spec.ts): Add new integration test withEventDeliveryModes for Vector Store RAG 🔧 (Vector Store.spec.ts): Update timeout values in test functions to improve test performance and reliability * 📝 (Text Sentiment Analysis.json): Update JSON file to have consistent formatting and structure for output_types and inputTypes arrays 📝 (Text Sentiment Analysis.spec.ts): Refactor integration test for Text Sentiment Analysis to improve readability and maintainability * ✨ (PageComponent/index.tsx): Update minZoom and maxZoom values for better user experience 🐛 (upload-file.ts): Fix missing await keyword before clicking on an element * 🐛 (PageComponent/index.tsx): fix minZoom value to 0.2 for consistency with fitViewOptions and improve user experience * ✨ (dropdownComponent.spec.ts): add delay before checking dropdown value to ensure it has updated properly ✨ (dropdownComponent.spec.ts): add delay before interacting with more options modal to ensure it has loaded ✨ (floatComponent.spec.ts): add delay after clicking add button to wait for API request to complete --------- Co-authored-by: cristhianzl Co-authored-by: Edwin Jose Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .github/workflows/typescript_test.yml | 25 + .../langflow/components/data/api_request.py | 37 +- .../starter_projects/Pokédex Agent.json | 330 +++++++++----- .../Text Sentiment Analysis.json | 429 ++++++------------ .../data/test_api_request_component.py | 411 +++++++++++------ .../GlobalVariableModal.tsx | 4 + .../components/deploy-dropdown.tsx | 2 +- .../components/popover/index.tsx | 15 +- .../components/inputFileComponent/index.tsx | 46 +- .../API/queries/flows/use-get-types.ts | 28 +- src/frontend/src/pages/AppInitPage/index.tsx | 2 - .../components/PageComponent/index.tsx | 6 +- src/frontend/src/pages/FlowPage/index.tsx | 3 +- .../pages/MainPage/pages/emptyPage/index.tsx | 6 +- src/frontend/src/types/api/index.ts | 10 +- .../tests/core/features/publish-flow.spec.ts | 59 ++- .../core/integrations/Simple Agent.spec.ts | 27 +- .../integrations/Social Media Agent.spec.ts | 58 ++- .../Text Sentiment Analysis.spec.ts | 9 +- .../core/integrations/Vector Store.spec.ts | 269 +++++++---- .../tests/core/unit/chatInputOutput.spec.ts | 5 +- .../tests/core/unit/dropdownComponent.spec.ts | 4 + .../tests/core/unit/floatComponent.spec.ts | 9 + .../extended/features/files-page.spec.ts | 73 ++- .../extended/features/userSettings.spec.ts | 9 +- .../add-flow-to-test-on-empty-langflow.ts | 8 + src/frontend/tests/utils/add-new-api-keys.ts | 11 + .../tests/utils/remove-old-api-keys.ts | 6 +- src/frontend/tests/utils/upload-file.ts | 1 + .../tests/utils/withEventDeliveryModes.ts | 8 +- 30 files changed, 1183 insertions(+), 727 deletions(-) create mode 100644 src/frontend/tests/utils/add-flow-to-test-on-empty-langflow.ts diff --git a/.github/workflows/typescript_test.yml b/.github/workflows/typescript_test.yml index af501d483..ff844e9ef 100644 --- a/.github/workflows/typescript_test.yml +++ b/.github/workflows/typescript_test.yml @@ -99,6 +99,7 @@ jobs: # If input suites were not provided, determine based on changes if [[ "$SUITES" == "[]" ]]; then echo "No input suites provided - determining from changes" + SUITES='[]' # Ensure we start with a valid JSON array TAGS=() # Add suites and tags based on changed files if [[ "${{ steps.filter.outputs.components }}" == "true" ]]; then @@ -126,6 +127,11 @@ jobs: TAGS+=("@database") echo "Added database suite" fi + if [[ "${{ steps.filter.outputs.development }}" == "true" ]]; then + SUITES=$(echo $SUITES | jq -c '. += ["development"]') + TAGS+=("@development") + echo "Added development suite" + fi # Create grep pattern if we have tags if [ ${#TAGS[@]} -gt 0 ]; then @@ -135,6 +141,21 @@ jobs: fi else # Process input suites to tags + # First ensure SUITES is valid JSON + if ! echo "$SUITES" | jq -e . > /dev/null 2>&1; then + echo "Warning: Input suites is not valid JSON, attempting to fix" + # Try to fix common issues like missing quotes + if [[ "$SUITES" == "[development]" ]]; then + SUITES='["development"]' + elif [[ "$SUITES" =~ ^\[(.*)\]$ ]]; then + # Extract items and add quotes + ITEMS="${BASH_REMATCH[1]}" + QUOTED_ITEMS=$(echo "$ITEMS" | sed 's/\([^,]*\)/"\1"/g') + SUITES="[$QUOTED_ITEMS]" + fi + echo "Fixed suites: $SUITES" + fi + TAGS=() if echo "$SUITES" | jq -e 'contains(["components"])' > /dev/null; then TAGS+=("@components") @@ -151,6 +172,9 @@ jobs: if echo "$SUITES" | jq -e 'contains(["database"])' > /dev/null; then TAGS+=("@database") fi + if echo "$SUITES" | jq -e 'contains(["development"])' > /dev/null; then + TAGS+=("@development") + fi if [ ${#TAGS[@]} -gt 0 ]; then # Join tags with | for OR logic @@ -168,6 +192,7 @@ jobs: # Ensure proper JSON formatting for matrix output echo "matrix=$(echo $SUITES | jq -c .)" >> $GITHUB_OUTPUT echo "test_grep=$TEST_GREP" >> $GITHUB_OUTPUT + echo "suites=$SUITES" >> $GITHUB_OUTPUT - name: Setup Node ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 diff --git a/src/backend/base/langflow/components/data/api_request.py b/src/backend/base/langflow/components/data/api_request.py index 5772c7ab5..b9b42dc97 100644 --- a/src/backend/base/langflow/components/data/api_request.py +++ b/src/backend/base/langflow/components/data/api_request.py @@ -26,9 +26,7 @@ from langflow.io import ( StrInput, TableInput, ) -from langflow.schema import Data -from langflow.schema.dataframe import DataFrame -from langflow.schema.dotdict import dotdict +from langflow.schema import Data, DataFrame, Message, dotdict class APIRequestComponent(Component): @@ -156,8 +154,9 @@ class APIRequestComponent(Component): ] outputs = [ - Output(display_name="Data", name="data", method="make_requests"), + Output(display_name="Data", name="data", method="as_data"), Output(display_name="DataFrame", name="dataframe", method="as_dataframe"), + Output(display_name="Message", name="message", method="as_message"), ] def _parse_json_value(self, value: Any) -> Any: @@ -454,7 +453,12 @@ class APIRequestComponent(Component): self.log("Failed to decode JSON response") result = response.text.encode("utf-8") - metadata.update({"result": result}) + # If result is a dictionary, merge it with metadata + if isinstance(result, dict): + metadata.update(result) + else: + # If result is not a dict, store it as 'data' + metadata["data"] = result if include_httpx_metadata: metadata.update( @@ -528,7 +532,7 @@ class APIRequestComponent(Component): urls = [self.add_query_params(url, query_params) for url in urls] async with httpx.AsyncClient() as client: - results = await asyncio.gather( + return await asyncio.gather( *[ self.make_request( client, @@ -544,8 +548,6 @@ class APIRequestComponent(Component): for u, rec in zip(urls, bodies, strict=False) ] ) - self.status = results - return results async def _response_info( self, response: httpx.Response, *, with_file_path: bool = False @@ -644,6 +646,16 @@ class APIRequestComponent(Component): return processed_headers return {} + async def as_data(self) -> Data: + """Convert the API response data into a DataFrame. + + Returns: + DataFrame: A DataFrame containing the API response data. + """ + data = await self.make_requests() + dicts = {"output": [d.data for d in data]} + return Data(**dicts) + async def as_dataframe(self) -> DataFrame: """Convert the API response data into a DataFrame. @@ -652,3 +664,12 @@ class APIRequestComponent(Component): """ data = await self.make_requests() return DataFrame(data) + + async def as_message(self) -> Message: + """Convert the API response data into a DataFrame. + + Returns: + DataFrame: A DataFrame containing the API response data. + """ + data = await self.as_data() + return Message(text=str(data)) diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json index 71e8e5b9c..a542856d4 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json @@ -7,23 +7,27 @@ "data": { "sourceHandle": { "dataType": "APIRequest", - "id": "APIRequest-KJLNf", + "id": "APIRequest-i3Jjf", "name": "component_as_tool", - "output_types": ["Tool"] + "output_types": [ + "Tool" + ] }, "targetHandle": { "fieldName": "tools", - "id": "Agent-gZkrx", - "inputTypes": ["Tool"], + "id": "Agent-LwvP3", + "inputTypes": [ + "Tool" + ], "type": "other" } }, - "id": "reactflow__edge-APIRequest-KJLNf{œdataTypeœ:œAPIRequestœ,œidœ:œAPIRequest-KJLNfœ,œnameœ:œcomponent_as_toolœ,œoutput_typesœ:[œToolœ]}-Agent-gZkrx{œfieldNameœ:œtoolsœ,œidœ:œAgent-gZkrxœ,œinputTypesœ:[œToolœ],œtypeœ:œotherœ}", + "id": "reactflow__edge-APIRequest-i3Jjf{œdataTypeœ:œAPIRequestœ,œidœ:œAPIRequest-i3Jjfœ,œnameœ:œcomponent_as_toolœ,œoutput_typesœ:[œToolœ]}-Agent-LwvP3{œfieldNameœ:œtoolsœ,œidœ:œAgent-LwvP3œ,œinputTypesœ:[œToolœ],œtypeœ:œotherœ}", "selected": false, - "source": "APIRequest-KJLNf", - "sourceHandle": "{œdataTypeœ: œAPIRequestœ, œidœ: œAPIRequest-KJLNfœ, œnameœ: œcomponent_as_toolœ, œoutput_typesœ: [œToolœ]}", - "target": "Agent-gZkrx", - "targetHandle": "{œfieldNameœ: œtoolsœ, œidœ: œAgent-gZkrxœ, œinputTypesœ: [œToolœ], œtypeœ: œotherœ}" + "source": "APIRequest-i3Jjf", + "sourceHandle": "{œdataTypeœ: œAPIRequestœ, œidœ: œAPIRequest-i3Jjfœ, œnameœ: œcomponent_as_toolœ, œoutput_typesœ: [œToolœ]}", + "target": "Agent-LwvP3", + "targetHandle": "{œfieldNameœ: œtoolsœ, œidœ: œAgent-LwvP3œ, œinputTypesœ: [œToolœ], œtypeœ: œotherœ}" }, { "animated": false, @@ -31,23 +35,27 @@ "data": { "sourceHandle": { "dataType": "ChatInput", - "id": "ChatInput-eumKo", + "id": "ChatInput-DbY0i", "name": "message", - "output_types": ["Message"] + "output_types": [ + "Message" + ] }, "targetHandle": { "fieldName": "input_value", - "id": "Agent-gZkrx", - "inputTypes": ["Message"], + "id": "Agent-LwvP3", + "inputTypes": [ + "Message" + ], "type": "str" } }, - "id": "reactflow__edge-ChatInput-eumKo{œdataTypeœ:œChatInputœ,œidœ:œChatInput-eumKoœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-Agent-gZkrx{œfieldNameœ:œinput_valueœ,œidœ:œAgent-gZkrxœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", + "id": "reactflow__edge-ChatInput-DbY0i{œdataTypeœ:œChatInputœ,œidœ:œChatInput-DbY0iœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-Agent-LwvP3{œfieldNameœ:œinput_valueœ,œidœ:œAgent-LwvP3œ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", "selected": false, - "source": "ChatInput-eumKo", - "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-eumKoœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", - "target": "Agent-gZkrx", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œAgent-gZkrxœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + "source": "ChatInput-DbY0i", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-DbY0iœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "Agent-LwvP3", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œAgent-LwvP3œ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" }, { "animated": false, @@ -55,23 +63,29 @@ "data": { "sourceHandle": { "dataType": "Agent", - "id": "Agent-gZkrx", + "id": "Agent-LwvP3", "name": "response", - "output_types": ["Message"] + "output_types": [ + "Message" + ] }, "targetHandle": { "fieldName": "input_value", - "id": "ChatOutput-D4JWF", - "inputTypes": ["Data", "DataFrame", "Message"], + "id": "ChatOutput-iRmVU", + "inputTypes": [ + "Data", + "DataFrame", + "Message" + ], "type": "str" } }, - "id": "reactflow__edge-Agent-gZkrx{œdataTypeœ:œAgentœ,œidœ:œAgent-gZkrxœ,œnameœ:œresponseœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-D4JWF{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-D4JWFœ,œinputTypesœ:[œDataœ,œDataFrameœ,œMessageœ],œtypeœ:œstrœ}", + "id": "reactflow__edge-Agent-LwvP3{œdataTypeœ:œAgentœ,œidœ:œAgent-LwvP3œ,œnameœ:œresponseœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-iRmVU{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-iRmVUœ,œinputTypesœ:[œDataœ,œDataFrameœ,œMessageœ],œtypeœ:œstrœ}", "selected": false, - "source": "Agent-gZkrx", - "sourceHandle": "{œdataTypeœ: œAgentœ, œidœ: œAgent-gZkrxœ, œnameœ: œresponseœ, œoutput_typesœ: [œMessageœ]}", - "target": "ChatOutput-D4JWF", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-D4JWFœ, œinputTypesœ: [œDataœ, œDataFrameœ, œMessageœ], œtypeœ: œstrœ}" + "source": "Agent-LwvP3", + "sourceHandle": "{œdataTypeœ: œAgentœ, œidœ: œAgent-LwvP3œ, œnameœ: œresponseœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-iRmVU", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-iRmVUœ, œinputTypesœ: [œDataœ, œDataFrameœ, œMessageœ], œtypeœ: œstrœ}" } ], "nodes": [ @@ -79,9 +93,13 @@ "data": { "description": "Make HTTP requests using URLs or cURL commands.", "display_name": "API Request", - "id": "APIRequest-KJLNf", + "id": "APIRequest-i3Jjf", "node": { - "base_classes": ["Data", "DataFrame"], + "base_classes": [ + "Data", + "DataFrame", + "Message" + ], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -120,7 +138,9 @@ "required_inputs": null, "selected": "Tool", "tool_mode": true, - "types": ["Tool"], + "types": [ + "Tool" + ], "value": "__UNDEFINED__" } ], @@ -133,7 +153,9 @@ "display_name": "Body", "dynamic": false, "info": "The body to send with the request as a dictionary (for POST, PATCH, PUT).", - "input_types": ["Data"], + "input_types": [ + "Data" + ], "is_list": true, "list_add_label": "Add More", "name": "body", @@ -193,7 +215,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import asyncio\nimport json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom langflow.base.curl.parse import parse_context\nfrom langflow.custom import Component\nfrom langflow.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n StrInput,\n TableInput,\n)\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.dotdict import dotdict\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URLs or cURL commands.\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n default_keys = [\"urls\", \"method\", \"query_params\"]\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n list=True,\n info=\"Enter one or more URLs, separated by commas.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n advanced=True,\n real_time_refresh=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_curl\",\n display_name=\"Use cURL\",\n value=False,\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\"],\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request as a dictionary.\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[],\n advanced=True,\n input_types=[\"Data\"],\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=5,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=True,\n info=\"Whether to follow http redirects.\",\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Data\", name=\"data\", method=\"make_requests\"),\n Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\n\n Args:\n body: The body to process, can be dict, str, or list\n Returns:\n Processed dictionary\n \"\"\"\n if body is None:\n return {}\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n\n try:\n for item in body:\n if not self._is_valid_key_value_item(item):\n continue\n\n key = item[\"key\"]\n value = self._parse_json_value(item[\"value\"])\n processed_dict[key] = value\n\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {} # Return empty dictionary instead of None\n\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\n\n Args:\n curl: The cURL command to parse\n build_config: The build configuration to update\n Returns:\n Updated build configuration\n \"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n build_config[\"urls\"][\"value\"] = [parsed.url]\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n build_config[\"headers\"][\"advanced\"] = True\n build_config[\"body\"][\"advanced\"] = True\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n if headers_list:\n build_config[\"headers\"][\"advanced\"] = False\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n build_config[\"body\"][\"advanced\"] = False\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n build_config[\"body\"][\"advanced\"] = False\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n build_config[\"body\"][\"advanced\"] = False\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n if field_name == \"use_curl\":\n build_config = self._update_curl_mode(build_config, use_curl=field_value)\n\n # Fields that should not be reset\n preserve_fields = {\"timeout\", \"follow_redirects\", \"save_to_file\", \"include_httpx_metadata\", \"use_curl\"}\n\n # Mapping between input types and their reset values\n type_reset_mapping = {\n TableInput: [],\n BoolInput: False,\n IntInput: 0,\n FloatInput: 0.0,\n MessageTextInput: \"\",\n StrInput: \"\",\n MultilineInput: \"\",\n DropdownInput: \"GET\",\n DataInput: {},\n }\n\n for input_field in self.inputs:\n # Only reset if field is not in preserve list\n if input_field.name not in preserve_fields:\n reset_value = type_reset_mapping.get(type(input_field), None)\n build_config[input_field.name][\"value\"] = reset_value\n self.log(f\"Reset field {input_field.name} to {reset_value}\")\n elif field_name == \"method\" and not self.use_curl:\n build_config = self._update_method_fields(build_config, field_value)\n elif field_name == \"curl\" and self.use_curl and field_value:\n build_config = self.parse_curl(field_value, build_config)\n return build_config\n\n def _update_curl_mode(self, build_config: dotdict, *, use_curl: bool) -> dotdict:\n always_visible = [\"method\", \"use_curl\"]\n\n for field in self.inputs:\n field_name = field.name\n field_config = build_config.get(field_name)\n if isinstance(field_config, dict):\n if field_name in always_visible:\n field_config[\"advanced\"] = False\n elif field_name == \"urls\":\n field_config[\"advanced\"] = use_curl\n elif field_name == \"curl\":\n field_config[\"advanced\"] = not use_curl\n field_config[\"real_time_refresh\"] = use_curl\n elif field_name in {\"body\", \"headers\"}:\n field_config[\"advanced\"] = True # Always keep body and headers in advanced when use_curl is False\n else:\n field_config[\"advanced\"] = use_curl\n else:\n self.log(f\"Expected dict for build_config[{field_name}], got {type(field_config).__name__}\")\n\n if not use_curl:\n current_method = build_config.get(\"method\", {}).get(\"value\", \"GET\")\n build_config = self._update_method_fields(build_config, current_method)\n\n return build_config\n\n def _update_method_fields(self, build_config: dotdict, method: str) -> dotdict:\n common_fields = [\n \"urls\",\n \"method\",\n \"use_curl\",\n ]\n\n always_advanced_fields = [\n \"body\",\n \"headers\",\n \"timeout\",\n \"follow_redirects\",\n \"save_to_file\",\n \"include_httpx_metadata\",\n ]\n\n body_fields = [\"body\"]\n\n for field in self.inputs:\n field_name = field.name\n field_config = build_config.get(field_name)\n if isinstance(field_config, dict):\n if field_name in common_fields:\n field_config[\"advanced\"] = False\n elif field_name in body_fields:\n field_config[\"advanced\"] = method not in {\"POST\", \"PUT\", \"PATCH\"}\n elif field_name in always_advanced_fields:\n field_config[\"advanced\"] = True\n else:\n field_config[\"advanced\"] = True\n else:\n self.log(f\"Expected dict for build_config[{field_name}], got {type(field_config).__name__}\")\n\n return build_config\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = True,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n # Process body using the new helper method\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n response = await client.request(\n method,\n url,\n headers=headers,\n json=processed_body,\n timeout=timeout,\n follow_redirects=follow_redirects,\n )\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n metadata: dict[str, Any] = {\n \"source\": url,\n }\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n # Ensure parent directory exists\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update(\n {\n \"headers\": headers,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n }\n )\n return Data(data=metadata)\n\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata.update({\"result\": result})\n\n if include_httpx_metadata:\n metadata.update(\n {\n \"headers\": headers,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n }\n )\n return Data(data=metadata)\n except httpx.TimeoutException:\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 408,\n \"error\": \"Request timed out\",\n },\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n def add_query_params(self, url: str, params: dict) -> str:\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n async def make_requests(self) -> list[Data]:\n method = self.method\n urls = [url.strip() for url in self.urls if url.strip()]\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n if self.use_curl and self.curl:\n self._build_config = self.parse_curl(self.curl, dotdict())\n\n invalid_urls = [url for url in urls if not validators.url(url)]\n if invalid_urls:\n msg = f\"Invalid URLs provided: {invalid_urls}\"\n raise ValueError(msg)\n\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers here\n headers = self._process_headers(headers)\n\n # Process body\n body = self._process_body(body)\n\n bodies = [body] * len(urls)\n\n urls = [self.add_query_params(url, query_params) for url in urls]\n\n async with httpx.AsyncClient() as client:\n results = await asyncio.gather(\n *[\n self.make_request(\n client,\n method,\n u,\n headers,\n rec,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n for u, rec in zip(urls, bodies, strict=False)\n ]\n )\n self.status = results\n return results\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\n\n Args:\n headers: The headers to process, can be dict, str, or list\n Returns:\n Processed dictionary\n \"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n processed_headers = {}\n try:\n for item in headers:\n if not self._is_valid_key_value_item(item):\n continue\n key = item[\"key\"]\n value = item[\"value\"]\n processed_headers[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process headers list: {e}\")\n return {} # Return empty dictionary instead of None\n return processed_headers\n return {}\n\n async def as_dataframe(self) -> DataFrame:\n \"\"\"Convert the API response data into a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the API response data.\n \"\"\"\n data = await self.make_requests()\n return DataFrame(data)\n" + "value": "import asyncio\nimport json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom langflow.base.curl.parse import parse_context\nfrom langflow.custom import Component\nfrom langflow.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n StrInput,\n TableInput,\n)\nfrom langflow.schema import Data, DataFrame, Message, dotdict\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URLs or cURL commands.\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n default_keys = [\"urls\", \"method\", \"query_params\"]\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n list=True,\n info=\"Enter one or more URLs, separated by commas.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n advanced=True,\n real_time_refresh=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_curl\",\n display_name=\"Use cURL\",\n value=False,\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\"],\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request as a dictionary.\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[],\n advanced=True,\n input_types=[\"Data\"],\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=5,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=True,\n info=\"Whether to follow http redirects.\",\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Data\", name=\"data\", method=\"as_data\"),\n Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n Output(display_name=\"Message\", name=\"message\", method=\"as_message\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\n\n Args:\n body: The body to process, can be dict, str, or list\n Returns:\n Processed dictionary\n \"\"\"\n if body is None:\n return {}\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n\n try:\n for item in body:\n if not self._is_valid_key_value_item(item):\n continue\n\n key = item[\"key\"]\n value = self._parse_json_value(item[\"value\"])\n processed_dict[key] = value\n\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {} # Return empty dictionary instead of None\n\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\n\n Args:\n curl: The cURL command to parse\n build_config: The build configuration to update\n Returns:\n Updated build configuration\n \"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n build_config[\"urls\"][\"value\"] = [parsed.url]\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n build_config[\"headers\"][\"advanced\"] = True\n build_config[\"body\"][\"advanced\"] = True\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n if headers_list:\n build_config[\"headers\"][\"advanced\"] = False\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n build_config[\"body\"][\"advanced\"] = False\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n build_config[\"body\"][\"advanced\"] = False\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n build_config[\"body\"][\"advanced\"] = False\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n if field_name == \"use_curl\":\n build_config = self._update_curl_mode(build_config, use_curl=field_value)\n\n # Fields that should not be reset\n preserve_fields = {\"timeout\", \"follow_redirects\", \"save_to_file\", \"include_httpx_metadata\", \"use_curl\"}\n\n # Mapping between input types and their reset values\n type_reset_mapping = {\n TableInput: [],\n BoolInput: False,\n IntInput: 0,\n FloatInput: 0.0,\n MessageTextInput: \"\",\n StrInput: \"\",\n MultilineInput: \"\",\n DropdownInput: \"GET\",\n DataInput: {},\n }\n\n for input_field in self.inputs:\n # Only reset if field is not in preserve list\n if input_field.name not in preserve_fields:\n reset_value = type_reset_mapping.get(type(input_field), None)\n build_config[input_field.name][\"value\"] = reset_value\n self.log(f\"Reset field {input_field.name} to {reset_value}\")\n elif field_name == \"method\" and not self.use_curl:\n build_config = self._update_method_fields(build_config, field_value)\n elif field_name == \"curl\" and self.use_curl and field_value:\n build_config = self.parse_curl(field_value, build_config)\n return build_config\n\n def _update_curl_mode(self, build_config: dotdict, *, use_curl: bool) -> dotdict:\n always_visible = [\"method\", \"use_curl\"]\n\n for field in self.inputs:\n field_name = field.name\n field_config = build_config.get(field_name)\n if isinstance(field_config, dict):\n if field_name in always_visible:\n field_config[\"advanced\"] = False\n elif field_name == \"urls\":\n field_config[\"advanced\"] = use_curl\n elif field_name == \"curl\":\n field_config[\"advanced\"] = not use_curl\n field_config[\"real_time_refresh\"] = use_curl\n elif field_name in {\"body\", \"headers\"}:\n field_config[\"advanced\"] = True # Always keep body and headers in advanced when use_curl is False\n else:\n field_config[\"advanced\"] = use_curl\n else:\n self.log(f\"Expected dict for build_config[{field_name}], got {type(field_config).__name__}\")\n\n if not use_curl:\n current_method = build_config.get(\"method\", {}).get(\"value\", \"GET\")\n build_config = self._update_method_fields(build_config, current_method)\n\n return build_config\n\n def _update_method_fields(self, build_config: dotdict, method: str) -> dotdict:\n common_fields = [\n \"urls\",\n \"method\",\n \"use_curl\",\n ]\n\n always_advanced_fields = [\n \"body\",\n \"headers\",\n \"timeout\",\n \"follow_redirects\",\n \"save_to_file\",\n \"include_httpx_metadata\",\n ]\n\n body_fields = [\"body\"]\n\n for field in self.inputs:\n field_name = field.name\n field_config = build_config.get(field_name)\n if isinstance(field_config, dict):\n if field_name in common_fields:\n field_config[\"advanced\"] = False\n elif field_name in body_fields:\n field_config[\"advanced\"] = method not in {\"POST\", \"PUT\", \"PATCH\"}\n elif field_name in always_advanced_fields:\n field_config[\"advanced\"] = True\n else:\n field_config[\"advanced\"] = True\n else:\n self.log(f\"Expected dict for build_config[{field_name}], got {type(field_config).__name__}\")\n\n return build_config\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = True,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n # Process body using the new helper method\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n response = await client.request(\n method,\n url,\n headers=headers,\n json=processed_body,\n timeout=timeout,\n follow_redirects=follow_redirects,\n )\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n metadata: dict[str, Any] = {\n \"source\": url,\n }\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n # Ensure parent directory exists\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update(\n {\n \"headers\": headers,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n }\n )\n return Data(data=metadata)\n\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n # If result is a dictionary, merge it with metadata\n if isinstance(result, dict):\n metadata.update(result)\n else:\n # If result is not a dict, store it as 'data'\n metadata[\"data\"] = result\n\n if include_httpx_metadata:\n metadata.update(\n {\n \"headers\": headers,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n }\n )\n return Data(data=metadata)\n except httpx.TimeoutException:\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 408,\n \"error\": \"Request timed out\",\n },\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n def add_query_params(self, url: str, params: dict) -> str:\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n async def make_requests(self) -> list[Data]:\n method = self.method\n urls = [url.strip() for url in self.urls if url.strip()]\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n if self.use_curl and self.curl:\n self._build_config = self.parse_curl(self.curl, dotdict())\n\n invalid_urls = [url for url in urls if not validators.url(url)]\n if invalid_urls:\n msg = f\"Invalid URLs provided: {invalid_urls}\"\n raise ValueError(msg)\n\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers here\n headers = self._process_headers(headers)\n\n # Process body\n body = self._process_body(body)\n\n bodies = [body] * len(urls)\n\n urls = [self.add_query_params(url, query_params) for url in urls]\n\n async with httpx.AsyncClient() as client:\n return await asyncio.gather(\n *[\n self.make_request(\n client,\n method,\n u,\n headers,\n rec,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n for u, rec in zip(urls, bodies, strict=False)\n ]\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\n\n Args:\n headers: The headers to process, can be dict, str, or list\n Returns:\n Processed dictionary\n \"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n processed_headers = {}\n try:\n for item in headers:\n if not self._is_valid_key_value_item(item):\n continue\n key = item[\"key\"]\n value = item[\"value\"]\n processed_headers[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process headers list: {e}\")\n return {} # Return empty dictionary instead of None\n return processed_headers\n return {}\n\n async def as_data(self) -> Data:\n \"\"\"Convert the API response data into a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the API response data.\n \"\"\"\n data = await self.make_requests()\n dicts = {\"output\": [d.data for d in data]}\n return Data(**dicts)\n\n async def as_dataframe(self) -> DataFrame:\n \"\"\"Convert the API response data into a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the API response data.\n \"\"\"\n data = await self.make_requests()\n return DataFrame(data)\n\n async def as_message(self) -> Message:\n \"\"\"Convert the API response data into a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the API response data.\n \"\"\"\n data = await self.as_data()\n return Message(text=str(data))\n" }, "curl": { "_input_type": "MultilineInput", @@ -202,7 +224,9 @@ "display_name": "cURL", "dynamic": false, "info": "Paste a curl command to populate the fields. This will fill in the dictionary fields for headers and body.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -243,7 +267,9 @@ "display_name": "Headers", "dynamic": false, "info": "The headers to send with the request as a dictionary.", - "input_types": ["Data"], + "input_types": [ + "Data" + ], "is_list": true, "list_add_label": "Add More", "name": "headers", @@ -317,7 +343,13 @@ "info": "The HTTP method to use.", "load_from_db": false, "name": "method", - "options": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "options": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ], "options_metadata": [], "placeholder": "", "real_time_refresh": true, @@ -335,7 +367,9 @@ "display_name": "Query Parameters", "dynamic": false, "info": "The query parameters to append to the URL.", - "input_types": ["Data"], + "input_types": [ + "Data" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -411,7 +445,10 @@ "description": "Modify tool names and descriptions to help agents understand when to use each tool.", "field_parsers": { "commands": "commands", - "name": ["snake_case", "no_blank"] + "name": [ + "snake_case", + "no_blank" + ] }, "hide_options": true }, @@ -479,16 +516,28 @@ "type": "table", "value": [ { - "description": "make_requests() - Make HTTP requests using URLs or cURL commands.", - "name": "APIRequest-make_requests", + "description": "as_data() - Make HTTP requests using URLs or cURL commands.", + "name": "APIRequest-as_data", "status": true, - "tags": ["APIRequest-make_requests"] + "tags": [ + "APIRequest-as_data" + ] }, { "description": "as_dataframe() - Make HTTP requests using URLs or cURL commands.", "name": "APIRequest-as_dataframe", "status": true, - "tags": ["APIRequest-as_dataframe"] + "tags": [ + "APIRequest-as_dataframe" + ] + }, + { + "description": "as_message() - Make HTTP requests using URLs or cURL commands.", + "name": "APIRequest-as_message", + "status": true, + "tags": [ + "APIRequest-as_message" + ] } ] }, @@ -498,7 +547,9 @@ "display_name": "URLs", "dynamic": false, "info": "Enter one or more URLs, separated by commas.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": true, "list_add_label": "Add More", "load_from_db": false, @@ -511,7 +562,9 @@ "trace_as_input": true, "trace_as_metadata": true, "type": "str", - "value": [""] + "value": [ + "" + ] }, "use_curl": { "_input_type": "BoolInput", @@ -539,10 +592,10 @@ "type": "APIRequest" }, "dragging": false, - "id": "APIRequest-KJLNf", + "id": "APIRequest-i3Jjf", "measured": { - "height": 523, - "width": 360 + "height": 465, + "width": 320 }, "position": { "x": 99.03855391505124, @@ -553,9 +606,11 @@ }, { "data": { - "id": "ChatInput-eumKo", + "id": "ChatInput-DbY0i", "node": { - "base_classes": ["Message"], + "base_classes": [ + "Message" + ], "beta": false, "category": "inputs", "conditional_paths": [], @@ -592,7 +647,9 @@ "name": "message", "selected": "Message", "tool_mode": true, - "types": ["Message"], + "types": [ + "Message" + ], "value": "__UNDEFINED__" } ], @@ -606,7 +663,9 @@ "display_name": "Background Color", "dynamic": false, "info": "The background color of the icon.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -627,7 +686,9 @@ "display_name": "Icon", "dynamic": false, "info": "The icon of the message.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -735,7 +796,10 @@ "dynamic": false, "info": "Type of sender.", "name": "sender", - "options": ["Machine", "User"], + "options": [ + "Machine", + "User" + ], "options_metadata": [], "placeholder": "", "required": false, @@ -752,7 +816,9 @@ "display_name": "Sender Name", "dynamic": false, "info": "Name of the sender.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -773,7 +839,9 @@ "display_name": "Session ID", "dynamic": false, "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -812,7 +880,9 @@ "display_name": "Text Color", "dynamic": false, "info": "The text color of the name", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -834,10 +904,10 @@ "type": "ChatInput" }, "dragging": false, - "id": "ChatInput-eumKo", + "id": "ChatInput-DbY0i", "measured": { - "height": 74, - "width": 216 + "height": 66, + "width": 192 }, "position": { "x": 253.05570107641427, @@ -848,9 +918,11 @@ }, { "data": { - "id": "ChatOutput-D4JWF", + "id": "ChatOutput-iRmVU", "node": { - "base_classes": ["Message"], + "base_classes": [ + "Message" + ], "beta": false, "category": "outputs", "conditional_paths": [], @@ -887,7 +959,9 @@ "name": "message", "selected": "Message", "tool_mode": true, - "types": ["Message"], + "types": [ + "Message" + ], "value": "__UNDEFINED__" } ], @@ -901,7 +975,9 @@ "display_name": "Background Color", "dynamic": false, "info": "The background color of the icon.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -922,7 +998,9 @@ "display_name": "Icon", "dynamic": false, "info": "The icon of the message.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -979,7 +1057,9 @@ "display_name": "Data Template", "dynamic": false, "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1000,7 +1080,11 @@ "display_name": "Text", "dynamic": false, "info": "Message to be passed as output.", - "input_types": ["Data", "DataFrame", "Message"], + "input_types": [ + "Data", + "DataFrame", + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1024,7 +1108,10 @@ "dynamic": false, "info": "Type of sender.", "name": "sender", - "options": ["Machine", "User"], + "options": [ + "Machine", + "User" + ], "options_metadata": [], "placeholder": "", "required": false, @@ -1041,7 +1128,9 @@ "display_name": "Sender Name", "dynamic": false, "info": "Name of the sender.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1062,7 +1151,9 @@ "display_name": "Session ID", "dynamic": false, "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1101,7 +1192,9 @@ "display_name": "Text Color", "dynamic": false, "info": "The text color of the name", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1122,10 +1215,10 @@ "showNode": false, "type": "ChatOutput" }, - "id": "ChatOutput-D4JWF", + "id": "ChatOutput-iRmVU", "measured": { - "height": 74, - "width": 216 + "height": 66, + "width": 192 }, "position": { "x": 1020, @@ -1136,7 +1229,7 @@ }, { "data": { - "id": "note-nfy3d", + "id": "note-fD7hP", "node": { "description": "## Open the playground and ask anything about a Pokémon! ⚡ 🐹", "display_name": "", @@ -1149,10 +1242,10 @@ }, "dragging": false, "height": 324, - "id": "note-nfy3d", + "id": "note-fD7hP", "measured": { "height": 324, - "width": 393 + "width": 390 }, "position": { "x": 972.3620941029305, @@ -1165,7 +1258,7 @@ }, { "data": { - "id": "note-JAywo", + "id": "note-VHYRv", "node": { "description": "# Pokédex Agent\n\nCollect research on Pokémon with a specialized **Agent** and the Pokédex API.\n\n## Prerequisites\n\n* An [OpenAI API key](https://platform.openai.com/)\n\n## Quickstart\n\n1. Paste your OpenAI API key in the **Agent** component.\n2. Click **Playground** and ask about your favorite Pokémon.\nThe **Agent** queries the Pokedex API and returns a formatted entry.", "display_name": "", @@ -1176,10 +1269,10 @@ }, "dragging": false, "height": 543, - "id": "note-JAywo", + "id": "note-VHYRv", "measured": { "height": 543, - "width": 352 + "width": 349 }, "position": { "x": -364.79357624384227, @@ -1192,7 +1285,7 @@ }, { "data": { - "id": "note-xMIW2", + "id": "note-KTLM0", "node": { "description": "### 💡 Add your OpenAI API key here", "display_name": "", @@ -1205,10 +1298,10 @@ }, "dragging": false, "height": 324, - "id": "note-xMIW2", + "id": "note-KTLM0", "measured": { "height": 324, - "width": 337 + "width": 334 }, "position": { "x": 572.2283687381639, @@ -1221,9 +1314,11 @@ }, { "data": { - "id": "Agent-gZkrx", + "id": "Agent-LwvP3", "node": { - "base_classes": ["Message"], + "base_classes": [ + "Message" + ], "beta": false, "category": "agents", "conditional_paths": [], @@ -1276,7 +1371,9 @@ "name": "response", "selected": "Message", "tool_mode": true, - "types": ["Message"], + "types": [ + "Message" + ], "value": "__UNDEFINED__" } ], @@ -1309,7 +1406,9 @@ "display_name": "Agent Description [Deprecated]", "dynamic": false, "info": "The description of the agent. This is only used when in Tool Mode. Defaults to 'A helpful assistant with access to the following tools:' and tools are added dynamically. This feature is deprecated and will be removed in future versions.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1391,7 +1490,9 @@ "display_name": "OpenAI API Key", "dynamic": false, "info": "The OpenAI API Key to use for the OpenAI model.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "load_from_db": true, "name": "api_key", "password": true, @@ -1444,7 +1545,9 @@ "display_name": "Input", "dynamic": false, "info": "The input provided by the user for the agent to process.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1543,7 +1646,9 @@ "display_name": "External Memory", "dynamic": false, "info": "Retrieve messages from an external memory. If empty, it will use the Langflow tables.", - "input_types": ["Memory"], + "input_types": [ + "Memory" + ], "list": false, "list_add_label": "Add More", "name": "memory", @@ -1648,7 +1753,10 @@ "dynamic": false, "info": "Order of the messages.", "name": "order", - "options": ["Ascending", "Descending"], + "options": [ + "Ascending", + "Descending" + ], "options_metadata": [], "placeholder": "", "required": false, @@ -1686,7 +1794,11 @@ "dynamic": false, "info": "Filter by sender type.", "name": "sender", - "options": ["Machine", "User", "Machine and User"], + "options": [ + "Machine", + "User", + "Machine and User" + ], "options_metadata": [], "placeholder": "", "required": false, @@ -1703,7 +1815,9 @@ "display_name": "Sender Name", "dynamic": false, "info": "Filter by sender name.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1724,7 +1838,9 @@ "display_name": "Session ID", "dynamic": false, "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1746,7 +1862,9 @@ "display_name": "Agent Instructions", "dynamic": false, "info": "System Prompt: Initial instructions and context provided to guide the agent's behavior.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1797,7 +1915,9 @@ "display_name": "Template", "dynamic": false, "info": "The template to use for formatting the data. It can contain the keys {text}, {sender} or any other key in the message data.", - "input_types": ["Message"], + "input_types": [ + "Message" + ], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1837,7 +1957,9 @@ "display_name": "Tools", "dynamic": false, "info": "These are the tools that the agent can use to help with tasks.", - "input_types": ["Tool"], + "input_types": [ + "Tool" + ], "list": true, "list_add_label": "Add More", "name": "tools", @@ -1873,10 +1995,10 @@ "showNode": true, "type": "Agent" }, - "id": "Agent-gZkrx", + "id": "Agent-LwvP3", "measured": { - "height": 698, - "width": 360 + "height": 621, + "width": 320 }, "position": { "x": 585, @@ -1887,9 +2009,9 @@ } ], "viewport": { - "x": 335.4484893412189, - "y": 681.2799267718543, - "zoom": 0.7488502535633749 + "x": 354.89464052611004, + "y": 692.49044875044, + "zoom": 0.7924384570705768 } }, "description": "Research Pokémon with a specialized Agent and the Pokédex API.", @@ -1898,5 +2020,7 @@ "is_component": false, "last_tested_version": "1.2.0", "name": "Pokédex Agent", - "tags": ["agents"] -} + "tags": [ + "agents" + ] +} \ No newline at end of file diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json index f451eaeb0..2626686d5 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json @@ -9,16 +9,12 @@ "dataType": "File", "id": "File-Ktatn", "name": "data", - "output_types": [ - "Data" - ] + "output_types": ["Data"] }, "targetHandle": { "fieldName": "data", "id": "ParseData-gouVC", - "inputTypes": [ - "Data" - ], + "inputTypes": ["Data"], "type": "other" } }, @@ -37,16 +33,12 @@ "dataType": "ParseData", "id": "ParseData-gouVC", "name": "text", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "text", "id": "Prompt-epiSD", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -65,16 +57,12 @@ "dataType": "Prompt", "id": "Prompt-epiSD", "name": "prompt", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "system_message", "id": "OpenAIModel-ppS3O", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -93,16 +81,12 @@ "dataType": "OpenAIModel", "id": "OpenAIModel-ppS3O", "name": "text_output", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "summary", "id": "Prompt-l9XAo", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -121,16 +105,12 @@ "dataType": "Prompt", "id": "Prompt-l9XAo", "name": "prompt", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "system_message", "id": "OpenAIModel-DxfrQ", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -149,16 +129,12 @@ "dataType": "Prompt", "id": "Prompt-LKleN", "name": "prompt", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "system_message", "id": "OpenAIModel-W1vhv", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -177,16 +153,12 @@ "dataType": "ParseData", "id": "ParseData-gouVC", "name": "text", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "input_value", "id": "OpenAIModel-W1vhv", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -205,18 +177,12 @@ "dataType": "OpenAIModel", "id": "OpenAIModel-W1vhv", "name": "text_output", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "input_value", "id": "ChatOutput-V5ZFA", - "inputTypes": [ - "Data", - "DataFrame", - "Message" - ], + "inputTypes": ["Data", "DataFrame", "Message"], "type": "other" } }, @@ -235,18 +201,12 @@ "dataType": "OpenAIModel", "id": "OpenAIModel-DxfrQ", "name": "text_output", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "input_value", "id": "ChatOutput-8y94b", - "inputTypes": [ - "Data", - "DataFrame", - "Message" - ], + "inputTypes": ["Data", "DataFrame", "Message"], "type": "other" } }, @@ -263,9 +223,7 @@ "data": { "id": "File-Ktatn", "node": { - "base_classes": [ - "Data" - ], + "base_classes": ["Data"], "beta": false, "category": "data", "conditional_paths": [], @@ -302,9 +260,31 @@ "required_inputs": [], "selected": "Data", "tool_mode": true, - "types": [ - "Data" - ], + "types": ["Data"], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "DataFrame", + "method": "load_dataframe", + "name": "dataframe", + "required_inputs": [], + "selected": "DataFrame", + "tool_mode": true, + "types": ["DataFrame"], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "Message", + "method": "load_message", + "name": "message", + "required_inputs": [], + "selected": "Message", + "tool_mode": true, + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -372,10 +352,7 @@ "display_name": "Server File Path", "dynamic": false, "info": "Data object with a 'file_path' property pointing to server file or a Message object with a path to the file. Supercedes 'Path' but supports same file types.", - "input_types": [ - "Data", - "Message" - ], + "input_types": ["Data", "Message"], "list": true, "list_add_label": "Add More", "name": "file_path", @@ -426,7 +403,7 @@ "path": { "_input_type": "FileInput", "advanced": false, - "display_name": "Path", + "display_name": "Files", "dynamic": false, "fileTypes": [ "txt", @@ -455,7 +432,7 @@ ], "file_path": "43bb2a52-8dbf-4edf-a200-c54ee0e7fa1f\\2025-02-21_09-35-24_messages.json", "info": "Supported file extensions: txt, md, mdx, csv, json, yaml, yml, xml, html, htm, pdf, docx, py, sh, sql, js, ts, tsx; optionally bundled in file extensions: zip, tar, tgz, bz2, gz", - "list": false, + "list": true, "list_add_label": "Add More", "name": "path", "placeholder": "", @@ -466,6 +443,25 @@ "type": "file", "value": "" }, + "separator": { + "_input_type": "StrInput", + "advanced": true, + "display_name": "Separator", + "dynamic": false, + "info": "Specify the separator to use between multiple outputs in Message format.", + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "separator", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "str", + "value": "\n\n" + }, "silent_errors": { "_input_type": "BoolInput", "advanced": true, @@ -525,10 +521,7 @@ "data": { "id": "ParseData-gouVC", "node": { - "base_classes": [ - "Data", - "Message" - ], + "base_classes": ["Data", "Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -536,11 +529,7 @@ "display_name": "Data to Message", "documentation": "", "edited": false, - "field_order": [ - "data", - "template", - "sep" - ], + "field_order": ["data", "template", "sep"], "frozen": false, "icon": "message-square", "legacy": false, @@ -559,9 +548,7 @@ "name": "text", "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" }, { @@ -572,9 +559,7 @@ "name": "data_list", "selected": "Data", "tool_mode": true, - "types": [ - "Data" - ], + "types": ["Data"], "value": "__UNDEFINED__" } ], @@ -605,9 +590,7 @@ "display_name": "Data", "dynamic": false, "info": "The data to convert to text.", - "input_types": [ - "Data" - ], + "input_types": ["Data"], "list": true, "list_add_label": "Add More", "name": "data", @@ -646,9 +629,7 @@ "display_name": "Template", "dynamic": false, "info": "The template to use for formatting the data. It can contain the keys {text}, {data} or any other key in the Data.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -662,7 +643,7 @@ "trace_as_input": true, "trace_as_metadata": true, "type": "str", - "value": "list of messages details: {text}" + "value": "list of messages details: {file_path}" } }, "tool_mode": false @@ -687,25 +668,18 @@ "data": { "id": "Prompt-l9XAo", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": { - "template": [ - "summary" - ] + "template": ["summary"] }, "description": "Create a prompt template with dynamic variables.", "display_name": "Prompt", "documentation": "", "edited": false, "error": null, - "field_order": [ - "template", - "tool_placeholder" - ], + "field_order": ["template", "tool_placeholder"], "frozen": false, "full_path": null, "icon": "prompts", @@ -727,9 +701,7 @@ "name": "prompt", "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -762,9 +734,7 @@ "fileTypes": [], "file_path": "", "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "load_from_db": false, "multiline": true, @@ -800,9 +770,7 @@ "display_name": "Tool Placeholder", "dynamic": false, "info": "A placeholder input for tool mode.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -840,25 +808,18 @@ "data": { "id": "Prompt-epiSD", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": { - "template": [ - "text" - ] + "template": ["text"] }, "description": "Create a prompt template with dynamic variables.", "display_name": "Prompt", "documentation": "", "edited": false, "error": null, - "field_order": [ - "template", - "tool_placeholder" - ], + "field_order": ["template", "tool_placeholder"], "frozen": false, "full_path": null, "icon": "prompts", @@ -880,9 +841,7 @@ "name": "prompt", "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -933,9 +892,7 @@ "fileTypes": [], "file_path": "", "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "load_from_db": false, "multiline": true, @@ -953,9 +910,7 @@ "display_name": "Tool Placeholder", "dynamic": false, "info": "A placeholder input for tool mode.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -993,10 +948,7 @@ "data": { "id": "OpenAIModel-ppS3O", "node": { - "base_classes": [ - "LanguageModel", - "Message" - ], + "base_classes": ["LanguageModel", "Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1036,9 +988,7 @@ "required_inputs": [], "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" }, { @@ -1047,14 +997,10 @@ "display_name": "Language Model", "method": "build_model", "name": "model_output", - "required_inputs": [ - "api_key" - ], + "required_inputs": ["api_key"], "selected": "LanguageModel", "tool_mode": true, - "types": [ - "LanguageModel" - ], + "types": ["LanguageModel"], "value": "__UNDEFINED__" } ], @@ -1067,9 +1013,7 @@ "display_name": "OpenAI API Key", "dynamic": false, "info": "The OpenAI API Key to use for the OpenAI model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "load_from_db": false, "name": "api_key", "password": true, @@ -1096,7 +1040,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\", display_name=\"Temperature\", value=0.1, range_spec=RangeSpec(min=0, max=1, step=0.01)\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = self.json_mode\n seed = self.seed\n max_retries = self.max_retries\n timeout = self.timeout\n\n api_key = SecretStr(openai_api_key).get_secret_value() if openai_api_key else None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n max_retries=max_retries,\n request_timeout=timeout,\n )\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n" + "value": "from langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = self.json_mode\n seed = self.seed\n max_retries = self.max_retries\n timeout = self.timeout\n\n api_key = SecretStr(openai_api_key).get_secret_value() if openai_api_key else None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n max_retries=max_retries,\n request_timeout=timeout,\n )\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n" }, "input_value": { "_input_type": "MessageInput", @@ -1104,9 +1048,7 @@ "display_name": "Input", "dynamic": false, "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1266,7 +1208,7 @@ }, "stream": { "_input_type": "BoolInput", - "advanced": false, + "advanced": true, "display_name": "Stream", "dynamic": false, "info": "Stream the response from the model. Streaming works only in Chat.", @@ -1288,9 +1230,7 @@ "display_name": "System Message", "dynamic": false, "info": "System message to pass to the model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1308,7 +1248,7 @@ }, "temperature": { "_input_type": "SliderInput", - "advanced": false, + "advanced": true, "display_name": "Temperature", "dynamic": false, "info": "", @@ -1375,10 +1315,7 @@ "data": { "id": "OpenAIModel-DxfrQ", "node": { - "base_classes": [ - "LanguageModel", - "Message" - ], + "base_classes": ["LanguageModel", "Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1418,9 +1355,7 @@ "required_inputs": [], "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" }, { @@ -1429,14 +1364,10 @@ "display_name": "Language Model", "method": "build_model", "name": "model_output", - "required_inputs": [ - "api_key" - ], + "required_inputs": ["api_key"], "selected": "LanguageModel", "tool_mode": true, - "types": [ - "LanguageModel" - ], + "types": ["LanguageModel"], "value": "__UNDEFINED__" } ], @@ -1449,9 +1380,7 @@ "display_name": "OpenAI API Key", "dynamic": false, "info": "The OpenAI API Key to use for the OpenAI model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "load_from_db": false, "name": "api_key", "password": true, @@ -1478,7 +1407,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\", display_name=\"Temperature\", value=0.1, range_spec=RangeSpec(min=0, max=1, step=0.01)\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = self.json_mode\n seed = self.seed\n max_retries = self.max_retries\n timeout = self.timeout\n\n api_key = SecretStr(openai_api_key).get_secret_value() if openai_api_key else None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n max_retries=max_retries,\n request_timeout=timeout,\n )\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n" + "value": "from langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = self.json_mode\n seed = self.seed\n max_retries = self.max_retries\n timeout = self.timeout\n\n api_key = SecretStr(openai_api_key).get_secret_value() if openai_api_key else None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n max_retries=max_retries,\n request_timeout=timeout,\n )\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n" }, "input_value": { "_input_type": "MessageInput", @@ -1486,9 +1415,7 @@ "display_name": "Input", "dynamic": false, "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1648,7 +1575,7 @@ }, "stream": { "_input_type": "BoolInput", - "advanced": false, + "advanced": true, "display_name": "Stream", "dynamic": false, "info": "Stream the response from the model. Streaming works only in Chat.", @@ -1670,9 +1597,7 @@ "display_name": "System Message", "dynamic": false, "info": "System message to pass to the model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1690,7 +1615,7 @@ }, "temperature": { "_input_type": "SliderInput", - "advanced": false, + "advanced": true, "display_name": "Temperature", "dynamic": false, "info": "", @@ -1757,9 +1682,7 @@ "data": { "id": "Prompt-LKleN", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": { @@ -1770,10 +1693,7 @@ "documentation": "", "edited": false, "error": null, - "field_order": [ - "template", - "tool_placeholder" - ], + "field_order": ["template", "tool_placeholder"], "frozen": false, "full_path": null, "icon": "prompts", @@ -1795,9 +1715,7 @@ "name": "prompt", "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -1846,9 +1764,7 @@ "display_name": "Tool Placeholder", "dynamic": false, "info": "A placeholder input for tool mode.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1886,10 +1802,7 @@ "data": { "id": "OpenAIModel-W1vhv", "node": { - "base_classes": [ - "LanguageModel", - "Message" - ], + "base_classes": ["LanguageModel", "Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1929,9 +1842,7 @@ "required_inputs": [], "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" }, { @@ -1940,14 +1851,10 @@ "display_name": "Language Model", "method": "build_model", "name": "model_output", - "required_inputs": [ - "api_key" - ], + "required_inputs": ["api_key"], "selected": "LanguageModel", "tool_mode": true, - "types": [ - "LanguageModel" - ], + "types": ["LanguageModel"], "value": "__UNDEFINED__" } ], @@ -1960,9 +1867,7 @@ "display_name": "OpenAI API Key", "dynamic": false, "info": "The OpenAI API Key to use for the OpenAI model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "load_from_db": false, "name": "api_key", "password": true, @@ -1989,7 +1894,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\", display_name=\"Temperature\", value=0.1, range_spec=RangeSpec(min=0, max=1, step=0.01)\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = self.json_mode\n seed = self.seed\n max_retries = self.max_retries\n timeout = self.timeout\n\n api_key = SecretStr(openai_api_key).get_secret_value() if openai_api_key else None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n max_retries=max_retries,\n request_timeout=timeout,\n )\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n" + "value": "from langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\n\nfrom langflow.base.models.model import LCModelComponent\nfrom langflow.base.models.openai_constants import OPENAI_MODEL_NAMES\nfrom langflow.field_typing import LanguageModel\nfrom langflow.field_typing.range_spec import RangeSpec\nfrom langflow.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\n\nclass OpenAIModelComponent(LCModelComponent):\n display_name = \"OpenAI\"\n description = \"Generates text using OpenAI LLMs.\"\n icon = \"OpenAI\"\n name = \"OpenAIModel\"\n\n inputs = [\n *LCModelComponent._base_inputs,\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=OPENAI_MODEL_NAMES,\n value=OPENAI_MODEL_NAMES[1],\n combobox=True,\n ),\n StrInput(\n name=\"openai_api_base\",\n display_name=\"OpenAI API Base\",\n advanced=True,\n info=\"The base URL of the OpenAI API. \"\n \"Defaults to https://api.openai.com/v1. \"\n \"You can change this to use other APIs like JinaChat, LocalAI and Prem.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"The OpenAI API Key to use for the OpenAI model.\",\n advanced=False,\n value=\"OPENAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n info=\"The maximum number of retries to make when generating.\",\n advanced=True,\n value=5,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"The timeout for requests to OpenAI completion API.\",\n advanced=True,\n value=700,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n openai_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n openai_api_base = self.openai_api_base or \"https://api.openai.com/v1\"\n json_mode = self.json_mode\n seed = self.seed\n max_retries = self.max_retries\n timeout = self.timeout\n\n api_key = SecretStr(openai_api_key).get_secret_value() if openai_api_key else None\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=openai_api_base,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n max_retries=max_retries,\n request_timeout=timeout,\n )\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an OpenAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n" }, "input_value": { "_input_type": "MessageInput", @@ -1997,9 +1902,7 @@ "display_name": "Input", "dynamic": false, "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2159,7 +2062,7 @@ }, "stream": { "_input_type": "BoolInput", - "advanced": false, + "advanced": true, "display_name": "Stream", "dynamic": false, "info": "Stream the response from the model. Streaming works only in Chat.", @@ -2181,9 +2084,7 @@ "display_name": "System Message", "dynamic": false, "info": "System message to pass to the model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2201,7 +2102,7 @@ }, "temperature": { "_input_type": "SliderInput", - "advanced": false, + "advanced": true, "display_name": "Temperature", "dynamic": false, "info": "", @@ -2295,9 +2196,7 @@ "data": { "id": "ChatOutput-V5ZFA", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -2333,9 +2232,7 @@ "name": "message", "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2348,9 +2245,7 @@ "display_name": "Background Color", "dynamic": false, "info": "The background color of the icon.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2371,9 +2266,7 @@ "display_name": "Icon", "dynamic": false, "info": "The icon of the message.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2430,9 +2323,7 @@ "display_name": "Data Template", "dynamic": false, "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2453,11 +2344,7 @@ "display_name": "Text", "dynamic": false, "info": "Message to be passed as output.", - "input_types": [ - "Data", - "DataFrame", - "Message" - ], + "input_types": ["Data", "DataFrame", "Message"], "list": false, "list_add_label": "Add More", "name": "input_value", @@ -2478,10 +2365,7 @@ "dynamic": false, "info": "Type of sender.", "name": "sender", - "options": [ - "Machine", - "User" - ], + "options": ["Machine", "User"], "options_metadata": [], "placeholder": "", "required": false, @@ -2498,9 +2382,7 @@ "display_name": "Sender Name", "dynamic": false, "info": "Name of the sender.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2521,9 +2403,7 @@ "display_name": "Session ID", "dynamic": false, "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2562,9 +2442,7 @@ "display_name": "Text Color", "dynamic": false, "info": "The text color of the name", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2602,9 +2480,7 @@ "data": { "id": "ChatOutput-8y94b", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -2640,9 +2516,7 @@ "name": "message", "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2655,9 +2529,7 @@ "display_name": "Background Color", "dynamic": false, "info": "The background color of the icon.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2678,9 +2550,7 @@ "display_name": "Icon", "dynamic": false, "info": "The icon of the message.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2737,9 +2607,7 @@ "display_name": "Data Template", "dynamic": false, "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2760,11 +2628,7 @@ "display_name": "Text", "dynamic": false, "info": "Message to be passed as output.", - "input_types": [ - "Data", - "DataFrame", - "Message" - ], + "input_types": ["Data", "DataFrame", "Message"], "list": false, "list_add_label": "Add More", "name": "input_value", @@ -2785,10 +2649,7 @@ "dynamic": false, "info": "Type of sender.", "name": "sender", - "options": [ - "Machine", - "User" - ], + "options": ["Machine", "User"], "options_metadata": [], "placeholder": "", "required": false, @@ -2805,9 +2666,7 @@ "display_name": "Sender Name", "dynamic": false, "info": "Name of the sender.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2828,9 +2687,7 @@ "display_name": "Session ID", "dynamic": false, "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2869,9 +2726,7 @@ "display_name": "Text Color", "dynamic": false, "info": "The text color of the name", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2999,7 +2854,5 @@ "is_component": false, "last_tested_version": "1.2.0", "name": "Text Sentiment Analysis", - "tags": [ - "classification" - ] -} \ No newline at end of file + "tags": ["classification"] +} diff --git a/src/backend/tests/unit/components/data/test_api_request_component.py b/src/backend/tests/unit/components/data/test_api_request_component.py index 6230d66b5..f90df1df9 100644 --- a/src/backend/tests/unit/components/data/test_api_request_component.py +++ b/src/backend/tests/unit/components/data/test_api_request_component.py @@ -1,6 +1,5 @@ -import tempfile from pathlib import Path -from unittest.mock import Mock +from unittest.mock import patch import aiofiles import aiofiles.os @@ -8,172 +7,306 @@ import httpx import pytest import respx from httpx import Response -from langflow.components import data +from langflow.components.data import APIRequestComponent +from langflow.schema import Data, DataFrame, Message + +from tests.base import ComponentTestBaseWithoutClient -@pytest.fixture -def api_request(): - # This fixture provides an instance of APIRequest for each test case - return data.APIRequestComponent() +class TestAPIRequestComponent(ComponentTestBaseWithoutClient): + @pytest.fixture + def component_class(self): + """Return the component class to test.""" + return APIRequestComponent + @pytest.fixture + def default_kwargs(self): + """Return the default kwargs for the component.""" + return { + "urls": ["https://example.com/api/test"], + "method": "GET", + "headers": [], + "body": [], + "timeout": 5, + "follow_redirects": True, + "save_to_file": False, + "include_httpx_metadata": False, + "use_curl": False, + "curl": "", + } -def test_parse_curl(api_request): - # Arrange - field_value = ( - "curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'" - ) - build_config = { - "method": {"value": ""}, - "urls": {"value": []}, - "headers": {}, - "body": {}, - } - # Act - new_build_config = api_request.parse_curl(field_value, build_config.copy()) + @pytest.fixture + def file_names_mapping(self): + """Return an empty list since this component doesn't have version-specific files.""" + return [] - # Assert - assert new_build_config["method"]["value"] == "GET" - assert new_build_config["urls"]["value"] == ["https://example.com/api/test"] - assert new_build_config["headers"]["value"] == [{"key": "Content-Type", "value": "application/json"}] - assert new_build_config["body"]["value"] == [{"key": "key", "value": "value"}] + @pytest.fixture + async def component(self, component_class, default_kwargs): + """Return a component instance.""" + return component_class(**default_kwargs) + async def test_parse_curl(self, component): + # Test basic curl command parsing + curl_cmd = ( + "curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'" + ) + build_config = { + "method": {"value": ""}, + "urls": {"value": []}, + "headers": {}, + "body": {}, + } + new_build_config = component.parse_curl(curl_cmd, build_config.copy()) -# HTTPx Metadata testing -@pytest.mark.parametrize( - ("include_metadata", "expected_properties"), - [ - (False, {"source", "result"}), - (True, {"source", "result", "headers", "status_code", "response_headers", "redirection_history"}), - ], -) -@respx.mock -async def test_httpx_metadata_behavior(api_request, include_metadata, expected_properties): - # Mocking a successful GET request with headers and a redirection - url = "https://example.com/api/test" - redirected_url = "https://example.com/api/redirect" - response_content = {"key": "value"} - respx.get(url).mock(return_value=Response(303, headers={"Location": redirected_url})) - respx.get(redirected_url).mock( - return_value=Response(200, json=response_content, headers={"Custom-Header": "HeaderValue"}) - ) + assert new_build_config["method"]["value"] == "GET" + assert new_build_config["urls"]["value"] == ["https://example.com/api/test"] + assert new_build_config["headers"]["value"] == [{"key": "Content-Type", "value": "application/json"}] + assert new_build_config["body"]["value"] == [{"key": "key", "value": "value"}] - # Make the request - result = await api_request.make_request( - client=httpx.AsyncClient(), - method="GET", - url=url, - save_to_file=False, - include_httpx_metadata=include_metadata, - ) + @respx.mock + async def test_make_request_success(self, component): + # Test successful request with JSON response + url = "https://example.com/api/test" + response_data = {"key": "value"} + respx.get(url).mock(return_value=Response(200, json=response_data)) - # Check returned metadata - metadata = result.data - assert set(metadata.keys()) == expected_properties, f"Unexpected properties: {set(metadata.keys())}" - - if include_metadata: - # Validate individual fields - assert metadata["source"] == url - assert metadata["headers"] is None - assert metadata["status_code"] == 200 - assert metadata["response_headers"]["custom-header"] == "HeaderValue" - - # Validate redirection history - assert metadata["redirection_history"] == [{"url": redirected_url, "status_code": 303}], ( - "Redirection history is incorrect" + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, ) - # Validate result - assert metadata["result"] == response_content, "Response content mismatch" + assert isinstance(result, Data) + assert result.data["source"] == url + assert "key" in result.data + assert result.data["key"] == "value" + @respx.mock + async def test_make_request_with_metadata(self, component): + # Test request with metadata included + url = "https://example.com/api/test" + headers = {"Custom-Header": "Value"} + response_data = {"key": "value"} + respx.get(url).mock(return_value=Response(200, json=response_data, headers=headers)) -# Save to File testing -@pytest.mark.parametrize( - ("save_to_file", "expected_properties"), - [ - (False, {"source", "result"}), - (True, {"source", "file_path"}), - ], -) -@respx.mock -async def test_save_to_file_behavior(api_request, save_to_file, expected_properties): - # Mocking a successful GET request with a response body - url = "https://example.com/api/test" - response_content = "Test response content" - respx.get(url).mock(return_value=Response(200, content=response_content)) + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, + include_httpx_metadata=True, + ) - # Make the request - result = await api_request.make_request( - client=httpx.AsyncClient(), - method="GET", - url=url, - save_to_file=save_to_file, - ) + assert isinstance(result, Data) + assert result.data["source"] == url + assert result.data["status_code"] == 200 + assert result.data["response_headers"]["custom-header"] == "Value" - # Check returned metadata - metadata = result.data - assert set(metadata.keys()) == expected_properties, ( - f"Unexpected properties: {set(metadata.keys())}. Raw result: {result.data}" - ) + @respx.mock + async def test_make_request_save_to_file(self, component): + # Test saving response to file + url = "https://example.com/api/test" + content = "Test content" + respx.get(url).mock(return_value=Response(200, text=content)) - if save_to_file: - # Validate that file_path exists in metadata - assert "file_path" in metadata, "file_path is missing in metadata" - file_path = metadata["file_path"] + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, + save_to_file=True, + ) - # Validate that the file exists and its content matches the response - assert await aiofiles.os.path.exists(file_path), "Saved file does not exist" + assert isinstance(result, Data) + assert "file_path" in result.data + file_path = Path(result.data["file_path"]) + + # Use async file operations + assert await aiofiles.os.path.exists(file_path) async with aiofiles.open(file_path) as f: - file_content = await f.read() - assert file_content == response_content, "File content does not match response content" + saved_content = await f.read() + assert saved_content == content - # Cleanup the file + # Cleanup using async operation await aiofiles.os.remove(file_path) - else: - # Validate that result exists in metadata - assert "result" in metadata, "result is missing in metadata" - assert metadata["result"] == response_content.encode("utf-8"), "Response content mismatch in metadata" + @respx.mock + async def test_make_request_binary_response(self, component): + # Test handling binary response + url = "https://example.com/api/binary" + binary_content = b"Binary content" + headers = {"Content-Type": "application/octet-stream"} + respx.get(url).mock(return_value=Response(200, content=binary_content, headers=headers)) -async def test_response_info_binary_content(api_request): - response = Mock() - response.headers = {"Content-Type": "application/octet-stream"} - is_binary, file_path = await api_request._response_info(response, with_file_path=False) - assert is_binary is True - assert file_path is None + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, + ) + assert isinstance(result, Data) + assert result.data["source"] == url + assert result.data["data"] == binary_content -async def test_response_info_non_binary_content(api_request): - response = Mock() - response.headers = {"Content-Type": "text/plain"} - is_binary, file_path = await api_request._response_info(response, with_file_path=False) - assert is_binary is False - assert file_path is None + @respx.mock + async def test_make_request_timeout(self, component): + # Test request timeout + url = "https://example.com/api/test" + respx.get(url).mock(side_effect=httpx.TimeoutException("Request timed out")) + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, + timeout=1, + ) -async def test_response_info_filename_from_content_disposition(api_request): - response = Mock() - response.headers = { - "Content-Disposition": 'attachment; filename="thisfile.txt"', - "Content-Type": "text/plain", - } - response.request = Mock() - response.request.url = "https://example.com/testfile" + assert isinstance(result, Data) + assert result.data["status_code"] == 408 + assert result.data["error"] == "Request timed out" - is_binary, file_path = await api_request._response_info(response, with_file_path=True) + @respx.mock + async def test_make_request_with_redirects(self, component): + # Test handling redirects + url = "https://example.com/api/test" + redirect_url = "https://example.com/api/redirect" + final_data = {"key": "value"} - assert is_binary is False - assert file_path.parent == Path(tempfile.gettempdir()) / "APIRequestComponent" - assert file_path.name.endswith("thisfile.txt") + respx.get(url).mock(return_value=Response(303, headers={"Location": redirect_url})) + respx.get(redirect_url).mock(return_value=Response(200, json=final_data)) + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, + include_httpx_metadata=True, + follow_redirects=True, + ) -async def test_response_info_default_filename(api_request): - response = Mock() - response.headers = {"Content-Type": "text/plain"} - response.request = Mock() - response.request.url = "https://example.com/testfile" + assert isinstance(result, Data) + assert result.data["source"] == url + assert result.data["status_code"] == 200 + assert result.data["redirection_history"] == [{"url": redirect_url, "status_code": 303}] - is_binary, file_path = await api_request._response_info(response, with_file_path=True) + async def test_process_headers(self, component): + # Test header processing + headers_list = [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer token"}, + ] + processed = component._process_headers(headers_list) + assert processed == { + "Content-Type": "application/json", + "Authorization": "Bearer token", + } - assert is_binary is False - assert file_path.parent == Path(tempfile.gettempdir()) / "APIRequestComponent" - assert file_path.name.endswith("testfile.txt") + # Test invalid headers + assert component._process_headers(None) == {} + assert component._process_headers([{"invalid": "format"}]) == {} + + async def test_process_body(self, component): + # Test body processing + # Test dictionary body + dict_body = {"key": "value", "nested": {"inner": "value"}} + assert component._process_body(dict_body) == dict_body + + # Test string body + json_str = '{"key": "value"}' + assert component._process_body(json_str) == {"key": "value"} + + # Test list body + list_body = [{"key": "key1", "value": "value1"}, {"key": "key2", "value": "value2"}] + assert component._process_body(list_body) == {"key1": "value1", "key2": "value2"} + + # Test invalid body + assert component._process_body(None) == {} + assert component._process_body([{"invalid": "format"}]) == {} + + async def test_add_query_params(self, component): + # Test query parameter handling + url = "https://example.com/api/test" + params = {"param1": "value1", "param2": "value2"} + result = component.add_query_params(url, params) + assert "param1=value1" in result + assert "param2=value2" in result + + # Test with existing query params + url_with_params = "https://example.com/api/test?existing=true" + result = component.add_query_params(url_with_params, params) + assert "existing=true" in result + assert "param1=value1" in result + assert "param2=value2" in result + + async def test_output_formats(self, component): + # Test different output formats + with patch.object(component, "make_requests") as mock_make_requests: + mock_make_requests.return_value = [Data(data={"key": "value"})] + + # Test DataFrame output + df_result = await component.as_dataframe() + assert isinstance(df_result, DataFrame) + + # Test Message output + msg_result = await component.as_message() + assert isinstance(msg_result, Message) + + # Test Data output + data_result = await component.as_data() + assert isinstance(data_result, Data) + assert isinstance(data_result.data["output"], list) + + async def test_invalid_urls(self, component): + # Test invalid URL handling + component.urls = ["not_a_valid_url"] + with pytest.raises(ValueError, match="Invalid URLs provided"): + await component.make_requests() + + async def test_update_build_config(self, component): + # Test build config updates + build_config = { + "method": {"value": "GET", "advanced": False}, + "urls": {"value": [], "advanced": False}, + "headers": {"value": [], "advanced": True}, + "body": {"value": [], "advanced": True}, + "use_curl": {"value": False, "advanced": False}, + "curl": {"value": "", "advanced": True}, + "timeout": {"value": 5, "advanced": True}, + "follow_redirects": {"value": True, "advanced": True}, + "save_to_file": {"value": False, "advanced": True}, + "include_httpx_metadata": {"value": False, "advanced": True}, + "query_params": {"value": {}, "advanced": True}, + } + + # Test curl mode update + updated = component.update_build_config( + build_config=build_config.copy(), field_value=True, field_name="use_curl" + ) + assert updated["curl"]["advanced"] is False + assert updated["urls"]["advanced"] is True + + # Test method update + updated = component.update_build_config( + build_config=build_config.copy(), field_value="POST", field_name="method" + ) + assert updated["body"]["advanced"] is False + + @respx.mock + async def test_error_handling(self, component): + # Test various error scenarios + url = "https://example.com/api/test" + + # Test connection error + respx.get(url).mock(side_effect=httpx.ConnectError("Connection failed")) + result = await component.make_request( + client=httpx.AsyncClient(), + method="GET", + url=url, + ) + assert result.data["status_code"] == 500 + assert "Connection failed" in result.data["error"] + + # Test invalid method + with pytest.raises(ValueError, match="Unsupported method"): + await component.make_request( + client=httpx.AsyncClient(), + method="INVALID", + url=url, + ) diff --git a/src/frontend/src/components/core/GlobalVariableModal/GlobalVariableModal.tsx b/src/frontend/src/components/core/GlobalVariableModal/GlobalVariableModal.tsx index 000556072..68ec23d0d 100644 --- a/src/frontend/src/components/core/GlobalVariableModal/GlobalVariableModal.tsx +++ b/src/frontend/src/components/core/GlobalVariableModal/GlobalVariableModal.tsx @@ -11,6 +11,7 @@ import { ForwardedIconComponent } from "@/components/common/genericIconComponent import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs-button"; +import { useGetTypes } from "@/controllers/API/queries/flows/use-get-types"; import BaseModal from "@/modals/baseModal"; import useAlertStore from "@/stores/alertStore"; import { useTypesStore } from "@/stores/typesStore"; @@ -53,6 +54,7 @@ export default function GlobalVariableModal({ const { mutate: updateVariable } = usePatchGlobalVariables(); const { data: globalVariables } = useGetGlobalVariables(); const [availableFields, setAvailableFields] = useState([]); + useGetTypes({ checkCache: true, enabled: !!globalVariables }); useEffect(() => { if (globalVariables && componentFields.size > 0) { @@ -66,6 +68,8 @@ export default function GlobalVariableModal({ if (referenceField && fields.includes(referenceField)) { setFields([referenceField]); } + } else { + setAvailableFields(["System", "System Message", "System Prompt"]); } }, [globalVariables, componentFields, initialData]); diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx index 62618f95a..7169cd435 100644 --- a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx +++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx @@ -143,9 +143,9 @@ export default function PublishDropdown() { !hasIO ? "cursor-not-allowed" : "", "flex items-center", )} + data-testid="shareable-playground" > { diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx index 536df6cd7..d6157970a 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx @@ -56,14 +56,13 @@ const OptionBadge = ({ className={cn("flex items-center gap-1 truncate", className)} >
{option}
-
- - onRemove(e as unknown as React.MouseEvent) - } - data-testid="remove-icon-badge" - /> +
+ onRemove(e as unknown as React.MouseEvent) + } + > +
); diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/inputFileComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/inputFileComponent/index.tsx index 9d9d182da..360602560 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/inputFileComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/inputFileComponent/index.tsx @@ -246,27 +246,31 @@ export default function InputFileComponent({ isList={isList} > {(selectedFiles.length === 0 || isList) && ( - +
+ +
)}
diff --git a/src/frontend/src/controllers/API/queries/flows/use-get-types.ts b/src/frontend/src/controllers/API/queries/flows/use-get-types.ts index 864c0822b..fa4999570 100644 --- a/src/frontend/src/controllers/API/queries/flows/use-get-types.ts +++ b/src/frontend/src/controllers/API/queries/flows/use-get-types.ts @@ -5,18 +5,28 @@ import { api } from "../../api"; import { getURL } from "../../helpers/constants"; import { UseRequestProcessor } from "../../services/request-processor"; -export const useGetTypes: useQueryFunctionType = (options) => { +export const useGetTypes: useQueryFunctionType< + undefined, + any, + { checkCache?: boolean } +> = (options) => { const { query } = UseRequestProcessor(); const setLoading = useFlowsManagerStore((state) => state.setIsLoading); const setTypes = useTypesStore((state) => state.setTypes); - const getTypesFn = async () => { + const getTypesFn = async (checkCache = false) => { try { + if (checkCache) { + const data = useTypesStore.getState().types; + if (data && Object.keys(data).length > 0) { + return data; + } + } + const response = await api.get( `${getURL("ALL")}?force_refresh=true`, ); const data = response?.data; - console.log("[Types] Got types data:", data); setTypes(data); return data; } catch (error) { @@ -26,10 +36,14 @@ export const useGetTypes: useQueryFunctionType = (options) => { } }; - const queryResult = query(["useGetTypes"], getTypesFn, { - refetchOnWindowFocus: false, - ...options, - }); + const queryResult = query( + ["useGetTypes"], + () => getTypesFn(options?.checkCache), + { + refetchOnWindowFocus: false, + ...options, + }, + ); return queryResult; }; diff --git a/src/frontend/src/pages/AppInitPage/index.tsx b/src/frontend/src/pages/AppInitPage/index.tsx index 89b870441..986b79e6d 100644 --- a/src/frontend/src/pages/AppInitPage/index.tsx +++ b/src/frontend/src/pages/AppInitPage/index.tsx @@ -1,7 +1,6 @@ import { useGetAutoLogin } from "@/controllers/API/queries/auth"; import { useGetConfig } from "@/controllers/API/queries/config/use-get-config"; import { useGetBasicExamplesQuery } from "@/controllers/API/queries/flows/use-get-basic-examples"; -import { useGetTypes } from "@/controllers/API/queries/flows/use-get-types"; import { useGetFoldersQuery } from "@/controllers/API/queries/folders/use-get-folders"; import { useGetTagsQuery } from "@/controllers/API/queries/store"; import { useGetGlobalVariables } from "@/controllers/API/queries/variables"; @@ -15,7 +14,6 @@ import { Outlet } from "react-router-dom"; import { LoadingPage } from "../LoadingPage"; export function AppInitPage() { - const dark = useDarkStore((state) => state.dark); const refreshStars = useDarkStore((state) => state.refreshStars); const isLoading = useFlowsManagerStore((state) => state.isLoading); diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx index 2384371e5..62e348e77 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx @@ -573,11 +573,11 @@ export default function Page({ deleteKeyCode={[]} fitView={isEmptyFlow.current ? false : true} fitViewOptions={{ - minZoom: 0.4, - maxZoom: 1.25, + minZoom: 0.2, + maxZoom: 8, }} className="theme-attribution" - minZoom={0.4} + minZoom={0.2} maxZoom={8} zoomOnScroll={!view} zoomOnPinch={!view} diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index 7b7c2569e..eb0a9e1bf 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -18,9 +18,10 @@ import { FlowSidebarComponent } from "./components/flowSidebarComponent"; export default function FlowPage({ view }: { view?: boolean }): JSX.Element { const types = useTypesStore((state) => state.types); - const { isFetched: typesLoaded } = useGetTypes({ + useGetTypes({ enabled: Object.keys(types).length <= 0, }); + const setCurrentFlow = useFlowsManagerStore((state) => state.setCurrentFlow); const currentFlow = useFlowStore((state) => state.currentFlow); const currentSavedFlow = useFlowsManagerStore((state) => state.currentFlow); diff --git a/src/frontend/src/pages/MainPage/pages/emptyPage/index.tsx b/src/frontend/src/pages/MainPage/pages/emptyPage/index.tsx index eee1be19b..abb081ac2 100644 --- a/src/frontend/src/pages/MainPage/pages/emptyPage/index.tsx +++ b/src/frontend/src/pages/MainPage/pages/emptyPage/index.tsx @@ -28,13 +28,17 @@ export const EmptyPage = ({ setOpenModal }: EmptyPageProps) => { > {folders?.length > 1 ? "Empty folder" : "Start building"} -

+

Begin with a template, or start from scratch.