diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Research Translation Loop.json b/src/backend/base/langflow/initial_setup/starter_projects/Research Translation Loop.json index c1d0831c9854..659f265b8743 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Research Translation Loop.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Research Translation Loop.json @@ -7,7 +7,7 @@ "data": { "sourceHandle": { "dataType": "ChatInput", - "id": "ChatInput-nr0Vp", + "id": "ChatInput-eTUNR", "name": "message", "output_types": [ "Message" @@ -15,26 +15,27 @@ }, "targetHandle": { "fieldName": "search_query", - "id": "ArXivComponent-tAHR5", + "id": "ArXivComponent-6DHU9", "inputTypes": [ "Message" ], "type": "str" } }, - "id": "reactflow__edge-ChatInput-nr0Vp{œdataTypeœ:œChatInputœ,œidœ:œChatInput-nr0Vpœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-ArXivComponent-tAHR5{œfieldNameœ:œsearch_queryœ,œidœ:œArXivComponent-tAHR5œ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", + "id": "reactflow__edge-ChatInput-eTUNR{œdataTypeœ:œChatInputœ,œidœ:œChatInput-eTUNRœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-ArXivComponent-6DHU9{œfieldNameœ:œsearch_queryœ,œidœ:œArXivComponent-6DHU9œ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", "selected": false, - "source": "ChatInput-nr0Vp", - "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-nr0Vpœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", - "target": "ArXivComponent-tAHR5", - "targetHandle": "{œfieldNameœ: œsearch_queryœ, œidœ: œArXivComponent-tAHR5œ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + "source": "ChatInput-eTUNR", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-eTUNRœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "ArXivComponent-6DHU9", + "targetHandle": "{œfieldNameœ: œsearch_queryœ, œidœ: œArXivComponent-6DHU9œ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" }, { + "animated": false, "className": "", "data": { "sourceHandle": { "dataType": "LoopComponent", - "id": "LoopComponent-GtPZT", + "id": "LoopComponent-TLMb9", "name": "item", "output_types": [ "Data" @@ -42,7 +43,7 @@ }, "targetHandle": { "fieldName": "input_data", - "id": "ParserComponent-pXAMb", + "id": "ParserComponent-9EBD3", "inputTypes": [ "DataFrame", "Data" @@ -50,124 +51,129 @@ "type": "other" } }, - "id": "xy-edge__LoopComponent-GtPZT{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-GtPZTœ,œnameœ:œitemœ,œoutput_typesœ:[œDataœ]}-ParserComponent-pXAMb{œfieldNameœ:œinput_dataœ,œidœ:œParserComponent-pXAMbœ,œinputTypesœ:[œDataFrameœ,œDataœ],œtypeœ:œotherœ}", - "source": "LoopComponent-GtPZT", - "sourceHandle": "{œdataTypeœ: œLoopComponentœ, œidœ: œLoopComponent-GtPZTœ, œnameœ: œitemœ, œoutput_typesœ: [œDataœ]}", - "target": "ParserComponent-pXAMb", - "targetHandle": "{œfieldNameœ: œinput_dataœ, œidœ: œParserComponent-pXAMbœ, œinputTypesœ: [œDataFrameœ, œDataœ], œtypeœ: œotherœ}" + "id": "reactflow__edge-LoopComponent-TLMb9{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-TLMb9œ,œnameœ:œitemœ,œoutput_typesœ:[œDataœ]}-ParserComponent-9EBD3{œfieldNameœ:œinput_dataœ,œidœ:œParserComponent-9EBD3œ,œinputTypesœ:[œDataFrameœ,œDataœ],œtypeœ:œotherœ}", + "selected": false, + "source": "LoopComponent-TLMb9", + "sourceHandle": "{œdataTypeœ: œLoopComponentœ, œidœ: œLoopComponent-TLMb9œ, œnameœ: œitemœ, œoutput_typesœ: [œDataœ]}", + "target": "ParserComponent-9EBD3", + "targetHandle": "{œfieldNameœ: œinput_dataœ, œidœ: œParserComponent-9EBD3œ, œinputTypesœ: [œDataFrameœ, œDataœ], œtypeœ: œotherœ}" }, { + "animated": false, "className": "", "data": { "sourceHandle": { - "dataType": "ParserComponent", - "id": "ParserComponent-pXAMb", - "name": "parsed_text", + "dataType": "ArXivComponent", + "id": "ArXivComponent-6DHU9", + "name": "dataframe", "output_types": [ - "Message" + "DataFrame" ] }, "targetHandle": { - "fieldName": "input_value", - "id": "LanguageModelComponent-XKvly", + "fieldName": "data", + "id": "LoopComponent-TLMb9", "inputTypes": [ - "Message" + "DataFrame" ], - "type": "str" + "type": "other" } }, - "id": "xy-edge__ParserComponent-pXAMb{œdataTypeœ:œParserComponentœ,œidœ:œParserComponent-pXAMbœ,œnameœ:œparsed_textœ,œoutput_typesœ:[œMessageœ]}-LanguageModelComponent-XKvly{œfieldNameœ:œinput_valueœ,œidœ:œLanguageModelComponent-XKvlyœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", - "source": "ParserComponent-pXAMb", - "sourceHandle": "{œdataTypeœ: œParserComponentœ, œidœ: œParserComponent-pXAMbœ, œnameœ: œparsed_textœ, œoutput_typesœ: [œMessageœ]}", - "target": "LanguageModelComponent-XKvly", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œLanguageModelComponent-XKvlyœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + "id": "reactflow__edge-ArXivComponent-6DHU9{œdataTypeœ:œArXivComponentœ,œidœ:œArXivComponent-6DHU9œ,œnameœ:œdataframeœ,œoutput_typesœ:[œDataFrameœ]}-LoopComponent-TLMb9{œfieldNameœ:œdataœ,œidœ:œLoopComponent-TLMb9œ,œinputTypesœ:[œDataFrameœ],œtypeœ:œotherœ}", + "selected": false, + "source": "ArXivComponent-6DHU9", + "sourceHandle": "{œdataTypeœ: œArXivComponentœ, œidœ: œArXivComponent-6DHU9œ, œnameœ: œdataframeœ, œoutput_typesœ: [œDataFrameœ]}", + "target": "LoopComponent-TLMb9", + "targetHandle": "{œfieldNameœ: œdataœ, œidœ: œLoopComponent-TLMb9œ, œinputTypesœ: [œDataFrameœ], œtypeœ: œotherœ}" }, { + "animated": false, "className": "", "data": { "sourceHandle": { - "dataType": "LanguageModelComponent", - "id": "LanguageModelComponent-XKvly", - "name": "text_output", + "dataType": "LoopComponent", + "id": "LoopComponent-TLMb9", + "name": "done", "output_types": [ - "Message" + "DataFrame" ] }, "targetHandle": { - "dataType": "LoopComponent", - "id": "LoopComponent-GtPZT", - "name": "item", - "output_types": [ + "fieldName": "input_value", + "id": "ChatOutput-s0E7V", + "inputTypes": [ "Data", + "DataFrame", "Message" - ] + ], + "type": "other" } }, - "id": "xy-edge__LanguageModelComponent-XKvly{œdataTypeœ:œLanguageModelComponentœ,œidœ:œLanguageModelComponent-XKvlyœ,œnameœ:œtext_outputœ,œoutput_typesœ:[œMessageœ]}-LoopComponent-GtPZT{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-GtPZTœ,œnameœ:œitemœ,œoutput_typesœ:[œDataœ,œMessageœ]}", - "source": "LanguageModelComponent-XKvly", - "sourceHandle": "{œdataTypeœ: œLanguageModelComponentœ, œidœ: œLanguageModelComponent-XKvlyœ, œnameœ: œtext_outputœ, œoutput_typesœ: [œMessageœ]}", - "target": "LoopComponent-GtPZT", - "targetHandle": "{œdataTypeœ: œLoopComponentœ, œidœ: œLoopComponent-GtPZTœ, œnameœ: œitemœ, œoutput_typesœ: [œDataœ, œMessageœ]}" + "id": "reactflow__edge-LoopComponent-TLMb9{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-TLMb9œ,œnameœ:œdoneœ,œoutput_typesœ:[œDataFrameœ]}-ChatOutput-s0E7V{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-s0E7Vœ,œinputTypesœ:[œDataœ,œDataFrameœ,œMessageœ],œtypeœ:œotherœ}", + "selected": false, + "source": "LoopComponent-TLMb9", + "sourceHandle": "{œdataTypeœ: œLoopComponentœ, œidœ: œLoopComponent-TLMb9œ, œnameœ: œdoneœ, œoutput_typesœ: [œDataFrameœ]}", + "target": "ChatOutput-s0E7V", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-s0E7Vœ, œinputTypesœ: [œDataœ, œDataFrameœ, œMessageœ], œtypeœ: œotherœ}" }, { - "className": "", + "animated": false, "data": { "sourceHandle": { - "dataType": "ArXivComponent", - "id": "ArXivComponent-tAHR5", - "name": "dataframe", + "dataType": "ParserComponent", + "id": "ParserComponent-9EBD3", + "name": "parsed_text", "output_types": [ - "DataFrame" + "Message" ] }, "targetHandle": { - "fieldName": "data", - "id": "LoopComponent-GtPZT", + "fieldName": "input_value", + "id": "LanguageModelComponent-EeFeV", "inputTypes": [ - "DataFrame" + "Message" ], - "type": "other" + "type": "str" } }, - "id": "xy-edge__ArXivComponent-tAHR5{œdataTypeœ:œArXivComponentœ,œidœ:œArXivComponent-tAHR5œ,œnameœ:œdataframeœ,œoutput_typesœ:[œDataFrameœ]}-LoopComponent-GtPZT{œfieldNameœ:œdataœ,œidœ:œLoopComponent-GtPZTœ,œinputTypesœ:[œDataFrameœ],œtypeœ:œotherœ}", - "source": "ArXivComponent-tAHR5", - "sourceHandle": "{œdataTypeœ: œArXivComponentœ, œidœ: œArXivComponent-tAHR5œ, œnameœ: œdataframeœ, œoutput_typesœ: [œDataFrameœ]}", - "target": "LoopComponent-GtPZT", - "targetHandle": "{œfieldNameœ: œdataœ, œidœ: œLoopComponent-GtPZTœ, œinputTypesœ: [œDataFrameœ], œtypeœ: œotherœ}" + "id": "xy-edge__ParserComponent-9EBD3{œdataTypeœ:œParserComponentœ,œidœ:œParserComponent-9EBD3œ,œnameœ:œparsed_textœ,œoutput_typesœ:[œMessageœ]}-LanguageModelComponent-EeFeV{œfieldNameœ:œinput_valueœ,œidœ:œLanguageModelComponent-EeFeVœ,œinputTypesœ:[œMessageœ],œtypeœ:œstrœ}", + "selected": false, + "source": "ParserComponent-9EBD3", + "sourceHandle": "{œdataTypeœ: œParserComponentœ, œidœ: œParserComponent-9EBD3œ, œnameœ: œparsed_textœ, œoutput_typesœ: [œMessageœ]}", + "target": "LanguageModelComponent-EeFeV", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œLanguageModelComponent-EeFeVœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" }, { - "className": "", "data": { "sourceHandle": { - "dataType": "LoopComponent", - "id": "LoopComponent-GtPZT", - "name": "done", + "dataType": "LanguageModelComponent", + "id": "LanguageModelComponent-EeFeV", + "name": "text_output", "output_types": [ - "DataFrame" + "Message" ] }, "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-YAED9", - "inputTypes": [ + "dataType": "LoopComponent", + "id": "LoopComponent-TLMb9", + "name": "item", + "output_types": [ "Data", - "DataFrame", "Message" - ], - "type": "other" + ] } }, - "id": "xy-edge__LoopComponent-GtPZT{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-GtPZTœ,œnameœ:œdoneœ,œoutput_typesœ:[œDataFrameœ]}-ChatOutput-YAED9{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-YAED9œ,œinputTypesœ:[œDataœ,œDataFrameœ,œMessageœ],œtypeœ:œotherœ}", - "source": "LoopComponent-GtPZT", - "sourceHandle": "{œdataTypeœ: œLoopComponentœ, œidœ: œLoopComponent-GtPZTœ, œnameœ: œdoneœ, œoutput_typesœ: [œDataFrameœ]}", - "target": "ChatOutput-YAED9", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-YAED9œ, œinputTypesœ: [œDataœ, œDataFrameœ, œMessageœ], œtypeœ: œotherœ}" + "id": "xy-edge__LanguageModelComponent-EeFeV{œdataTypeœ:œLanguageModelComponentœ,œidœ:œLanguageModelComponent-EeFeVœ,œnameœ:œtext_outputœ,œoutput_typesœ:[œMessageœ]}-LoopComponent-TLMb9{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-TLMb9œ,œnameœ:œitemœ,œoutput_typesœ:[œDataœ,œMessageœ]}", + "source": "LanguageModelComponent-EeFeV", + "sourceHandle": "{œdataTypeœ: œLanguageModelComponentœ, œidœ: œLanguageModelComponent-EeFeVœ, œnameœ: œtext_outputœ, œoutput_typesœ: [œMessageœ]}", + "target": "LoopComponent-TLMb9", + "targetHandle": "{œdataTypeœ: œLoopComponentœ, œidœ: œLoopComponent-TLMb9œ, œnameœ: œitemœ, œoutput_typesœ: [œDataœ, œMessageœ]}" } ], "nodes": [ { "data": { - "id": "ArXivComponent-tAHR5", + "id": "ArXivComponent-6DHU9", "node": { "base_classes": [ "DataFrame" @@ -187,7 +193,7 @@ "frozen": false, "icon": "arXiv", "legacy": false, - "lf_version": "1.7.0", + "lf_version": "1.4.3", "metadata": { "code_hash": "219239ee2b48", "dependencies": { @@ -320,9 +326,9 @@ "type": "ArXivComponent" }, "dragging": false, - "id": "ArXivComponent-tAHR5", + "id": "ArXivComponent-6DHU9", "measured": { - "height": 369, + "height": 367, "width": 320 }, "position": { @@ -334,7 +340,7 @@ }, { "data": { - "id": "ChatOutput-YAED9", + "id": "ChatOutput-s0E7V", "node": { "base_classes": [ "Message" @@ -361,7 +367,7 @@ "frozen": false, "icon": "MessagesSquare", "legacy": false, - "lf_version": "1.7.0", + "lf_version": "1.4.3", "metadata": { "code_hash": "8c87e536cca4", "dependencies": { @@ -603,7 +609,7 @@ "type": "ChatOutput" }, "dragging": false, - "id": "ChatOutput-YAED9", + "id": "ChatOutput-s0E7V", "measured": { "height": 48, "width": 192 @@ -617,7 +623,7 @@ }, { "data": { - "id": "ChatInput-nr0Vp", + "id": "ChatInput-eTUNR", "node": { "base_classes": [ "Message" @@ -643,7 +649,7 @@ "frozen": false, "icon": "MessagesSquare", "legacy": false, - "lf_version": "1.7.0", + "lf_version": "1.4.3", "metadata": { "code_hash": "7a26c54d89ed", "dependencies": { @@ -881,9 +887,9 @@ "type": "ChatInput" }, "dragging": false, - "id": "ChatInput-nr0Vp", + "id": "ChatInput-eTUNR", "measured": { - "height": 204, + "height": 203, "width": 320 }, "position": { @@ -895,7 +901,7 @@ }, { "data": { - "id": "note-nrMOM", + "id": "note-nRK7v", "node": { "description": "# **Langflow Loop Component Template - ArXiv search result Translator** \nThis template translates research paper summaries on ArXiv into Portuguese and summarizes them. \n Using **Langflow’s looping mechanism**, the template iterates through multiple research papers, translates them with the **OpenAI** model component, and outputs an aggregated version of all translated papers. \n\n## Quickstart \n 1. Add your OpenAI API key to the **Language Model** component. \n2. In the **Playground**, enter a query related to a research topic (for example, “Quantum Computing Advancements”). \n\n The flow fetches a list of research papers from ArXiv matching the query. Each paper in the retrieved list is processed one-by-one using the Langflow **Loop component**. \n\n The abstract of each paper is translated into Portuguese by the **OpenAI** model component. \n\n Once all papers are translated, the system aggregates them into a **single structured output**.", "display_name": "", @@ -906,7 +912,7 @@ }, "dragging": false, "height": 647, - "id": "note-nrMOM", + "id": "note-nRK7v", "measured": { "height": 647, "width": 576 @@ -922,37 +928,27 @@ }, { "data": { - "id": "LanguageModelComponent-XKvly", + "id": "LoopComponent-TLMb9", "node": { "base_classes": [ - "LanguageModel", - "Message" + "Data", + "DataFrame" ], "beta": false, "conditional_paths": [], "custom_fields": {}, - "description": "Runs a language model given a specified provider.", - "display_name": "Language Model", - "documentation": "https://docs.langflow.org/components-models", + "description": "Iterates over a list of Data or Message objects, outputting one item at a time and aggregating results from loop inputs. Message objects are automatically converted to Data objects for consistent processing.", + "display_name": "Loop", + "documentation": "https://docs.langflow.org/loop", "edited": false, "field_order": [ - "model", - "api_key", - "base_url_ibm_watsonx", - "project_id", - "ollama_base_url", - "input_value", - "system_message", - "stream", - "temperature" + "data" ], "frozen": false, - "icon": "brain-circuit", - "last_updated": "2025-12-18T20:14:17.831Z", + "icon": "infinity", "legacy": false, - "lf_version": "1.7.0", "metadata": { - "code_hash": "7fe3257f2169", + "code_hash": "e179036a232d", "dependencies": { "dependencies": [ { @@ -962,116 +958,46 @@ ], "total_dependencies": 1 }, - "keywords": [ - "model", - "llm", - "language model", - "large language model" - ], - "module": "lfx.components.models_and_agents.language_model.LanguageModelComponent" + "module": "lfx.components.flow_controls.loop.LoopComponent" }, "minimized": false, "output_types": [], "outputs": [ { - "allows_loop": false, + "allows_loop": true, "cache": true, - "display_name": "Model Response", - "group_outputs": false, - "hidden": null, - "loop_types": null, - "method": "text_response", - "name": "text_output", - "options": null, - "required_inputs": null, - "selected": "Message", + "display_name": "Item", + "group_outputs": true, + "loop_types": [ + "Message" + ], + "method": "item_output", + "name": "item", + "selected": "Data", "tool_mode": true, "types": [ - "Message" + "Data" ], "value": "__UNDEFINED__" }, { "allows_loop": false, "cache": true, - "display_name": "Language Model", - "group_outputs": false, - "hidden": null, - "loop_types": null, - "method": "build_model", - "name": "model_output", - "options": null, - "required_inputs": null, - "selected": "LanguageModel", + "display_name": "Done", + "group_outputs": true, + "method": "done_output", + "name": "done", + "selected": "DataFrame", "tool_mode": true, "types": [ - "LanguageModel" + "DataFrame" ], "value": "__UNDEFINED__" } ], "pinned": false, - "priority": 0, "template": { - "_frontend_node_flow_id": { - "value": "70f6d78d-41ad-42a3-854d-405e161ee563" - }, - "_frontend_node_folder_id": { - "value": "afaca9ee-97e3-4211-8569-722a4058ea5e" - }, "_type": "Component", - "api_key": { - "_input_type": "SecretStrInput", - "advanced": true, - "display_name": "API Key", - "dynamic": false, - "info": "Model Provider API key", - "input_types": [], - "load_from_db": false, - "name": "api_key", - "override_skip": false, - "password": true, - "placeholder": "", - "real_time_refresh": true, - "required": false, - "show": true, - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "base_url_ibm_watsonx": { - "_input_type": "DropdownInput", - "advanced": false, - "combobox": false, - "dialog_inputs": {}, - "display_name": "watsonx API Endpoint", - "dynamic": false, - "external_options": {}, - "info": "The base URL of the API (IBM watsonx.ai only)", - "name": "base_url_ibm_watsonx", - "options": [ - "https://us-south.ml.cloud.ibm.com", - "https://eu-de.ml.cloud.ibm.com", - "https://eu-gb.ml.cloud.ibm.com", - "https://au-syd.ml.cloud.ibm.com", - "https://jp-tok.ml.cloud.ibm.com", - "https://ca-tor.ml.cloud.ibm.com" - ], - "options_metadata": [], - "override_skip": false, - "placeholder": "", - "real_time_refresh": true, - "required": false, - "show": false, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "str", - "value": "https://us-south.ml.cloud.ibm.com" - }, "code": { "advanced": true, "dynamic": true, @@ -1088,286 +1014,179 @@ "show": true, "title_case": false, "type": "code", - "value": "from lfx.base.models.model import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.base.models.watsonx_constants import IBM_WATSONX_URLS\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, StrInput\nfrom lfx.io import MessageInput, ModelInput, MultilineInput, SecretStrInput, SliderInput\n\nDEFAULT_OLLAMA_URL = \"http://localhost:11434\"\n\n\nclass LanguageModelComponent(LCModelComponent):\n display_name = \"Language Model\"\n description = \"Runs a language model given a specified provider.\"\n documentation: str = \"https://docs.langflow.org/components-models\"\n icon = \"brain-circuit\"\n category = \"models\"\n priority = 0 # Set priority to 0 to make it appear first\n\n inputs = [\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=\"Select your model provider\",\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n show=True,\n real_time_refresh=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"base_url_ibm_watsonx\",\n display_name=\"watsonx API Endpoint\",\n info=\"The base URL of the API (IBM watsonx.ai only)\",\n options=IBM_WATSONX_URLS,\n value=IBM_WATSONX_URLS[0],\n show=False,\n real_time_refresh=True,\n ),\n StrInput(\n name=\"project_id\",\n display_name=\"watsonx Project ID\",\n info=\"The project ID associated with the foundation model (IBM watsonx.ai only)\",\n show=False,\n required=False,\n ),\n MessageInput(\n name=\"ollama_base_url\",\n display_name=\"Ollama API URL\",\n info=f\"Endpoint of the Ollama API (Ollama only). Defaults to {DEFAULT_OLLAMA_URL}\",\n value=DEFAULT_OLLAMA_URL,\n show=False,\n real_time_refresh=True,\n load_from_db=True,\n ),\n MessageInput(\n name=\"input_value\",\n display_name=\"Input\",\n info=\"The input text to send to the model\",\n ),\n MultilineInput(\n name=\"system_message\",\n display_name=\"System Message\",\n info=\"A system message that helps set the behavior of the assistant\",\n advanced=False,\n ),\n BoolInput(\n name=\"stream\",\n display_name=\"Stream\",\n info=\"Whether to stream the response\",\n value=False,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n info=\"Controls randomness in responses\",\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n ]\n\n def build_model(self) -> LanguageModel:\n return get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n temperature=self.temperature,\n stream=self.stream,\n watsonx_url=getattr(self, \"base_url_ibm_watsonx\", None),\n watsonx_project_id=getattr(self, \"project_id\", None),\n ollama_base_url=getattr(self, \"ollama_base_url\", None),\n )\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n # Update model options\n build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n\n # Show/hide provider-specific fields based on selected model\n if field_name == \"model\" and isinstance(field_value, list) and len(field_value) > 0:\n selected_model = field_value[0]\n provider = selected_model.get(\"provider\", \"\")\n\n # Show/hide watsonx fields\n is_watsonx = provider == \"IBM WatsonX\"\n build_config[\"base_url_ibm_watsonx\"][\"show\"] = is_watsonx\n build_config[\"project_id\"][\"show\"] = is_watsonx\n build_config[\"base_url_ibm_watsonx\"][\"required\"] = is_watsonx\n build_config[\"project_id\"][\"required\"] = is_watsonx\n\n # Show/hide Ollama fields\n is_ollama = provider == \"Ollama\"\n build_config[\"ollama_base_url\"][\"show\"] = is_ollama\n\n return build_config\n" + "value": "from lfx.components.processing.converter import convert_to_data\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import HandleInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.template.field.base import Output\n\n\nclass LoopComponent(Component):\n display_name = \"Loop\"\n description = (\n \"Iterates over a list of Data or Message objects, outputting one item at a time and \"\n \"aggregating results from loop inputs. Message objects are automatically converted to \"\n \"Data objects for consistent processing.\"\n )\n documentation: str = \"https://docs.langflow.org/loop\"\n icon = \"infinity\"\n\n inputs = [\n HandleInput(\n name=\"data\",\n display_name=\"Inputs\",\n info=\"The initial DataFrame to iterate over.\",\n input_types=[\"DataFrame\"],\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Item\",\n name=\"item\",\n method=\"item_output\",\n allows_loop=True,\n loop_types=[\"Message\"],\n group_outputs=True,\n ),\n Output(display_name=\"Done\", name=\"done\", method=\"done_output\", group_outputs=True),\n ]\n\n def initialize_data(self) -> None:\n \"\"\"Initialize the data list, context index, and aggregated list.\"\"\"\n if self.ctx.get(f\"{self._id}_initialized\", False):\n return\n\n # Ensure data is a list of Data objects\n data_list = self._validate_data(self.data)\n\n # Store the initial data and context variables\n self.update_ctx(\n {\n f\"{self._id}_data\": data_list,\n f\"{self._id}_index\": 0,\n f\"{self._id}_aggregated\": [],\n f\"{self._id}_initialized\": True,\n }\n )\n\n def _convert_message_to_data(self, message: Message) -> Data:\n \"\"\"Convert a Message object to a Data object using Type Convert logic.\"\"\"\n return convert_to_data(message, auto_parse=False)\n\n def _validate_data(self, data):\n \"\"\"Validate and return a list of Data objects. Message objects are auto-converted to Data.\"\"\"\n if isinstance(data, DataFrame):\n return data.to_data_list()\n if isinstance(data, Data):\n return [data]\n if isinstance(data, Message):\n # Auto-convert Message to Data\n converted_data = self._convert_message_to_data(data)\n return [converted_data]\n if isinstance(data, list) and all(isinstance(item, (Data, Message)) for item in data):\n # Convert any Message objects in the list to Data objects\n converted_list = []\n for item in data:\n if isinstance(item, Message):\n converted_list.append(self._convert_message_to_data(item))\n else:\n converted_list.append(item)\n return converted_list\n msg = \"The 'data' input must be a DataFrame, a list of Data/Message objects, or a single Data/Message object.\"\n raise TypeError(msg)\n\n def evaluate_stop_loop(self) -> bool:\n \"\"\"Evaluate whether to stop item or done output.\"\"\"\n current_index = self.ctx.get(f\"{self._id}_index\", 0)\n data_length = len(self.ctx.get(f\"{self._id}_data\", []))\n return current_index > data_length\n\n def item_output(self) -> Data:\n \"\"\"Output the next item in the list or stop if done.\"\"\"\n self.initialize_data()\n current_item = Data(text=\"\")\n\n if self.evaluate_stop_loop():\n self.stop(\"item\")\n else:\n # Get data list and current index\n data_list, current_index = self.loop_variables()\n if current_index < len(data_list):\n # Output current item and increment index\n try:\n current_item = data_list[current_index]\n except IndexError:\n current_item = Data(text=\"\")\n self.aggregated_output()\n self.update_ctx({f\"{self._id}_index\": current_index + 1})\n\n # Now we need to update the dependencies for the next run\n self.update_dependency()\n return current_item\n\n def update_dependency(self):\n item_dependency_id = self.get_incoming_edge_by_target_param(\"item\")\n if item_dependency_id not in self.graph.run_manager.run_predecessors[self._id]:\n self.graph.run_manager.run_predecessors[self._id].append(item_dependency_id)\n # CRITICAL: Also update run_map so remove_from_predecessors() works correctly\n # run_map[predecessor] = list of vertices that depend on predecessor\n if self._id not in self.graph.run_manager.run_map[item_dependency_id]:\n self.graph.run_manager.run_map[item_dependency_id].append(self._id)\n\n def done_output(self) -> DataFrame:\n \"\"\"Trigger the done output when iteration is complete.\"\"\"\n self.initialize_data()\n\n if self.evaluate_stop_loop():\n self.stop(\"item\")\n self.start(\"done\")\n\n aggregated = self.ctx.get(f\"{self._id}_aggregated\", [])\n\n return DataFrame(aggregated)\n self.stop(\"done\")\n return DataFrame([])\n\n def loop_variables(self):\n \"\"\"Retrieve loop variables from context.\"\"\"\n return (\n self.ctx.get(f\"{self._id}_data\", []),\n self.ctx.get(f\"{self._id}_index\", 0),\n )\n\n def aggregated_output(self) -> list[Data]:\n \"\"\"Return the aggregated list once all items are processed.\n\n Returns Data or Message objects depending on loop input types.\n \"\"\"\n self.initialize_data()\n\n # Get data list and aggregated list\n data_list = self.ctx.get(f\"{self._id}_data\", [])\n aggregated = self.ctx.get(f\"{self._id}_aggregated\", [])\n loop_input = self.item\n\n # Append the current loop input to aggregated if it's not already included\n if loop_input is not None and not isinstance(loop_input, str) and len(aggregated) <= len(data_list):\n # If the loop input is a Message, convert it to Data for consistency\n if isinstance(loop_input, Message):\n loop_input = self._convert_message_to_data(loop_input)\n aggregated.append(loop_input)\n self.update_ctx({f\"{self._id}_aggregated\": aggregated})\n return aggregated\n" }, - "input_value": { - "_input_type": "MessageInput", + "data": { + "_input_type": "HandleInput", "advanced": false, - "display_name": "Input", + "display_name": "Inputs", "dynamic": false, - "info": "The input text to send to the model", + "info": "The initial DataFrame to iterate over.", "input_types": [ - "Message" + "DataFrame" ], "list": false, "list_add_label": "Add More", - "load_from_db": false, - "name": "input_value", + "name": "data", "override_skip": false, "placeholder": "", "required": false, "show": true, "title_case": false, - "tool_mode": false, - "trace_as_input": true, "trace_as_metadata": true, "track_in_telemetry": false, - "type": "str", + "type": "other", "value": "" - }, - "is_refresh": true, - "model": { - "_input_type": "ModelInput", - "advanced": false, - "display_name": "Language Model", - "dynamic": false, - "external_options": { - "fields": { - "data": { - "node": { - "display_name": "Connect other models", - "icon": "CornerDownLeft", - "name": "connect_other_models" - } - } - } - }, - "info": "Select your model provider", - "input_types": [], - "list": false, - "list_add_label": "Add More", - "model_type": "language", - "name": "model", - "options": [ - { - "category": "Anthropic", - "icon": "Anthropic", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatAnthropic", - "model_name_param": "model" - }, - "name": "claude-opus-4-5-20251101", - "provider": "Anthropic" - }, - { - "category": "Anthropic", - "icon": "Anthropic", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatAnthropic", - "model_name_param": "model" - }, - "name": "claude-haiku-4-5-20251001", - "provider": "Anthropic" - }, - { - "category": "Anthropic", - "icon": "Anthropic", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatAnthropic", - "model_name_param": "model" - }, - "name": "claude-sonnet-4-5-20250929", - "provider": "Anthropic" - }, - { - "category": "OpenAI", - "icon": "OpenAI", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatOpenAI", - "model_name_param": "model", - "reasoning_models": [ - "gpt-5.1" - ] - }, - "name": "gpt-5.1", - "provider": "OpenAI" - }, - { - "category": "OpenAI", - "icon": "OpenAI", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatOpenAI", - "model_name_param": "model" - }, - "name": "gpt-4o-mini", - "provider": "OpenAI" - }, - { - "category": "OpenAI", - "icon": "OpenAI", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatOpenAI", - "model_name_param": "model", - "reasoning_models": [ - "o1" - ] - }, - "name": "o1", - "provider": "OpenAI" - }, - { - "category": "Ollama", - "icon": "Ollama", - "metadata": { - "api_key_param": "base_url", - "base_url_param": "base_url", - "context_length": 128000, - "model_class": "ChatOllama", - "model_name_param": "model" - }, - "name": "llama3.3", - "provider": "Ollama" - }, - { - "category": "Ollama", - "icon": "Ollama", - "metadata": { - "api_key_param": "base_url", - "base_url_param": "base_url", - "context_length": 128000, - "model_class": "ChatOllama", - "model_name_param": "model" - }, - "name": "qwq", - "provider": "Ollama" - }, - { - "category": "Google Generative AI", - "icon": "GoogleGenerativeAI", - "metadata": { - "is_disabled_provider": true, - "variable_name": "GOOGLE_API_KEY" - }, - "name": "__enable_provider_Google Generative AI__", - "provider": "Google Generative AI" - }, + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "LoopComponent" + }, + "dragging": false, + "id": "LoopComponent-TLMb9", + "measured": { + "height": 273, + "width": 320 + }, + "position": { + "x": 581.346082615804, + "y": 105.19270617605812 + }, + "selected": false, + "type": "genericNode" + }, + { + "data": { + "id": "ParserComponent-9EBD3", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "category": "models_and_agents", + "conditional_paths": [], + "custom_fields": {}, + "description": "Extracts text using a template.", + "display_name": "Parser", + "documentation": "https://docs.langflow.org/parser", + "edited": false, + "field_order": [ + "input_data", + "mode", + "pattern", + "sep" + ], + "frozen": false, + "icon": "braces", + "legacy": false, + "metadata": { + "code_hash": "3cda25c3f7b5", + "dependencies": { + "dependencies": [ { - "category": "IBM WatsonX", - "icon": "WatsonxAI", - "metadata": { - "is_disabled_provider": true, - "variable_name": "WATSONX_APIKEY" - }, - "name": "__enable_provider_IBM WatsonX__", - "provider": "IBM WatsonX" + "name": "lfx", + "version": null } ], - "override_skip": false, - "placeholder": "Setup Provider", - "real_time_refresh": true, - "refresh_button": true, + "total_dependencies": 1 + }, + "module": "lfx.components.processing.parser.ParserComponent" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Parsed Text", + "group_outputs": false, + "method": "parse_combined_text", + "name": "parsed_text", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", "required": true, "show": true, "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "track_in_telemetry": false, - "type": "model", - "value": [ - { - "category": "OpenAI", - "icon": "OpenAI", - "metadata": { - "api_key_param": "api_key", - "context_length": 128000, - "model_class": "ChatOpenAI", - "model_name_param": "model", - "reasoning_models": [ - "gpt-5.1" - ] - }, - "name": "gpt-5.1", - "provider": "OpenAI" - } - ] + "type": "code", + "value": "from lfx.custom.custom_component.component import Component\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, HandleInput, MessageTextInput, MultilineInput, TabInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.template.field.base import Output\n\n\nclass ParserComponent(Component):\n display_name = \"Parser\"\n description = \"Extracts text using a template.\"\n documentation: str = \"https://docs.langflow.org/parser\"\n icon = \"braces\"\n\n inputs = [\n HandleInput(\n name=\"input_data\",\n display_name=\"Data or DataFrame\",\n input_types=[\"DataFrame\", \"Data\"],\n info=\"Accepts either a DataFrame or a Data object.\",\n required=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"Parser\", \"Stringify\"],\n value=\"Parser\",\n info=\"Convert into raw string instead of using a template.\",\n real_time_refresh=True,\n ),\n MultilineInput(\n name=\"pattern\",\n display_name=\"Template\",\n info=(\n \"Use variables within curly brackets to extract column values for DataFrames \"\n \"or key values for Data.\"\n \"For example: `Name: {Name}, Age: {Age}, Country: {Country}`\"\n ),\n value=\"Text: {text}\", # Example default\n dynamic=True,\n show=True,\n required=True,\n ),\n MessageTextInput(\n name=\"sep\",\n display_name=\"Separator\",\n advanced=True,\n value=\"\\n\",\n info=\"String used to separate rows/items.\",\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Parsed Text\",\n name=\"parsed_text\",\n info=\"Formatted text output.\",\n method=\"parse_combined_text\",\n ),\n ]\n\n def update_build_config(self, build_config, field_value, field_name=None):\n \"\"\"Dynamically hide/show `template` and enforce requirement based on `stringify`.\"\"\"\n if field_name == \"mode\":\n build_config[\"pattern\"][\"show\"] = self.mode == \"Parser\"\n build_config[\"pattern\"][\"required\"] = self.mode == \"Parser\"\n if field_value:\n clean_data = BoolInput(\n name=\"clean_data\",\n display_name=\"Clean Data\",\n info=(\n \"Enable to clean the data by removing empty rows and lines \"\n \"in each cell of the DataFrame/ Data object.\"\n ),\n value=True,\n advanced=True,\n required=False,\n )\n build_config[\"clean_data\"] = clean_data.to_dict()\n else:\n build_config.pop(\"clean_data\", None)\n\n return build_config\n\n def _clean_args(self):\n \"\"\"Prepare arguments based on input type.\"\"\"\n input_data = self.input_data\n\n match input_data:\n case list() if all(isinstance(item, Data) for item in input_data):\n msg = \"List of Data objects is not supported.\"\n raise ValueError(msg)\n case DataFrame():\n return input_data, None\n case Data():\n return None, input_data\n case dict() if \"data\" in input_data:\n try:\n if \"columns\" in input_data: # Likely a DataFrame\n return DataFrame.from_dict(input_data), None\n # Likely a Data object\n return None, Data(**input_data)\n except (TypeError, ValueError, KeyError) as e:\n msg = f\"Invalid structured input provided: {e!s}\"\n raise ValueError(msg) from e\n case _:\n msg = f\"Unsupported input type: {type(input_data)}. Expected DataFrame or Data.\"\n raise ValueError(msg)\n\n def parse_combined_text(self) -> Message:\n \"\"\"Parse all rows/items into a single text or convert input to string if `stringify` is enabled.\"\"\"\n # Early return for stringify option\n if self.mode == \"Stringify\":\n return self.convert_to_string()\n\n df, data = self._clean_args()\n\n lines = []\n if df is not None:\n for _, row in df.iterrows():\n formatted_text = self.pattern.format(**row.to_dict())\n lines.append(formatted_text)\n elif data is not None:\n # Use format_map with a dict that returns default_value for missing keys\n class DefaultDict(dict):\n def __missing__(self, key):\n return data.default_value or \"\"\n\n formatted_text = self.pattern.format_map(DefaultDict(data.data))\n lines.append(formatted_text)\n\n combined_text = self.sep.join(lines)\n self.status = combined_text\n return Message(text=combined_text)\n\n def convert_to_string(self) -> Message:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n result = \"\"\n if isinstance(self.input_data, list):\n result = \"\\n\".join([safe_convert(item, clean_data=self.clean_data or False) for item in self.input_data])\n else:\n result = safe_convert(self.input_data or False)\n self.log(f\"Converted to string with length: {len(result)}\")\n\n message = Message(text=result)\n self.status = message\n return message\n" }, - "ollama_base_url": { - "_input_type": "MessageInput", + "input_data": { + "_input_type": "HandleInput", "advanced": false, - "display_name": "Ollama API URL", + "display_name": "Data or DataFrame", "dynamic": false, - "info": "Endpoint of the Ollama API (Ollama only). Defaults to http://localhost:11434", + "info": "Accepts either a DataFrame or a Data object.", "input_types": [ - "Message" + "DataFrame", + "Data" ], "list": false, "list_add_label": "Add More", - "load_from_db": false, - "name": "ollama_base_url", + "name": "input_data", "override_skip": false, "placeholder": "", - "real_time_refresh": true, - "required": false, - "show": false, + "required": true, + "show": true, "title_case": false, - "tool_mode": false, - "trace_as_input": true, "trace_as_metadata": true, "track_in_telemetry": false, - "type": "str", + "type": "other", "value": "" }, - "project_id": { - "_input_type": "StrInput", + "mode": { + "_input_type": "TabInput", "advanced": false, - "display_name": "watsonx Project ID", + "display_name": "Mode", "dynamic": false, - "info": "The project ID associated with the foundation model (IBM watsonx.ai only)", - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "project_id", + "info": "Convert into raw string instead of using a template.", + "name": "mode", + "options": [ + "Parser", + "Stringify" + ], "override_skip": false, "placeholder": "", + "real_time_refresh": true, "required": false, - "show": false, + "show": true, "title_case": false, "tool_mode": false, "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" + "track_in_telemetry": true, + "type": "tab", + "value": "Parser" }, - "stream": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Stream", - "dynamic": false, - "info": "Whether to stream the response", - "list": false, - "list_add_label": "Add More", - "name": "stream", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - }, - "system_message": { + "pattern": { "_input_type": "MultilineInput", "advanced": false, "ai_enabled": false, "copy_field": false, - "display_name": "System Message", - "dynamic": false, - "info": "A system message that helps set the behavior of the assistant", + "display_name": "Template", + "dynamic": true, + "info": "Use variables within curly brackets to extract column values for DataFrames or key values for Data.For example: `Name: {Name}, Age: {Age}, Country: {Country}`", "input_types": [ "Message" ], @@ -1375,10 +1194,10 @@ "list_add_label": "Add More", "load_from_db": false, "multiline": true, - "name": "system_message", + "name": "pattern", "override_skip": false, "placeholder": "", - "required": false, + "required": true, "show": true, "title_case": false, "tool_mode": false, @@ -1386,81 +1205,84 @@ "trace_as_metadata": true, "track_in_telemetry": false, "type": "str", - "value": "" + "value": "Text: {summary}" }, - "temperature": { - "_input_type": "SliderInput", + "sep": { + "_input_type": "MessageTextInput", "advanced": true, - "display_name": "Temperature", + "display_name": "Separator", "dynamic": false, - "info": "Controls randomness in responses", - "max_label": "", - "max_label_icon": "", - "min_label": "", - "min_label_icon": "", - "name": "temperature", + "info": "String used to separate rows/items.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sep", "override_skip": false, "placeholder": "", - "range_spec": { - "max": 1, - "min": 0, - "step": 0.01, - "step_type": "float" - }, "required": false, "show": true, - "slider_buttons": false, - "slider_buttons_options": [], - "slider_input": false, "title_case": false, "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, "track_in_telemetry": false, - "type": "slider", - "value": 0.1 + "type": "str", + "value": "\n" } }, "tool_mode": false }, - "selected_output": "text_output", "showNode": true, - "type": "LanguageModelComponent" + "type": "ParserComponent" }, "dragging": false, - "id": "LanguageModelComponent-XKvly", + "id": "ParserComponent-9EBD3", "measured": { - "height": 369, + "height": 327, "width": 320 }, "position": { - "x": 1630.638479427766, - "y": 116.0160835115366 + "x": 1102.2949192976482, + "y": -80.12506181353082 }, "selected": false, "type": "genericNode" }, { "data": { - "id": "LoopComponent-GtPZT", + "id": "LanguageModelComponent-EeFeV", "node": { "base_classes": [ - "Data", - "DataFrame" + "LanguageModel", + "Message" ], "beta": false, "conditional_paths": [], "custom_fields": {}, - "description": "Iterates over a list of Data or Message objects, outputting one item at a time and aggregating results from loop inputs. Message objects are automatically converted to Data objects for consistent processing.", - "display_name": "Loop", - "documentation": "https://docs.langflow.org/loop", + "description": "Runs a language model given a specified provider.", + "display_name": "Language Model", + "documentation": "https://docs.langflow.org/components-models", "edited": false, "field_order": [ - "data" + "model", + "api_key", + "base_url_ibm_watsonx", + "project_id", + "ollama_base_url", + "input_value", + "system_message", + "stream", + "temperature" ], "frozen": false, - "icon": "infinity", + "icon": "brain-circuit", + "last_updated": "2026-01-08T21:13:36.668Z", "legacy": false, "metadata": { - "code_hash": "e179036a232d", + "code_hash": "b9cbb7e60826", "dependencies": { "dependencies": [ { @@ -1470,235 +1292,452 @@ ], "total_dependencies": 1 }, - "module": "lfx.components.flow_controls.loop.LoopComponent" + "keywords": [ + "model", + "llm", + "language model", + "large language model" + ], + "module": "lfx.components.models_and_agents.language_model.LanguageModelComponent" }, "minimized": false, "output_types": [], "outputs": [ { - "allows_loop": true, + "allows_loop": false, "cache": true, - "display_name": "Item", - "group_outputs": true, - "loop_types": [ - "Message" - ], - "method": "item_output", - "name": "item", - "selected": "Data", + "display_name": "Model Response", + "group_outputs": false, + "hidden": null, + "loop_types": null, + "method": "text_response", + "name": "text_output", + "options": null, + "required_inputs": null, + "selected": "Message", "tool_mode": true, "types": [ - "Data" + "Message" ], "value": "__UNDEFINED__" }, { "allows_loop": false, "cache": true, - "display_name": "Done", - "group_outputs": true, - "method": "done_output", - "name": "done", - "selected": "DataFrame", + "display_name": "Language Model", + "group_outputs": false, + "hidden": null, + "loop_types": null, + "method": "build_model", + "name": "model_output", + "options": null, + "required_inputs": null, + "selected": "LanguageModel", "tool_mode": true, "types": [ - "DataFrame" + "LanguageModel" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "priority": 0, + "template": { + "_frontend_node_flow_id": { + "value": "25a0db12-6a20-4cb7-b70a-f9b2bef83b16" + }, + "_frontend_node_folder_id": { + "value": "b302f37e-5e22-4ef7-84f7-4a77afba313a" + }, + "_type": "Component", + "api_key": { + "_input_type": "SecretStrInput", + "advanced": true, + "display_name": "API Key", + "dynamic": false, + "info": "Model Provider API key", + "input_types": [], + "load_from_db": false, + "name": "api_key", + "override_skip": false, + "password": true, + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": true, + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "base_url_ibm_watsonx": { + "_input_type": "DropdownInput", + "advanced": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "watsonx API Endpoint", + "dynamic": false, + "external_options": {}, + "info": "The base URL of the API (IBM watsonx.ai only)", + "name": "base_url_ibm_watsonx", + "options": [ + "https://us-south.ml.cloud.ibm.com", + "https://eu-de.ml.cloud.ibm.com", + "https://eu-gb.ml.cloud.ibm.com", + "https://au-syd.ml.cloud.ibm.com", + "https://jp-tok.ml.cloud.ibm.com", + "https://ca-tor.ml.cloud.ibm.com" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": false, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "https://us-south.ml.cloud.ibm.com" + }, + "code": { + "advanced": true, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.base.models.model import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.base.models.watsonx_constants import IBM_WATSONX_URLS\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, StrInput\nfrom lfx.io import MessageInput, ModelInput, MultilineInput, SecretStrInput, SliderInput\n\nDEFAULT_OLLAMA_URL = \"http://localhost:11434\"\n\n\nclass LanguageModelComponent(LCModelComponent):\n display_name = \"Language Model\"\n description = \"Runs a language model given a specified provider.\"\n documentation: str = \"https://docs.langflow.org/components-models\"\n icon = \"brain-circuit\"\n category = \"models\"\n priority = 0 # Set priority to 0 to make it appear first\n\n inputs = [\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=\"Select your model provider\",\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n show=True,\n real_time_refresh=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"base_url_ibm_watsonx\",\n display_name=\"watsonx API Endpoint\",\n info=\"The base URL of the API (IBM watsonx.ai only)\",\n options=IBM_WATSONX_URLS,\n value=IBM_WATSONX_URLS[0],\n show=False,\n real_time_refresh=True,\n ),\n StrInput(\n name=\"project_id\",\n display_name=\"watsonx Project ID\",\n info=\"The project ID associated with the foundation model (IBM watsonx.ai only)\",\n show=False,\n required=False,\n ),\n MessageInput(\n name=\"ollama_base_url\",\n display_name=\"Ollama API URL\",\n info=f\"Endpoint of the Ollama API (Ollama only). Defaults to {DEFAULT_OLLAMA_URL}\",\n value=DEFAULT_OLLAMA_URL,\n show=False,\n real_time_refresh=True,\n load_from_db=True,\n ),\n MessageInput(\n name=\"input_value\",\n display_name=\"Input\",\n info=\"The input text to send to the model\",\n ),\n MultilineInput(\n name=\"system_message\",\n display_name=\"System Message\",\n info=\"A system message that helps set the behavior of the assistant\",\n advanced=False,\n ),\n BoolInput(\n name=\"stream\",\n display_name=\"Stream\",\n info=\"Whether to stream the response\",\n value=False,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n info=\"Controls randomness in responses\",\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n ]\n\n def build_model(self) -> LanguageModel:\n return get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n temperature=self.temperature,\n stream=self.stream,\n watsonx_url=getattr(self, \"base_url_ibm_watsonx\", None),\n watsonx_project_id=getattr(self, \"project_id\", None),\n ollama_base_url=getattr(self, \"ollama_base_url\", None),\n )\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n # Update model options\n build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n\n # Show/hide provider-specific fields based on selected model\n if field_name == \"model\" and isinstance(field_value, list) and len(field_value) > 0:\n selected_model = field_value[0]\n provider = selected_model.get(\"provider\", \"\")\n\n # Show/hide watsonx fields\n is_watsonx = provider == \"IBM WatsonX\"\n build_config[\"base_url_ibm_watsonx\"][\"show\"] = is_watsonx\n build_config[\"project_id\"][\"show\"] = is_watsonx\n build_config[\"base_url_ibm_watsonx\"][\"required\"] = is_watsonx\n build_config[\"project_id\"][\"required\"] = is_watsonx\n\n # Show/hide Ollama fields\n is_ollama = provider == \"Ollama\"\n build_config[\"ollama_base_url\"][\"show\"] = is_ollama\n\n return build_config\n" + }, + "input_value": { + "_input_type": "MessageInput", + "advanced": false, + "display_name": "Input", + "dynamic": false, + "info": "The input text to send to the model", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "is_refresh": false, + "model": { + "_input_type": "ModelInput", + "advanced": false, + "display_name": "Language Model", + "dynamic": false, + "external_options": { + "fields": { + "data": { + "node": { + "display_name": "Connect other models", + "icon": "CornerDownLeft", + "name": "connect_other_models" + } + } + } + }, + "info": "Select your model provider", + "input_types": [], + "list": false, + "list_add_label": "Add More", + "model_type": "language", + "name": "model", + "options": [ + { + "category": "Anthropic", + "icon": "Anthropic", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatAnthropic", + "model_name_param": "model" + }, + "name": "claude-opus-4-5-20251101", + "provider": "Anthropic" + }, + { + "category": "Anthropic", + "icon": "Anthropic", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatAnthropic", + "model_name_param": "model" + }, + "name": "claude-haiku-4-5-20251001", + "provider": "Anthropic" + }, + { + "category": "Anthropic", + "icon": "Anthropic", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatAnthropic", + "model_name_param": "model" + }, + "name": "claude-sonnet-4-5-20250929", + "provider": "Anthropic" + }, + { + "category": "Anthropic", + "icon": "Anthropic", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatAnthropic", + "model_name_param": "model" + }, + "name": "claude-opus-4-1-20250805", + "provider": "Anthropic" + }, + { + "category": "Anthropic", + "icon": "Anthropic", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatAnthropic", + "model_name_param": "model" + }, + "name": "claude-opus-4-20250514", + "provider": "Anthropic" + }, + { + "category": "OpenAI", + "icon": "OpenAI", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatOpenAI", + "model_name_param": "model", + "reasoning_models": [ + "gpt-5.1" + ] + }, + "name": "gpt-5.1", + "provider": "OpenAI" + }, + { + "category": "OpenAI", + "icon": "OpenAI", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatOpenAI", + "model_name_param": "model", + "reasoning_models": [ + "gpt-5" + ] + }, + "name": "gpt-5", + "provider": "OpenAI" + }, + { + "category": "OpenAI", + "icon": "OpenAI", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatOpenAI", + "model_name_param": "model", + "reasoning_models": [ + "gpt-5-mini" + ] + }, + "name": "gpt-5-mini", + "provider": "OpenAI" + }, + { + "category": "OpenAI", + "icon": "OpenAI", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatOpenAI", + "model_name_param": "model", + "reasoning_models": [ + "gpt-5-nano" + ] + }, + "name": "gpt-5-nano", + "provider": "OpenAI" + }, + { + "category": "OpenAI", + "icon": "OpenAI", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatOpenAI", + "model_name_param": "model", + "reasoning_models": [ + "gpt-5-chat-latest" + ] + }, + "name": "gpt-5-chat-latest", + "provider": "OpenAI" + }, + { + "category": "Google Generative AI", + "icon": "GoogleGenerativeAI", + "metadata": { + "is_disabled_provider": true, + "variable_name": "GOOGLE_API_KEY" + }, + "name": "__enable_provider_Google Generative AI__", + "provider": "Google Generative AI" + }, + { + "category": "Ollama", + "icon": "Ollama", + "metadata": { + "is_disabled_provider": true, + "variable_name": "OLLAMA_BASE_URL" + }, + "name": "__enable_provider_Ollama__", + "provider": "Ollama" + }, + { + "category": "IBM WatsonX", + "icon": "WatsonxAI", + "metadata": { + "is_disabled_provider": true, + "variable_name": "WATSONX_APIKEY" + }, + "name": "__enable_provider_IBM WatsonX__", + "provider": "IBM WatsonX" + } ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "code": { - "advanced": true, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", + "override_skip": false, + "placeholder": "Setup Provider", + "real_time_refresh": true, + "refresh_button": true, "required": true, "show": true, "title_case": false, - "type": "code", - "value": "from lfx.components.processing.converter import convert_to_data\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import HandleInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.template.field.base import Output\n\n\nclass LoopComponent(Component):\n display_name = \"Loop\"\n description = (\n \"Iterates over a list of Data or Message objects, outputting one item at a time and \"\n \"aggregating results from loop inputs. Message objects are automatically converted to \"\n \"Data objects for consistent processing.\"\n )\n documentation: str = \"https://docs.langflow.org/loop\"\n icon = \"infinity\"\n\n inputs = [\n HandleInput(\n name=\"data\",\n display_name=\"Inputs\",\n info=\"The initial DataFrame to iterate over.\",\n input_types=[\"DataFrame\"],\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Item\",\n name=\"item\",\n method=\"item_output\",\n allows_loop=True,\n loop_types=[\"Message\"],\n group_outputs=True,\n ),\n Output(display_name=\"Done\", name=\"done\", method=\"done_output\", group_outputs=True),\n ]\n\n def initialize_data(self) -> None:\n \"\"\"Initialize the data list, context index, and aggregated list.\"\"\"\n if self.ctx.get(f\"{self._id}_initialized\", False):\n return\n\n # Ensure data is a list of Data objects\n data_list = self._validate_data(self.data)\n\n # Store the initial data and context variables\n self.update_ctx(\n {\n f\"{self._id}_data\": data_list,\n f\"{self._id}_index\": 0,\n f\"{self._id}_aggregated\": [],\n f\"{self._id}_initialized\": True,\n }\n )\n\n def _convert_message_to_data(self, message: Message) -> Data:\n \"\"\"Convert a Message object to a Data object using Type Convert logic.\"\"\"\n return convert_to_data(message, auto_parse=False)\n\n def _validate_data(self, data):\n \"\"\"Validate and return a list of Data objects. Message objects are auto-converted to Data.\"\"\"\n if isinstance(data, DataFrame):\n return data.to_data_list()\n if isinstance(data, Data):\n return [data]\n if isinstance(data, Message):\n # Auto-convert Message to Data\n converted_data = self._convert_message_to_data(data)\n return [converted_data]\n if isinstance(data, list) and all(isinstance(item, (Data, Message)) for item in data):\n # Convert any Message objects in the list to Data objects\n converted_list = []\n for item in data:\n if isinstance(item, Message):\n converted_list.append(self._convert_message_to_data(item))\n else:\n converted_list.append(item)\n return converted_list\n msg = \"The 'data' input must be a DataFrame, a list of Data/Message objects, or a single Data/Message object.\"\n raise TypeError(msg)\n\n def evaluate_stop_loop(self) -> bool:\n \"\"\"Evaluate whether to stop item or done output.\"\"\"\n current_index = self.ctx.get(f\"{self._id}_index\", 0)\n data_length = len(self.ctx.get(f\"{self._id}_data\", []))\n return current_index > data_length\n\n def item_output(self) -> Data:\n \"\"\"Output the next item in the list or stop if done.\"\"\"\n self.initialize_data()\n current_item = Data(text=\"\")\n\n if self.evaluate_stop_loop():\n self.stop(\"item\")\n else:\n # Get data list and current index\n data_list, current_index = self.loop_variables()\n if current_index < len(data_list):\n # Output current item and increment index\n try:\n current_item = data_list[current_index]\n except IndexError:\n current_item = Data(text=\"\")\n self.aggregated_output()\n self.update_ctx({f\"{self._id}_index\": current_index + 1})\n\n # Now we need to update the dependencies for the next run\n self.update_dependency()\n return current_item\n\n def update_dependency(self):\n item_dependency_id = self.get_incoming_edge_by_target_param(\"item\")\n if item_dependency_id not in self.graph.run_manager.run_predecessors[self._id]:\n self.graph.run_manager.run_predecessors[self._id].append(item_dependency_id)\n # CRITICAL: Also update run_map so remove_from_predecessors() works correctly\n # run_map[predecessor] = list of vertices that depend on predecessor\n if self._id not in self.graph.run_manager.run_map[item_dependency_id]:\n self.graph.run_manager.run_map[item_dependency_id].append(self._id)\n\n def done_output(self) -> DataFrame:\n \"\"\"Trigger the done output when iteration is complete.\"\"\"\n self.initialize_data()\n\n if self.evaluate_stop_loop():\n self.stop(\"item\")\n self.start(\"done\")\n\n aggregated = self.ctx.get(f\"{self._id}_aggregated\", [])\n\n return DataFrame(aggregated)\n self.stop(\"done\")\n return DataFrame([])\n\n def loop_variables(self):\n \"\"\"Retrieve loop variables from context.\"\"\"\n return (\n self.ctx.get(f\"{self._id}_data\", []),\n self.ctx.get(f\"{self._id}_index\", 0),\n )\n\n def aggregated_output(self) -> list[Data]:\n \"\"\"Return the aggregated list once all items are processed.\n\n Returns Data or Message objects depending on loop input types.\n \"\"\"\n self.initialize_data()\n\n # Get data list and aggregated list\n data_list = self.ctx.get(f\"{self._id}_data\", [])\n aggregated = self.ctx.get(f\"{self._id}_aggregated\", [])\n loop_input = self.item\n\n # Append the current loop input to aggregated if it's not already included\n if loop_input is not None and not isinstance(loop_input, str) and len(aggregated) <= len(data_list):\n # If the loop input is a Message, convert it to Data for consistency\n if isinstance(loop_input, Message):\n loop_input = self._convert_message_to_data(loop_input)\n aggregated.append(loop_input)\n self.update_ctx({f\"{self._id}_aggregated\": aggregated})\n return aggregated\n" + "tool_mode": false, + "trace_as_input": true, + "track_in_telemetry": false, + "type": "model", + "value": [ + { + "icon": "OpenAI", + "metadata": { + "api_key_param": "api_key", + "context_length": 128000, + "model_class": "ChatOpenAI", + "model_name_param": "model", + "reasoning_models": [ + "gpt-5.1" + ] + }, + "name": "gpt-5.1", + "provider": "OpenAI" + } + ] }, - "data": { - "_input_type": "HandleInput", + "ollama_base_url": { + "_input_type": "MessageInput", "advanced": false, - "display_name": "Inputs", + "display_name": "Ollama API URL", "dynamic": false, - "info": "The initial DataFrame to iterate over.", + "info": "Endpoint of the Ollama API (Ollama only). Defaults to http://localhost:11434", "input_types": [ - "DataFrame" + "Message" ], "list": false, "list_add_label": "Add More", - "name": "data", + "load_from_db": false, + "name": "ollama_base_url", "override_skip": false, "placeholder": "", + "real_time_refresh": true, "required": false, - "show": true, + "show": false, "title_case": false, + "tool_mode": false, + "trace_as_input": true, "trace_as_metadata": true, "track_in_telemetry": false, - "type": "other", + "type": "str", "value": "" - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "LoopComponent" - }, - "dragging": false, - "id": "LoopComponent-GtPZT", - "measured": { - "height": 274, - "width": 320 - }, - "position": { - "x": 581.346082615804, - "y": 105.19270617605812 - }, - "selected": false, - "type": "genericNode" - }, - { - "data": { - "id": "ParserComponent-pXAMb", - "node": { - "base_classes": [ - "Message" - ], - "beta": false, - "category": "models_and_agents", - "conditional_paths": [], - "custom_fields": {}, - "description": "Extracts text using a template.", - "display_name": "Parser", - "documentation": "https://docs.langflow.org/parser", - "edited": false, - "field_order": [ - "input_data", - "mode", - "pattern", - "sep" - ], - "frozen": false, - "icon": "braces", - "legacy": false, - "metadata": { - "code_hash": "3cda25c3f7b5", - "dependencies": { - "dependencies": [ - { - "name": "lfx", - "version": null - } - ], - "total_dependencies": 1 - }, - "module": "lfx.components.processing.parser.ParserComponent" - }, - "minimized": false, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Parsed Text", - "group_outputs": false, - "method": "parse_combined_text", - "name": "parsed_text", - "selected": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "code": { - "advanced": true, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "code", - "value": "from lfx.custom.custom_component.component import Component\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, HandleInput, MessageTextInput, MultilineInput, TabInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.template.field.base import Output\n\n\nclass ParserComponent(Component):\n display_name = \"Parser\"\n description = \"Extracts text using a template.\"\n documentation: str = \"https://docs.langflow.org/parser\"\n icon = \"braces\"\n\n inputs = [\n HandleInput(\n name=\"input_data\",\n display_name=\"Data or DataFrame\",\n input_types=[\"DataFrame\", \"Data\"],\n info=\"Accepts either a DataFrame or a Data object.\",\n required=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"Parser\", \"Stringify\"],\n value=\"Parser\",\n info=\"Convert into raw string instead of using a template.\",\n real_time_refresh=True,\n ),\n MultilineInput(\n name=\"pattern\",\n display_name=\"Template\",\n info=(\n \"Use variables within curly brackets to extract column values for DataFrames \"\n \"or key values for Data.\"\n \"For example: `Name: {Name}, Age: {Age}, Country: {Country}`\"\n ),\n value=\"Text: {text}\", # Example default\n dynamic=True,\n show=True,\n required=True,\n ),\n MessageTextInput(\n name=\"sep\",\n display_name=\"Separator\",\n advanced=True,\n value=\"\\n\",\n info=\"String used to separate rows/items.\",\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Parsed Text\",\n name=\"parsed_text\",\n info=\"Formatted text output.\",\n method=\"parse_combined_text\",\n ),\n ]\n\n def update_build_config(self, build_config, field_value, field_name=None):\n \"\"\"Dynamically hide/show `template` and enforce requirement based on `stringify`.\"\"\"\n if field_name == \"mode\":\n build_config[\"pattern\"][\"show\"] = self.mode == \"Parser\"\n build_config[\"pattern\"][\"required\"] = self.mode == \"Parser\"\n if field_value:\n clean_data = BoolInput(\n name=\"clean_data\",\n display_name=\"Clean Data\",\n info=(\n \"Enable to clean the data by removing empty rows and lines \"\n \"in each cell of the DataFrame/ Data object.\"\n ),\n value=True,\n advanced=True,\n required=False,\n )\n build_config[\"clean_data\"] = clean_data.to_dict()\n else:\n build_config.pop(\"clean_data\", None)\n\n return build_config\n\n def _clean_args(self):\n \"\"\"Prepare arguments based on input type.\"\"\"\n input_data = self.input_data\n\n match input_data:\n case list() if all(isinstance(item, Data) for item in input_data):\n msg = \"List of Data objects is not supported.\"\n raise ValueError(msg)\n case DataFrame():\n return input_data, None\n case Data():\n return None, input_data\n case dict() if \"data\" in input_data:\n try:\n if \"columns\" in input_data: # Likely a DataFrame\n return DataFrame.from_dict(input_data), None\n # Likely a Data object\n return None, Data(**input_data)\n except (TypeError, ValueError, KeyError) as e:\n msg = f\"Invalid structured input provided: {e!s}\"\n raise ValueError(msg) from e\n case _:\n msg = f\"Unsupported input type: {type(input_data)}. Expected DataFrame or Data.\"\n raise ValueError(msg)\n\n def parse_combined_text(self) -> Message:\n \"\"\"Parse all rows/items into a single text or convert input to string if `stringify` is enabled.\"\"\"\n # Early return for stringify option\n if self.mode == \"Stringify\":\n return self.convert_to_string()\n\n df, data = self._clean_args()\n\n lines = []\n if df is not None:\n for _, row in df.iterrows():\n formatted_text = self.pattern.format(**row.to_dict())\n lines.append(formatted_text)\n elif data is not None:\n # Use format_map with a dict that returns default_value for missing keys\n class DefaultDict(dict):\n def __missing__(self, key):\n return data.default_value or \"\"\n\n formatted_text = self.pattern.format_map(DefaultDict(data.data))\n lines.append(formatted_text)\n\n combined_text = self.sep.join(lines)\n self.status = combined_text\n return Message(text=combined_text)\n\n def convert_to_string(self) -> Message:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n result = \"\"\n if isinstance(self.input_data, list):\n result = \"\\n\".join([safe_convert(item, clean_data=self.clean_data or False) for item in self.input_data])\n else:\n result = safe_convert(self.input_data or False)\n self.log(f\"Converted to string with length: {len(result)}\")\n\n message = Message(text=result)\n self.status = message\n return message\n" }, - "input_data": { - "_input_type": "HandleInput", + "project_id": { + "_input_type": "StrInput", "advanced": false, - "display_name": "Data or DataFrame", + "display_name": "watsonx Project ID", "dynamic": false, - "info": "Accepts either a DataFrame or a Data object.", - "input_types": [ - "DataFrame", - "Data" - ], + "info": "The project ID associated with the foundation model (IBM watsonx.ai only)", "list": false, "list_add_label": "Add More", - "name": "input_data", + "load_from_db": false, + "name": "project_id", "override_skip": false, "placeholder": "", - "required": true, - "show": true, + "required": false, + "show": false, "title_case": false, + "tool_mode": false, "trace_as_metadata": true, "track_in_telemetry": false, - "type": "other", + "type": "str", "value": "" }, - "mode": { - "_input_type": "TabInput", - "advanced": false, - "display_name": "Mode", + "stream": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Stream", "dynamic": false, - "info": "Convert into raw string instead of using a template.", - "name": "mode", - "options": [ - "Parser", - "Stringify" - ], + "info": "Whether to stream the response", + "list": false, + "list_add_label": "Add More", + "name": "stream", "override_skip": false, "placeholder": "", - "real_time_refresh": true, "required": false, "show": true, "title_case": false, "tool_mode": false, "trace_as_metadata": true, "track_in_telemetry": true, - "type": "tab", - "value": "Parser" + "type": "bool", + "value": false }, - "pattern": { + "system_message": { "_input_type": "MultilineInput", "advanced": false, "ai_enabled": false, "copy_field": false, - "display_name": "Template", - "dynamic": true, - "info": "Use variables within curly brackets to extract column values for DataFrames or key values for Data.For example: `Name: {Name}, Age: {Age}, Country: {Country}`", + "display_name": "System Message", + "dynamic": false, + "info": "A system message that helps set the behavior of the assistant", "input_types": [ "Message" ], @@ -1706,10 +1745,10 @@ "list_add_label": "Add More", "load_from_db": false, "multiline": true, - "name": "pattern", + "name": "system_message", "override_skip": false, "placeholder": "", - "required": true, + "required": false, "show": true, "title_case": false, "tool_mode": false, @@ -1717,64 +1756,70 @@ "trace_as_metadata": true, "track_in_telemetry": false, "type": "str", - "value": "Text: {text}" + "value": "Translate to Portuguese and output in structured formatReturn only the JSON and no additional text." }, - "sep": { - "_input_type": "MessageTextInput", + "temperature": { + "_input_type": "SliderInput", "advanced": true, - "display_name": "Separator", + "display_name": "Temperature", "dynamic": false, - "info": "String used to separate rows/items.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "sep", + "info": "Controls randomness in responses", + "max_label": "", + "max_label_icon": "", + "min_label": "", + "min_label_icon": "", + "name": "temperature", "override_skip": false, "placeholder": "", + "range_spec": { + "max": 1, + "min": 0, + "step": 0.01, + "step_type": "float" + }, "required": false, "show": true, + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, "title_case": false, "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, "track_in_telemetry": false, - "type": "str", - "value": "\n" + "type": "slider", + "value": 0.1 } }, "tool_mode": false }, + "selected_output": "text_output", "showNode": true, - "type": "ParserComponent" + "type": "LanguageModelComponent" }, "dragging": false, - "id": "ParserComponent-pXAMb", + "id": "LanguageModelComponent-EeFeV", "measured": { - "height": 329, + "height": 367, "width": 320 }, "position": { - "x": 1102.2949192976482, - "y": -80.12506181353082 + "x": 1557.2517084884046, + "y": 116.45950039872002 }, - "selected": false, + "selected": true, "type": "genericNode" } ], "viewport": { - "x": 245.66793868161778, - "y": 267.9129204471257, - "zoom": 0.48565229862392995 + "x": -345.0986657169483, + "y": 284.78237469964324, + "zoom": 0.7918428722829236 } }, "description": "This template iterates over search results using LoopComponent and translates each result into Portuguese automatically. 🚀", "endpoint_name": null, "id": "70f6d78d-41ad-42a3-854d-405e161ee563", "is_component": false, - "last_tested_version": "1.7.0", + "last_tested_version": "1.7.2", "name": "Research Translation Loop", "tags": [ "chatbots", diff --git a/src/frontend/src/App.css b/src/frontend/src/App.css index 993c0228998a..e91cb6898608 100644 --- a/src/frontend/src/App.css +++ b/src/frontend/src/App.css @@ -94,6 +94,26 @@ body { background-color: hsl(var(--placeholder-foreground)) !important; } +/* Sticky note scrollbar - improved visibility and cursor */ +.sticky-note-scroll::-webkit-scrollbar { + width: 6px !important; +} + +.sticky-note-scroll::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted-foreground) / 0.4) !important; + border-radius: 999px !important; + cursor: pointer !important; +} + +.sticky-note-scroll::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--muted-foreground) / 0.6) !important; + cursor: pointer !important; +} + +.sticky-note-scroll::-webkit-scrollbar-track { + background-color: transparent !important; +} + .jv-indent::-webkit-scrollbar-track { background-color: hsl(var(--muted)) !important; border-radius: 10px; diff --git a/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx index ed0e0fb3c78b..b4f2ec129518 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx @@ -78,7 +78,9 @@ export default function NodeDescription({ , + nodes: [] as Array<{ + id: string; + measured?: { width?: number; height?: number }; + }>, }, }; @@ -96,7 +99,7 @@ describe("NoteNode Shrink Behavior", () => { const resizer = screen.getByTestId("node-resizer"); expect(Number(resizer.dataset.minWidth)).toBe(NOTE_NODE_MIN_WIDTH); - expect(NOTE_NODE_MIN_WIDTH).toBe(260); + expect(NOTE_NODE_MIN_WIDTH).toBe(280); }); it("should configure NodeResizer with correct minimum height", () => { @@ -105,7 +108,7 @@ describe("NoteNode Shrink Behavior", () => { const resizer = screen.getByTestId("node-resizer"); expect(Number(resizer.dataset.minHeight)).toBe(NOTE_NODE_MIN_HEIGHT); - expect(NOTE_NODE_MIN_HEIGHT).toBe(100); + expect(NOTE_NODE_MIN_HEIGHT).toBe(140); }); it("should show resizer only when selected", () => { @@ -124,16 +127,16 @@ describe("NoteNode Shrink Behavior", () => { }); describe("Default Size Behavior", () => { - it("should use DEFAULT_NOTE_SIZE when no dimensions are stored", () => { + it("should use minimum size when no dimensions are stored", () => { const data = createMockData("note-1"); mockCurrentFlow.data.nodes = []; render(); const noteNode = screen.getByTestId("note_node"); - expect(noteNode.style.width).toBe(`${DEFAULT_NOTE_SIZE}px`); - expect(noteNode.style.height).toBe(`${DEFAULT_NOTE_SIZE}px`); - expect(DEFAULT_NOTE_SIZE).toBe(324); + // Component uses MIN values as fallback when no measured dimensions exist + expect(noteNode.style.width).toBe(`${NOTE_NODE_MIN_WIDTH}px`); + expect(noteNode.style.height).toBe(`${NOTE_NODE_MIN_HEIGHT}px`); }); it("should use stored dimensions from flow state", () => { @@ -142,7 +145,10 @@ describe("NoteNode Shrink Behavior", () => { const customHeight = 300; mockCurrentFlow.data.nodes = [ - { id: "note-1", width: customWidth, height: customHeight }, + { + id: "note-1", + measured: { width: customWidth, height: customHeight }, + }, ]; render(); @@ -161,8 +167,10 @@ describe("NoteNode Shrink Behavior", () => { mockCurrentFlow.data.nodes = [ { id: "note-1", - width: NOTE_NODE_MIN_WIDTH, - height: NOTE_NODE_MIN_HEIGHT, + measured: { + width: NOTE_NODE_MIN_WIDTH, + height: NOTE_NODE_MIN_HEIGHT, + }, }, ]; @@ -176,7 +184,10 @@ describe("NoteNode Shrink Behavior", () => { it("should render correctly at minimum width", () => { const data = createMockData("note-1"); mockCurrentFlow.data.nodes = [ - { id: "note-1", width: NOTE_NODE_MIN_WIDTH, height: DEFAULT_NOTE_SIZE }, + { + id: "note-1", + measured: { width: NOTE_NODE_MIN_WIDTH, height: DEFAULT_NOTE_SIZE }, + }, ]; render(); @@ -190,8 +201,10 @@ describe("NoteNode Shrink Behavior", () => { mockCurrentFlow.data.nodes = [ { id: "note-1", - width: DEFAULT_NOTE_SIZE, - height: NOTE_NODE_MIN_HEIGHT, + measured: { + width: DEFAULT_NOTE_SIZE, + height: NOTE_NODE_MIN_HEIGHT, + }, }, ]; diff --git a/src/frontend/src/CustomNodes/NoteNode/index.tsx b/src/frontend/src/CustomNodes/NoteNode/index.tsx index c3414d5638a8..5ab880bd4163 100644 --- a/src/frontend/src/CustomNodes/NoteNode/index.tsx +++ b/src/frontend/src/CustomNodes/NoteNode/index.tsx @@ -1,9 +1,8 @@ import { NodeResizer } from "@xyflow/react"; import { debounce } from "lodash"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef } from "react"; import { COLOR_OPTIONS, - DEFAULT_NOTE_SIZE, NOTE_NODE_MIN_HEIGHT, NOTE_NODE_MIN_WIDTH, } from "@/constants/constants"; @@ -70,7 +69,6 @@ function NoteNode({ selected?: boolean; }) { const nodeRef = useRef(null); - const [isResizing, setIsResizing] = useState(false); const [isEditingDescription, setIsEditingDescription] = useAlternate(false); const currentFlow = useFlowStore((state) => state.currentFlow); @@ -103,8 +101,8 @@ function NoteNode({ () => currentFlow?.data?.nodes.find((node) => node.id === data.id), [currentFlow, data.id], ); - const nodeWidth = nodeData?.width ?? DEFAULT_NOTE_SIZE; - const nodeHeight = nodeData?.height ?? DEFAULT_NOTE_SIZE; + const nodeWidth = nodeData?.measured?.width ?? NOTE_NODE_MIN_WIDTH; + const nodeHeight = nodeData?.measured?.height ?? NOTE_NODE_MIN_HEIGHT; // Debounced resize handler to avoid excessive state updates during drag const debouncedResize = useMemo( @@ -112,7 +110,7 @@ function NoteNode({ debounce((width: number, height: number) => { setNode(data.id, (node) => ({ ...node, width, height })); }, 5), - [setNode, data.id], + [data.id, setNode], ); // Only render toolbar when note is selected @@ -149,13 +147,11 @@ function NoteNode({ minWidth={NOTE_NODE_MIN_WIDTH} minHeight={NOTE_NODE_MIN_HEIGHT} onResize={(_, { width, height }) => debouncedResize(width, height)} - isVisible={selected} - lineClassName="!border !border-muted-foreground" - onResizeStart={() => setIsResizing(true)} onResizeEnd={() => { - setIsResizing(false); debouncedResize.flush(); }} + isVisible={selected} + lineClassName="!border !border-muted-foreground" />
@@ -181,15 +177,16 @@ function NoteNode({ height: "100%", display: "flex", overflow: "hidden", + maxHeight: "100%", }} className={cn( "flex-1 duration-200 ease-in-out", - !isResizing && "transition-[width,height]", + "transition-[width,height]", )} > { @@ -657,8 +657,8 @@ export default function Page({ id: newId, type: "noteNode", position: position || { x: 0, y: 0 }, - width: DEFAULT_NOTE_SIZE, - height: DEFAULT_NOTE_SIZE, + width: NOTE_NODE_MIN_WIDTH, + height: NOTE_NODE_MIN_HEIGHT, data: { ...data, id: newId, @@ -826,6 +826,7 @@ export default function Page({ backgroundColor: `${shadowBoxBackgroundColor}`, opacity: 0.7, pointerEvents: "none", + borderRadius: "12px", // Prevent shadow-box from showing unexpectedly during initial renders display: "none", }} diff --git a/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts b/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts index e9a510cf7e0b..765cef46e79e 100644 --- a/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts +++ b/src/frontend/tests/core/integrations/Research Translation Loop.spec.ts @@ -4,7 +4,6 @@ import { expect, test } from "../../fixtures"; import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; import { initialGPTsetup } from "../../utils/initialGPTsetup"; import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes"; -import { selectGptModel } from "../../utils/select-gpt-model"; withEventDeliveryModes( "Research Translation Loop.spec", @@ -30,15 +29,9 @@ withEventDeliveryModes( timeout: 100000, }); - await initialGPTsetup(page, { - skipAdjustScreenView: true, - skipSelectGptModel: true, - }); - // TODO: Uncomment this when we have a way to test Anthropic - // await page.getByTestId("dropdown_str_provider").click(); - // await page.getByTestId("Anthropic-1-option").click(); + await initialGPTsetup(page); - await selectGptModel(page); + await page.getByTestId("int_int_max_results").fill("1"); await page.getByTestId("playground-btn-flow-io").click(); @@ -46,8 +39,6 @@ withEventDeliveryModes( timeout: 3000, }); - await page.getByTestId("input-chat-playground").fill("This is a test"); - await page.getByTestId("button-send").click(); await page.waitForSelector('[data-testid="div-chat-message"]', { diff --git a/src/frontend/tests/core/unit/sticky-notes-constants.spec.ts b/src/frontend/tests/core/unit/sticky-notes-constants.spec.ts new file mode 100644 index 000000000000..95894cc2731a --- /dev/null +++ b/src/frontend/tests/core/unit/sticky-notes-constants.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "../../fixtures"; + +test( + "sticky notes constants should be properly defined", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + const constants = await page.evaluate(() => ({ + expectedMinWidth: 280, + expectedMinHeight: 140, + expectedMaxWidth: 1000, + expectedMaxHeight: 800, + })); + + expect(constants.expectedMinWidth).toBe(280); + expect(constants.expectedMinHeight).toBe(140); + expect(constants.expectedMaxWidth).toBe(1000); + expect(constants.expectedMaxHeight).toBe(800); + }, +); + +test( + "sticky notes should use text-base font size", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + const textSize = await page.evaluate(() => { + const testEl = document.createElement("div"); + testEl.className = "text-base"; + testEl.textContent = "Test"; + testEl.style.visibility = "hidden"; + document.body.appendChild(testEl); + + const style = window.getComputedStyle(testEl); + const result = { + fontSize: style.fontSize, + }; + + document.body.removeChild(testEl); + return result; + }); + + // Verify text-base (16px / 1rem) is applied for sticky note readability + expect(textSize.fontSize).toBe("16px"); + }, +); diff --git a/src/frontend/tests/extended/features/sticky-notes-dimensions.spec.ts b/src/frontend/tests/extended/features/sticky-notes-dimensions.spec.ts new file mode 100644 index 000000000000..265ad64f953d --- /dev/null +++ b/src/frontend/tests/extended/features/sticky-notes-dimensions.spec.ts @@ -0,0 +1,340 @@ +import { expect, test } from "../../fixtures"; +import { adjustScreenView } from "../../utils/adjust-screen-view"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; + +test( + "sticky notes should have consistent 280x140px dimensions", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + + // Take reference element for size comparison + const targetElement = page.locator('//*[@id="react-flow-id"]'); + + // Start adding note + await page.getByTestId("sidebar-nav-add_note").click(); + + // Get shadow-box dimensions while dragging + const shadowBox = page.locator("#shadow-box"); + await page.mouse.move(300, 300); + + const shadowBoxSize = await shadowBox.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + width: parseInt(style.width), + height: parseInt(style.height), + borderRadius: style.borderRadius, + }; + }); + + // Place the note + await targetElement.click(); + await page.mouse.up(); + await page.mouse.down(); + await adjustScreenView(page); + + // Get placed note dimensions + const noteNode = page.getByTestId("note_node"); + await expect(noteNode).toBeVisible(); + + const noteSize = await noteNode.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + width: parseInt(style.width), + height: parseInt(style.height), + borderRadius: style.borderRadius, + }; + }); + + // Verify shadow-box and note have same dimensions + expect(shadowBoxSize.width).toBe(280); + expect(shadowBoxSize.height).toBe(140); + expect(noteSize.width).toBe(280); + expect(noteSize.height).toBe(140); + + // Verify rounded corners consistency + expect(shadowBoxSize.borderRadius).toBe("12px"); + expect(noteSize.borderRadius).toBe("12px"); + }, +); + +test( + "sticky notes should maintain size with content", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + + // Add sticky note + await page.getByTestId("sidebar-nav-add_note").click(); + const targetElement = page.locator('//*[@id="react-flow-id"]'); + await targetElement.click(); + await page.mouse.up(); + await page.mouse.down(); + await adjustScreenView(page); + + const noteNode = page.getByTestId("note_node"); + + // Get initial size (empty note) + const initialSize = await noteNode.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + width: parseInt(style.width), + height: parseInt(style.height), + }; + }); + + // Add content to note + await noteNode.click(); + await page.locator(".generic-node-desc-text").last().dblclick(); + + const longText = + "This is a very long text that should not change the note dimensions because we have fixed sizing with overflow handling. ".repeat( + 10, + ); + await page.getByTestId("textarea").fill(longText); + + // Click outside to finish editing + await targetElement.click(); + await page.keyboard.press("Escape"); + + // Get size after content added + const finalSize = await noteNode.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + width: parseInt(style.width), + height: parseInt(style.height), + }; + }); + + // Verify size hasn't changed + expect(finalSize.width).toBe(initialSize.width); + expect(finalSize.height).toBe(initialSize.height); + expect(finalSize.width).toBe(280); + expect(finalSize.height).toBe(140); + }, +); + +test( + "sticky notes should have larger readable text", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + + // Add sticky note + await page.getByTestId("sidebar-nav-add_note").click(); + const targetElement = page.locator('//*[@id="react-flow-id"]'); + await targetElement.click(); + await page.mouse.up(); + await page.mouse.down(); + await adjustScreenView(page); + + const noteNode = page.getByTestId("note_node"); + await noteNode.click(); + + // Enter edit mode + await page.locator(".generic-node-desc-text").last().dblclick(); + const textarea = page.getByTestId("textarea"); + + // Check input text size + const inputTextStyle = await textarea.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + fontSize: style.fontSize, + fontWeight: style.fontWeight, + }; + }); + + // Add some text and exit edit mode + await textarea.fill("Test text for size verification"); + await targetElement.click(); + await page.keyboard.press("Escape"); + + // Check rendered text size + const renderedText = page.getByTestId("generic-node-desc"); + const renderedTextStyle = await renderedText.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + fontSize: style.fontSize, + fontWeight: style.fontWeight, + }; + }); + + // Verify both input and rendered text use same larger size (16px / text-base) + expect(inputTextStyle.fontSize).toBe("16px"); // text-base + expect(renderedTextStyle.fontSize).toBe("16px"); // text-base from markdown + // Font-weight check: 500 (font-medium) expected, but browsers may fallback to 400 + // if the font doesn't have weight 500 available + expect(Number(inputTextStyle.fontWeight)).toBeGreaterThanOrEqual(400); + expect(Number(renderedTextStyle.fontWeight)).toBeGreaterThanOrEqual(400); + }, +); + +test( + "sticky notes should handle overflow with scrollbars", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + + // Add sticky note + await page.getByTestId("sidebar-nav-add_note").click(); + const targetElement = page.locator('//*[@id="react-flow-id"]'); + await targetElement.click(); + await page.mouse.up(); + await page.mouse.down(); + await adjustScreenView(page); + + const noteNode = page.getByTestId("note_node"); + await noteNode.click(); + + // Add very long content that should overflow + await page.locator(".generic-node-desc-text").last().dblclick(); + const veryLongText = + "Line of text that will create vertical overflow. ".repeat(20); + await page.getByTestId("textarea").fill(veryLongText); + + // Check that textarea has max-height and overflow + const textarea = page.getByTestId("textarea"); + const textareaOverflow = await textarea.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + maxHeight: style.maxHeight, + overflowY: style.overflowY, + height: parseInt(style.height), + }; + }); + + // Exit edit mode and check rendered content overflow + await targetElement.click(); + await page.keyboard.press("Escape"); + + const renderedContent = page.getByTestId("generic-node-desc"); + const contentOverflow = await renderedContent.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + maxHeight: style.maxHeight, + overflowY: style.overflowY, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; + }); + + // Verify overflow handling - should have some form of overflow control + expect( + ["auto", "scroll", "hidden"].includes(textareaOverflow.overflowY), + ).toBe(true); + expect( + ["auto", "scroll", "hidden"].includes(contentOverflow.overflowY), + ).toBe(true); + + // Content should be constrained (either by max-height or overflow) + const hasOverflowControl = + contentOverflow.scrollHeight > contentOverflow.clientHeight || + contentOverflow.maxHeight !== "none"; + expect(hasOverflowControl).toBe(true); + }, +); + +test( + "sticky notes should respect resize constraints", + { tag: ["@release", "@workspace"] }, + + async ({ page }) => { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + + // Add sticky note + await page.getByTestId("sidebar-nav-add_note").click(); + const targetElement = page.locator('//*[@id="react-flow-id"]'); + await targetElement.click(); + await page.mouse.up(); + await page.mouse.down(); + await adjustScreenView(page); + + const noteNode = page.getByTestId("note_node"); + await noteNode.click(); + + // Verify resize handles are visible when selected + const resizeHandles = page.locator(".react-flow__resize-control"); + await expect(resizeHandles.first()).toBeVisible(); + + // Get initial size + const initialSize = await noteNode.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + width: parseInt(style.width), + height: parseInt(style.height), + }; + }); + + // Try to resize larger (should work) + const resizeHandle = page.locator( + ".react-flow__resize-control.bottom.right", + ); + await resizeHandle.hover(); + + // Get initial position of resize handle + const handleBox = await resizeHandle.boundingBox(); + if (handleBox) { + await page.mouse.move( + handleBox.x + handleBox.width / 2, + handleBox.y + handleBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move(handleBox.x + 100, handleBox.y + 50); // Move handle to resize + await page.mouse.up(); + } + + const enlargedSize = await noteNode.evaluate((el) => { + const style = window.getComputedStyle(el); + return { + width: parseInt(style.width), + height: parseInt(style.height), + }; + }); + + // Verify it can be resized larger (if resize worked) or at least respects constraints + if ( + enlargedSize.width > initialSize.width || + enlargedSize.height > initialSize.height + ) { + expect(enlargedSize.width).toBeGreaterThanOrEqual(initialSize.width); + expect(enlargedSize.height).toBeGreaterThanOrEqual(initialSize.height); + } + + // Verify it respects minimum constraints (280x140) + expect(enlargedSize.width).toBeGreaterThanOrEqual(280); + expect(enlargedSize.height).toBeGreaterThanOrEqual(140); + + // Verify it respects maximum constraints (1000x800) + expect(enlargedSize.width).toBeLessThanOrEqual(1000); + expect(enlargedSize.height).toBeLessThanOrEqual(800); + }, +); diff --git a/src/frontend/tests/utils/select-gpt-model.ts b/src/frontend/tests/utils/select-gpt-model.ts index 783c322aaa90..3b41bc160a4c 100644 --- a/src/frontend/tests/utils/select-gpt-model.ts +++ b/src/frontend/tests/utils/select-gpt-model.ts @@ -1,47 +1,78 @@ import type { Page } from "@playwright/test"; -export const selectGptModel = async (page: Page) => { - const gptModelDropdownCount = await page.getByTestId("model_model").count(); - - for (let i = 0; i < gptModelDropdownCount; i++) { - await page.getByTestId("model_model").nth(i).click(); - await page.waitForSelector('[role="listbox"]', { timeout: 10000 }); - - const gptOMiniOption = await page.getByTestId("gpt-4o-mini-option").count(); - - await page.waitForTimeout(500); - - if (gptOMiniOption === 0) { - await page.getByTestId("manage-model-providers").click(); - await page.waitForSelector("text=Model providers", { timeout: 30000 }); - - await page.getByTestId("provider-item-OpenAI").click(); - await page.waitForTimeout(500); - - const checkExistingKey = await page.getByTestId("input-end-icon").count(); - if (checkExistingKey === 0) { - await page - .getByPlaceholder("Add API key") - .fill(process.env.OPENAI_API_KEY!); - await page.waitForSelector("text=OpenAI Api Key Saved", { - timeout: 30000, - }); - await page.getByTestId("llm-toggle-gpt-4o-mini").click(); - await page.getByText("Close").last().click(); - } else { - await page.waitForTimeout(500); - - const isChecked = await page - .getByTestId("llm-toggle-gpt-4o-mini") - .isChecked(); - if (!isChecked) { - await page.getByTestId("llm-toggle-gpt-4o-mini").click(); - } - await page.getByText("Close").last().click(); - await page.getByTestId("model_model").nth(i).click(); - } +const MODEL_OPTION_TESTID = "gpt-4o-mini-option"; +const MODEL_TOGGLE_TESTID = "llm-toggle-gpt-4o-mini"; +const LISTBOX_TIMEOUT = 10000; +const MODAL_TIMEOUT = 30000; +const SHORT_DELAY = 500; + +async function configureOpenAIProvider(page: Page): Promise { + await page.getByTestId("manage-model-providers").click(); + await page.waitForSelector("text=Model providers", { + timeout: MODAL_TIMEOUT, + }); + await page.getByTestId("provider-item-OpenAI").click(); + await page.waitForTimeout(SHORT_DELAY); + + const hasExistingKey = (await page.getByTestId("input-end-icon").count()) > 0; + + if (!hasExistingKey) { + await page + .getByPlaceholder("Add API key") + .fill(process.env.OPENAI_API_KEY!); + await page.waitForSelector("text=OpenAI Api Key Saved", { + timeout: MODAL_TIMEOUT, + }); + await page.getByTestId(MODEL_TOGGLE_TESTID).click(); + await page.getByText("Close").last().click(); + return false; + } + + await page.waitForTimeout(SHORT_DELAY); + const isModelEnabled = await page + .getByTestId(MODEL_TOGGLE_TESTID) + .isChecked(); + if (!isModelEnabled) { + await page.getByTestId(MODEL_TOGGLE_TESTID).click(); + } + await page.getByText("Close").last().click(); + return true; +} + +async function selectModelFromDropdown( + page: Page, + dropdownTestId: string, + index: number, +): Promise { + await page.getByTestId(dropdownTestId).nth(index).click(); + await page.waitForSelector('[role="listbox"]', { timeout: LISTBOX_TIMEOUT }); + + const hasModelOption = + (await page.getByTestId(MODEL_OPTION_TESTID).count()) > 0; + await page.waitForTimeout(SHORT_DELAY); + + if (!hasModelOption) { + const needsReopen = await configureOpenAIProvider(page); + if (needsReopen) { + await page.getByTestId(dropdownTestId).nth(index).click(); } - await page.waitForTimeout(500); - await page.getByTestId("gpt-4o-mini-option").click(); + } + + await page.waitForTimeout(SHORT_DELAY); + await page.getByTestId(MODEL_OPTION_TESTID).click(); +} + +export const selectGptModel = async (page: Page): Promise => { + const modelDropdownCount = await page.getByTestId("model_model").count(); + const modelNameDropdownCount = await page + .getByTestId("model_model_name") + .count(); + + for (let i = 0; i < modelDropdownCount; i++) { + await selectModelFromDropdown(page, "model_model", i); + } + + for (let i = 0; i < modelNameDropdownCount; i++) { + await selectModelFromDropdown(page, "model_model_name", i); } };