From 634fffb96761b2e908cff0bda9a7efc851e6c6f8 Mon Sep 17 00:00:00 2001 From: Swifty Date: Fri, 26 Sep 2025 11:23:14 +0200 Subject: [PATCH 1/8] fix(blocks): Handle NoneType in DataForSEO Blocks and Add missing Err (#11004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes critical issues in the DataForSEO blocks to improve error handling and prevent runtime exceptions. ### Changes 🏗️ 1. **Fixed NoneType error in DataForSEO Related Keywords Block** (#10990) - Added null check to ensure `items` is always a list before iteration - Prevents TypeError when API returns None for items field - Ensures robust handling of unexpected API responses 2. **Added error output pins to DataForSEO blocks** (#10981) - Added `error` field to Output schema in both `related_keywords.py` and `keyword_suggestions.py` - Wrapped entire `run` methods in try-except blocks - Errors are now properly yielded to the error output pin, allowing agents to handle failures gracefully ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified that DataForSEO blocks handle None responses without throwing TypeError - [x] Confirmed error output pins capture and yield exceptions properly - [x] Ensured backwards compatibility with existing block implementations - [x] Tested both Related Keywords and Keyword Suggestions blocks #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) --- Fixes #10990 Fixes #10981 Generated with [Claude Code](https://claude.ai/code) ### Changes 🏗️ ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [ ] `.env.default` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
Co-authored-by: Toran Bruce Richards --- .../blocks/dataforseo/keyword_suggestions.py | 76 +++++++++------- .../blocks/dataforseo/related_keywords.py | 91 +++++++++++-------- 2 files changed, 94 insertions(+), 73 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py b/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py index ac36215bc7..1a04f8e598 100644 --- a/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py +++ b/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py @@ -90,6 +90,7 @@ class DataForSeoKeywordSuggestionsBlock(Block): seed_keyword: str = SchemaField( description="The seed keyword used for the query" ) + error: str = SchemaField(description="Error message if the API call failed") def __init__(self): super().__init__( @@ -161,43 +162,52 @@ class DataForSeoKeywordSuggestionsBlock(Block): **kwargs, ) -> BlockOutput: """Execute the keyword suggestions query.""" - client = DataForSeoClient(credentials) + try: + client = DataForSeoClient(credentials) - results = await self._fetch_keyword_suggestions(client, input_data) + results = await self._fetch_keyword_suggestions(client, input_data) - # Process and format the results - suggestions = [] - if results and len(results) > 0: - # results is a list, get the first element - first_result = results[0] if isinstance(results, list) else results - items = ( - first_result.get("items", []) if isinstance(first_result, dict) else [] - ) - for item in items: - # Create the KeywordSuggestion object - suggestion = KeywordSuggestion( - keyword=item.get("keyword", ""), - search_volume=item.get("keyword_info", {}).get("search_volume"), - competition=item.get("keyword_info", {}).get("competition"), - cpc=item.get("keyword_info", {}).get("cpc"), - keyword_difficulty=item.get("keyword_properties", {}).get( - "keyword_difficulty" - ), - serp_info=( - item.get("serp_info") if input_data.include_serp_info else None - ), - clickstream_data=( - item.get("clickstream_keyword_info") - if input_data.include_clickstream_data - else None - ), + # Process and format the results + suggestions = [] + if results and len(results) > 0: + # results is a list, get the first element + first_result = results[0] if isinstance(results, list) else results + items = ( + first_result.get("items", []) + if isinstance(first_result, dict) + else [] ) - yield "suggestion", suggestion - suggestions.append(suggestion) + if items is None: + items = [] + for item in items: + # Create the KeywordSuggestion object + suggestion = KeywordSuggestion( + keyword=item.get("keyword", ""), + search_volume=item.get("keyword_info", {}).get("search_volume"), + competition=item.get("keyword_info", {}).get("competition"), + cpc=item.get("keyword_info", {}).get("cpc"), + keyword_difficulty=item.get("keyword_properties", {}).get( + "keyword_difficulty" + ), + serp_info=( + item.get("serp_info") + if input_data.include_serp_info + else None + ), + clickstream_data=( + item.get("clickstream_keyword_info") + if input_data.include_clickstream_data + else None + ), + ) + yield "suggestion", suggestion + suggestions.append(suggestion) - yield "suggestions", suggestions - yield "total_count", len(suggestions) - yield "seed_keyword", input_data.keyword + yield "suggestions", suggestions + yield "total_count", len(suggestions) + yield "seed_keyword", input_data.keyword + except Exception as e: + yield "error", f"Failed to fetch keyword suggestions: {str(e)}" class KeywordSuggestionExtractorBlock(Block): diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py b/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py index 7535076fb7..f0c26c5b06 100644 --- a/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py +++ b/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py @@ -98,6 +98,7 @@ class DataForSeoRelatedKeywordsBlock(Block): seed_keyword: str = SchemaField( description="The seed keyword used for the query" ) + error: str = SchemaField(description="Error message if the API call failed") def __init__(self): super().__init__( @@ -171,50 +172,60 @@ class DataForSeoRelatedKeywordsBlock(Block): **kwargs, ) -> BlockOutput: """Execute the related keywords query.""" - client = DataForSeoClient(credentials) + try: + client = DataForSeoClient(credentials) - results = await self._fetch_related_keywords(client, input_data) + results = await self._fetch_related_keywords(client, input_data) - # Process and format the results - related_keywords = [] - if results and len(results) > 0: - # results is a list, get the first element - first_result = results[0] if isinstance(results, list) else results - items = ( - first_result.get("items", []) if isinstance(first_result, dict) else [] - ) - for item in items: - # Extract keyword_data from the item - keyword_data = item.get("keyword_data", {}) - - # Create the RelatedKeyword object - keyword = RelatedKeyword( - keyword=keyword_data.get("keyword", ""), - search_volume=keyword_data.get("keyword_info", {}).get( - "search_volume" - ), - competition=keyword_data.get("keyword_info", {}).get("competition"), - cpc=keyword_data.get("keyword_info", {}).get("cpc"), - keyword_difficulty=keyword_data.get("keyword_properties", {}).get( - "keyword_difficulty" - ), - serp_info=( - keyword_data.get("serp_info") - if input_data.include_serp_info - else None - ), - clickstream_data=( - keyword_data.get("clickstream_keyword_info") - if input_data.include_clickstream_data - else None - ), + # Process and format the results + related_keywords = [] + if results and len(results) > 0: + # results is a list, get the first element + first_result = results[0] if isinstance(results, list) else results + items = ( + first_result.get("items", []) + if isinstance(first_result, dict) + else [] ) - yield "related_keyword", keyword - related_keywords.append(keyword) + # Ensure items is never None + if items is None: + items = [] + for item in items: + # Extract keyword_data from the item + keyword_data = item.get("keyword_data", {}) - yield "related_keywords", related_keywords - yield "total_count", len(related_keywords) - yield "seed_keyword", input_data.keyword + # Create the RelatedKeyword object + keyword = RelatedKeyword( + keyword=keyword_data.get("keyword", ""), + search_volume=keyword_data.get("keyword_info", {}).get( + "search_volume" + ), + competition=keyword_data.get("keyword_info", {}).get( + "competition" + ), + cpc=keyword_data.get("keyword_info", {}).get("cpc"), + keyword_difficulty=keyword_data.get( + "keyword_properties", {} + ).get("keyword_difficulty"), + serp_info=( + keyword_data.get("serp_info") + if input_data.include_serp_info + else None + ), + clickstream_data=( + keyword_data.get("clickstream_keyword_info") + if input_data.include_clickstream_data + else None + ), + ) + yield "related_keyword", keyword + related_keywords.append(keyword) + + yield "related_keywords", related_keywords + yield "total_count", len(related_keywords) + yield "seed_keyword", input_data.keyword + except Exception as e: + yield "error", f"Failed to fetch related keywords: {str(e)}" class RelatedKeywordExtractorBlock(Block): From da6e1ad26df38d14c63c7f0be103ceece4ff5e36 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:12:05 +0530 Subject: [PATCH 2/8] refactor(frontend): enhance builder UI for better performance (#10922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes 🏗️ This PR introduces a new high-performance builder interface for the AutoGPT platform, implementing a React Flow-based visual editor with optimized state management and rendering. #### Key Changes: 1. **New Flow Editor Implementation** - Built on React Flow for efficient graph rendering and interaction - Implements a node-based visual workflow builder with custom nodes and edges - Dynamic form generation using React JSON Schema Form (RJSF) for block inputs - Intelligent connection handling with visual feedback 2. **State Management Optimization** - Added Zustand for lightweight, performant state management - Separated node and edge stores for better data isolation - Reduced unnecessary re-renders through granular state updates 3. **Dual Builder View (Temporary)** - Added toggle between old and new builder implementations - Allows A/B testing and gradual migration - Feature flagged for controlled rollout 4. **Enhanced UI Components** - Custom form widgets for various input types (date, time, file, etc.) - Array and object editors with improved UX - Connection handles with visual state indicators - Advanced mode toggle for complex configurations 5. **Architecture Improvements** - Modular component structure for better code organization - Comprehensive documentation for the new system - Type-safe implementation with TypeScript #### Dependencies Added: - `zustand` (v5.0.2) - State management - `@rjsf/core` (v5.22.8) - JSON Schema Form core - `@rjsf/utils` (v5.22.8) - RJSF utilities - `@rjsf/validator-ajv8` (v5.22.8) - Schema validation ### Performance Improvements 🚀 - **Reduced Re-renders**: Zustand's shallow comparison and selective subscriptions minimize unnecessary component updates - **Optimized Graph Rendering**: React Flow provides efficient canvas-based rendering for large workflows - **Lazy Loading**: Components are loaded on-demand reducing initial bundle size - **Memoized Computations**: Heavy calculations are cached to avoid redundant processing ### Test Plan 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: #### Test Checklist: - [x] Create a new agent from scratch with at least 5 blocks - [x] Connect blocks and verify connections render correctly - [x] Switch between old and new builder views - [x] Test all form input types (text, number, boolean, array, object) - [x] Verify data persistence when switching views - [x] Test advanced mode toggle functionality - [x] Performance test with 50+ blocks to verify smooth interaction ### Migration Strategy The implementation includes a temporary toggle to switch between the old and new builder. This allows for: - Gradual user migration - A/B testing to measure performance improvements - Fallback option if issues are discovered - Collecting user feedback before full rollout ### Documentation Comprehensive documentation has been added: - `/components/FlowEditor/docs/README.md` - Architecture overview and store management - `/components/FlowEditor/docs/FORM_CREATOR.md` - Detailed form system documentation --------- Co-authored-by: Zamil Majdy Co-authored-by: Claude --- autogpt_platform/frontend/package.json | 6 +- autogpt_platform/frontend/pnpm-lock.yaml | 163 +++++ .../BuilderViewTabs/BuilderViewTabs.tsx | 31 + .../BuilderViewTabs/useBuilderViewTabs.ts | 44 ++ .../build/components/FlowEditor/Flow.tsx | 44 ++ .../components/ArrayEditor/ArrayEditor.tsx | 88 +++ .../ArrayEditor/ArrayEditorContext.tsx | 11 + .../components/ObjectEditor/ObjectEditor.tsx | 166 +++++ .../FlowEditor/docs/FORM_CREATOR.md | 580 ++++++++++++++++++ .../components/FlowEditor/docs/README.md | 159 +++++ .../FlowEditor/edges/CustomEdge.tsx | 59 ++ .../components/FlowEditor/edges/helpers.ts | 12 + .../FlowEditor/edges/useCustomEdge.ts | 70 +++ .../FlowEditor/handlers/NodeHandle.tsx | 32 + .../components/FlowEditor/handlers/helpers.ts | 117 ++++ .../FlowEditor/nodes/CustomNode.tsx | 69 +++ .../FlowEditor/nodes/FormCreator.tsx | 33 + .../FlowEditor/nodes/OutputHandler.tsx | 90 +++ .../nodes/fields/AnyOfField/AnyOfField.tsx | 195 ++++++ .../nodes/fields/AnyOfField/useAnyOfField.tsx | 97 +++ .../nodes/fields/CredentialField.tsx | 34 + .../FlowEditor/nodes/fields/ObjectField.tsx | 41 ++ .../FlowEditor/nodes/fields/index.ts | 10 + .../components/FlowEditor/nodes/helpers.ts | 141 +++++ .../nodes/templates/ArrayFieldTemplate.tsx | 30 + .../nodes/templates/FieldTemplate.tsx | 103 ++++ .../FlowEditor/nodes/templates/index.ts | 10 + .../components/FlowEditor/nodes/uiSchema.ts | 12 + .../nodes/widgets/DateInputWidget.tsx | 23 + .../nodes/widgets/DateTimeInputWidget.tsx | 21 + .../FlowEditor/nodes/widgets/FileWidget.tsx | 33 + .../FlowEditor/nodes/widgets/SelectWidget.tsx | 62 ++ .../FlowEditor/nodes/widgets/SwitchWidget.tsx | 15 + .../nodes/widgets/TextInputWidget.tsx | 68 ++ .../nodes/widgets/TimeInputWidget.tsx | 20 + .../FlowEditor/nodes/widgets/index.ts | 18 + .../processors/input-schema-pre-processor.ts | 112 ++++ .../AllBlocksContent/AllBlocksContent.tsx | 4 + .../NewBlockMenu/BlockList/BlockList.tsx | 21 +- .../NewBlockMenu/BlockMenu/BlockMenu.tsx | 11 +- .../NewBlockMenu/BlockMenu/useBlockMenu.ts | 12 +- .../IntegrationBlocks/IntegrationBlocks.tsx | 3 + .../NewControlPanel/NewControlPanel.tsx | 82 ++- .../NewControlPanel/useNewControlPanel.ts | 67 +- .../SuggestionContent/SuggestionContent.tsx | 3 + .../build/components/RIghtSidebar.tsx | 88 +++ .../app/(platform)/build/components/helper.ts | 13 + .../components/legacy-builder/Flow/Flow.tsx | 2 +- .../src/app/(platform)/build/page.tsx | 32 +- .../app/(platform)/build/stores/edgeStore.ts | 82 +++ .../app/(platform)/build/stores/nodeStore.ts | 75 +++ .../frontend/src/app/(platform)/layout.tsx | 6 +- .../components/atoms/DateInput/DateInput.tsx | 143 +++++ .../atoms/DateTimeInput/DateTimeInput.tsx | 253 ++++++++ .../src/components/atoms/Select/Select.tsx | 4 +- .../components/atoms/TimeInput/TimeInput.tsx | 114 ++++ .../layout/Navbar/components/NavbarView.tsx | 2 +- .../services/feature-flags/use-get-flag.ts | 6 + 58 files changed, 3729 insertions(+), 113 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/BuilderViewTabs.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/useBuilderViewTabs.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditor.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditorContext.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ObjectEditor/ObjectEditor.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/FORM_CREATOR.md create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/README.md create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/OutputHandler.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/AnyOfField/AnyOfField.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/AnyOfField/useAnyOfField.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/CredentialField.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/ObjectField.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/index.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/ArrayFieldTemplate.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/FieldTemplate.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/index.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateInputWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateTimeInputWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/FileWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/SelectWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/SwitchWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/TextInputWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/TimeInputWidget.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/index.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/processors/input-schema-pre-processor.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts create mode 100644 autogpt_platform/frontend/src/components/atoms/DateInput/DateInput.tsx create mode 100644 autogpt_platform/frontend/src/components/atoms/DateTimeInput/DateTimeInput.tsx create mode 100644 autogpt_platform/frontend/src/components/atoms/TimeInput/TimeInput.tsx diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 731151eb69..6a06d361e2 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -48,6 +48,9 @@ "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-tooltip": "1.2.8", + "@rjsf/core": "5.24.13", + "@rjsf/utils": "5.24.13", + "@rjsf/validator-ajv8": "5.24.13", "@sentry/nextjs": "9.42.0", "@supabase/ssr": "0.6.1", "@supabase/supabase-js": "2.55.0", @@ -103,7 +106,8 @@ "tailwindcss-animate": "1.0.7", "uuid": "11.1.0", "vaul": "1.1.2", - "zod": "3.25.76" + "zod": "3.25.76", + "zustand": "5.0.8" }, "devDependencies": { "@chromatic-com/storybook": "4.1.1", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index e9196a53bc..204ed2fa2b 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -77,6 +77,15 @@ importers: '@radix-ui/react-tooltip': specifier: 1.2.8 version: 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rjsf/core': + specifier: 5.24.13 + version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1))(react@18.3.1) + '@rjsf/utils': + specifier: 5.24.13 + version: 5.24.13(react@18.3.1) + '@rjsf/validator-ajv8': + specifier: 5.24.13 + version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1)) '@sentry/nextjs': specifier: 9.42.0 version: 9.42.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.4.7(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9)) @@ -245,6 +254,9 @@ importers: zod: specifier: 3.25.76 version: 3.25.76 + zustand: + specifier: 5.0.8 + version: 5.0.8(@types/react@18.3.17)(immer@10.1.3)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)) devDependencies: '@chromatic-com/storybook': specifier: 4.1.1 @@ -2306,6 +2318,25 @@ packages: react-redux: optional: true + '@rjsf/core@5.24.13': + resolution: {integrity: sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==} + engines: {node: '>=14'} + peerDependencies: + '@rjsf/utils': ^5.24.x + react: ^16.14.0 || >=17 + + '@rjsf/utils@5.24.13': + resolution: {integrity: sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.14.0 || >=17 + + '@rjsf/validator-ajv8@5.24.13': + resolution: {integrity: sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==} + engines: {node: '>=14'} + peerDependencies: + '@rjsf/utils': ^5.24.x + '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -3839,6 +3870,12 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compute-gcd@1.2.1: + resolution: {integrity: sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==} + + compute-lcm@1.1.2: + resolution: {integrity: sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -5136,6 +5173,13 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-compare@0.2.2: + resolution: {integrity: sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==} + + json-schema-merge-allof@0.8.1: + resolution: {integrity: sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==} + engines: {node: '>=12.0.0'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5248,6 +5292,9 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -5340,6 +5387,12 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + markdown-to-jsx@7.7.13: + resolution: {integrity: sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==} + engines: {node: '>= 10'} + peerDependencies: + react: '>= 0.14.0' + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -7216,6 +7269,21 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + validate.io-array@1.0.6: + resolution: {integrity: sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==} + + validate.io-function@1.0.2: + resolution: {integrity: sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==} + + validate.io-integer-array@1.0.0: + resolution: {integrity: sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==} + + validate.io-integer@1.0.5: + resolution: {integrity: sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==} + + validate.io-number@1.0.3: + resolution: {integrity: sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==} + validator@13.15.15: resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==} engines: {node: '>= 0.10'} @@ -7403,6 +7471,24 @@ packages: react: optional: true + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9584,6 +9670,32 @@ snapshots: react: 18.3.1 react-redux: 9.2.0(@types/react@18.3.17)(react@18.3.1)(redux@5.0.1) + '@rjsf/core@5.24.13(@rjsf/utils@5.24.13(react@18.3.1))(react@18.3.1)': + dependencies: + '@rjsf/utils': 5.24.13(react@18.3.1) + lodash: 4.17.21 + lodash-es: 4.17.21 + markdown-to-jsx: 7.7.13(react@18.3.1) + prop-types: 15.8.1 + react: 18.3.1 + + '@rjsf/utils@5.24.13(react@18.3.1)': + dependencies: + json-schema-merge-allof: 0.8.1 + jsonpointer: 5.0.1 + lodash: 4.17.21 + lodash-es: 4.17.21 + react: 18.3.1 + react-is: 18.3.1 + + '@rjsf/validator-ajv8@5.24.13(@rjsf/utils@5.24.13(react@18.3.1))': + dependencies: + '@rjsf/utils': 5.24.13(react@18.3.1) + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + lodash: 4.17.21 + lodash-es: 4.17.21 + '@rollup/plugin-commonjs@28.0.1(rollup@4.46.2)': dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.46.2) @@ -11377,6 +11489,19 @@ snapshots: compare-versions@6.1.1: {} + compute-gcd@1.2.1: + dependencies: + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + validate.io-integer-array: 1.0.0 + + compute-lcm@1.1.2: + dependencies: + compute-gcd: 1.2.1 + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + validate.io-integer-array: 1.0.0 + concat-map@0.0.1: {} concurrently@9.2.1: @@ -12915,6 +13040,16 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-compare@0.2.2: + dependencies: + lodash: 4.17.21 + + json-schema-merge-allof@0.8.1: + dependencies: + compute-lcm: 1.1.2 + json-schema-compare: 0.2.2 + lodash: 4.17.21 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -13028,6 +13163,8 @@ snapshots: dependencies: p-locate: 6.0.0 + lodash-es@4.17.21: {} + lodash.camelcase@4.3.0: {} lodash.debounce@4.0.8: {} @@ -13111,6 +13248,10 @@ snapshots: markdown-table@3.0.4: {} + markdown-to-jsx@7.7.13(react@18.3.1): + dependencies: + react: 18.3.1 + math-intrinsics@1.1.0: {} md5.js@1.3.5: @@ -15457,6 +15598,21 @@ snapshots: uuid@9.0.1: {} + validate.io-array@1.0.6: {} + + validate.io-function@1.0.2: {} + + validate.io-integer-array@1.0.0: + dependencies: + validate.io-array: 1.0.6 + validate.io-integer: 1.0.5 + + validate.io-integer@1.0.5: + dependencies: + validate.io-number: 1.0.3 + + validate.io-number@1.0.3: {} + validator@13.15.15: {} vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -15683,4 +15839,11 @@ snapshots: immer: 10.1.3 react: 18.3.1 + zustand@5.0.8(@types/react@18.3.17)(immer@10.1.3)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.17 + immer: 10.1.3 + react: 18.3.1 + use-sync-external-store: 1.5.0(react@18.3.1) + zwitch@2.0.4: {} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/BuilderViewTabs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/BuilderViewTabs.tsx new file mode 100644 index 0000000000..4f4237445b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/BuilderViewTabs.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/__legacy__/ui/tabs"; + +export type BuilderView = "old" | "new"; + +export function BuilderViewTabs({ + value, + onChange, +}: { + value: BuilderView; + onChange: (value: BuilderView) => void; +}) { + return ( +
+ onChange(v as BuilderView)} + > + + + Old + + + New + + + +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/useBuilderViewTabs.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/useBuilderViewTabs.ts new file mode 100644 index 0000000000..ac02becca5 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/useBuilderViewTabs.ts @@ -0,0 +1,44 @@ +import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo } from "react"; +import { BuilderView } from "./BuilderViewTabs"; + +export function useBuilderView() { + const isNewFlowEditorEnabled = useGetFlag(Flag.NEW_FLOW_EDITOR); + const isBuilderViewSwitchEnabled = useGetFlag(Flag.BUILDER_VIEW_SWITCH); + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const currentView = searchParams.get("view"); + const defaultView = "old"; + const selectedView = useMemo(() => { + if (currentView === "new" || currentView === "old") return currentView; + return defaultView; + }, [currentView, defaultView]); + + useEffect(() => { + if (isBuilderViewSwitchEnabled === true) { + if (currentView !== "new" && currentView !== "old") { + const params = new URLSearchParams(searchParams); + params.set("view", defaultView); + router.replace(`${pathname}?${params.toString()}`); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isBuilderViewSwitchEnabled, defaultView, pathname, router, searchParams]); + + const setSelectedView = (value: BuilderView) => { + const params = new URLSearchParams(searchParams); + params.set("view", value); + router.push(`${pathname}?${params.toString()}`); + }; + + return { + isSwitchEnabled: isBuilderViewSwitchEnabled === true, + selectedView, + setSelectedView, + isNewFlowEditorEnabled: Boolean(isNewFlowEditorEnabled), + } as const; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow.tsx new file mode 100644 index 0000000000..a12f8faebc --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow.tsx @@ -0,0 +1,44 @@ +import { ReactFlow, Background, Controls } from "@xyflow/react"; +import { useNodeStore } from "../../stores/nodeStore"; + +import NewControlPanel from "../NewBlockMenu/NewControlPanel/NewControlPanel"; +import { useShallow } from "zustand/react/shallow"; +import { useMemo } from "react"; +import { CustomNode } from "./nodes/CustomNode"; +import { useCustomEdge } from "./edges/useCustomEdge"; +import CustomEdge from "./edges/CustomEdge"; +import { RightSidebar } from "../RIghtSidebar"; + +export const Flow = () => { + // All these 3 are working perfectly + const nodes = useNodeStore(useShallow((state) => state.nodes)); + const onNodesChange = useNodeStore( + useShallow((state) => state.onNodesChange), + ); + const nodeTypes = useMemo(() => ({ custom: CustomNode }), []); + const { edges, onConnect, onEdgesChange } = useCustomEdge(); + + return ( +
+ {/* Builder area - flexible width */} +
+ + + + + +
+
+ +
+
+ ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditor.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditor.tsx new file mode 100644 index 0000000000..3271edcf90 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditor.tsx @@ -0,0 +1,88 @@ +import { ArrayFieldTemplateItemType, RJSFSchema } from "@rjsf/utils"; +import { generateHandleId, HandleIdType } from "../../handlers/helpers"; +import { ArrayEditorContext } from "./ArrayEditorContext"; +import { Button } from "@/components/atoms/Button/Button"; +import { PlusIcon, XIcon } from "@phosphor-icons/react"; +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; + +export interface ArrayEditorProps { + items?: ArrayFieldTemplateItemType[]; + nodeId: string; + canAdd: boolean | undefined; + onAddClick?: () => void; + disabled: boolean | undefined; + readonly: boolean | undefined; + id: string; +} + +export const ArrayEditor = ({ + items, + nodeId, + canAdd, + onAddClick, + disabled, + readonly, + id, +}: ArrayEditorProps) => { + const { isInputConnected } = useEdgeStore(); + + return ( +
+
+
+ {items?.map((element) => { + const fieldKey = generateHandleId( + id, + [element.index.toString()], + HandleIdType.ARRAY, + ); + const isConnected = isInputConnected(nodeId, fieldKey); + return ( +
+ + {element.children} + + + {element.hasRemove && + !readonly && + !disabled && + !isConnected && ( + + )} +
+ ); + })} +
+
+ + {canAdd && !readonly && !disabled && ( + + )} +
+ ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditorContext.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditorContext.tsx new file mode 100644 index 0000000000..0f3d465e0d --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ArrayEditor/ArrayEditorContext.tsx @@ -0,0 +1,11 @@ +import { createContext } from "react"; + +export const ArrayEditorContext = createContext<{ + isArrayItem: boolean; + fieldKey: string; + isConnected: boolean; +}>({ + isArrayItem: false, + fieldKey: "", + isConnected: false, +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ObjectEditor/ObjectEditor.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ObjectEditor/ObjectEditor.tsx new file mode 100644 index 0000000000..6174ac1a83 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/components/ObjectEditor/ObjectEditor.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React from "react"; +import { Plus, X } from "lucide-react"; +import { Text } from "@/components/atoms/Text/Text"; +import { Button } from "@/components/atoms/Button/Button"; +import { Input } from "@/components/atoms/Input/Input"; +import NodeHandle from "../../handlers/NodeHandle"; +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; +import { generateHandleId, HandleIdType } from "../../handlers/helpers"; + +export interface ObjectEditorProps { + id: string; + value?: Record; + onChange?: (value: Record) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + nodeId: string; + fieldKey: string; +} + +export const ObjectEditor = React.forwardRef( + ( + { + id, + value = {}, + onChange, + placeholder = "Enter value", + disabled = false, + className, + nodeId, + fieldKey, + }, + ref, + ) => { + const setProperty = (key: string, propertyValue: any) => { + if (!onChange) return; + + const newData: Record = { ...value }; + if (propertyValue === undefined || propertyValue === "") { + delete newData[key]; + } else { + newData[key] = propertyValue; + } + onChange(newData); + }; + + const addProperty = () => { + if (!onChange) return; + onChange({ ...value, [""]: "" }); + }; + + const removeProperty = (key: string) => { + if (!onChange) return; + const newData = { ...value }; + delete newData[key]; + onChange(newData); + }; + + const updateKey = (oldKey: string, newKey: string) => { + if (!onChange || oldKey === newKey) return; + + const propertyValue = value[oldKey]; + const newData: Record = { ...value }; + delete newData[oldKey]; + newData[newKey] = propertyValue; + onChange(newData); + }; + + const hasEmptyKeys = Object.keys(value).some((key) => key.trim() === ""); + + const { isInputConnected } = useEdgeStore(); + + return ( +
+ {Object.entries(value).map(([key, propertyValue], idx) => { + const dynamicHandleId = generateHandleId( + fieldKey, + [key], + HandleIdType.KEY_VALUE, + ); + const isDynamicPropertyConnected = isInputConnected( + nodeId, + dynamicHandleId, + ); + + console.log("dynamicHandleId", dynamicHandleId); + console.log("key", key); + console.log("fieldKey", fieldKey); + + return ( +
+
+ + + + #{key.trim() === "" ? "" : key} + + + (string) + +
+ {!isDynamicPropertyConnected && ( +
+ updateKey(key, e.target.value)} + placeholder="Key" + wrapperClassName="mb-0" + disabled={disabled} + /> + setProperty(key, e.target.value)} + placeholder={placeholder} + wrapperClassName="mb-0" + disabled={disabled} + /> + +
+ )} +
+ ); + })} + + +
+ ); + }, +); + +ObjectEditor.displayName = "ObjectEditor"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/FORM_CREATOR.md b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/FORM_CREATOR.md new file mode 100644 index 0000000000..0443011196 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/FORM_CREATOR.md @@ -0,0 +1,580 @@ +# Form Creator System + +The Form Creator is a dynamic form generation system built on React JSON Schema Form (RJSF) that automatically creates interactive forms based on JSON schemas. It's the core component that powers the input handling in the FlowEditor. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [How It Works](#how-it-works) +- [Schema Processing](#schema-processing) +- [Widget System](#widget-system) +- [Field System](#field-system) +- [Template System](#template-system) +- [Customization Guide](#customization-guide) +- [Advanced Features](#advanced-features) + +## Architecture Overview + +The Form Creator system consists of several interconnected layers: + +``` +FormCreator +├── Schema Preprocessing +│ └── input-schema-pre-processor.ts +├── Widget System +│ ├── TextInputWidget +│ ├── SelectWidget +│ ├── SwitchWidget +│ └── ... (other widgets) +├── Field System +│ ├── AnyOfField +│ ├── ObjectField +│ └── CredentialsField +├── Template System +│ ├── FieldTemplate +│ └── ArrayFieldTemplate +└── UI Schema + └── uiSchema.ts +``` + +## How It Works + +### 1. **Schema Input** + +The FormCreator receives a JSON schema that defines the structure of the form: + +```typescript +const schema = { + type: "object", + properties: { + message: { + type: "string", + title: "Message", + description: "Enter your message", + }, + count: { + type: "number", + title: "Count", + minimum: 0, + }, + }, +}; +``` + +### 2. **Schema Preprocessing** + +The schema is preprocessed to ensure all properties have proper types: + +```typescript +// Before preprocessing +{ + "properties": { + "name": { "title": "Name" } // No type defined + } +} + +// After preprocessing +// if there is no type - that means it can accept any type +{ + "properties": { + "name": { + "title": "Name", + "anyOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "array", "items": { "type": "string" } }, + { "type": "object" }, + { "type": "null" } + ] + } + } +} +``` + +### 3. **Widget Mapping** + +Schema types are mapped to appropriate input widgets: + +```typescript +// Schema type -> Widget mapping +"string" -> TextInputWidget +"number" -> TextInputWidget (with number type) +"boolean" -> SwitchWidget +"array" -> ArrayFieldTemplate +"object" -> ObjectField +"enum" -> SelectWidget +``` + +### 4. **Form Rendering** + +RJSF renders the form using the mapped widgets and templates: + +```typescript +
+``` + +## Schema Processing + +### Input Schema Preprocessor + +The `preprocessInputSchema` function ensures all properties have proper types: + +```typescript +export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema { + // Recursively processes properties + if (processedSchema.properties) { + for (const [key, property] of Object.entries(processedSchema.properties)) { + // Add type if none exists + if ( + !processedProperty.type && + !processedProperty.anyOf && + !processedProperty.oneOf && + !processedProperty.allOf + ) { + processedProperty.anyOf = [ + { type: "string" }, + { type: "number" }, + { type: "integer" }, + { type: "boolean" }, + { type: "array", items: { type: "string" } }, + { type: "object" }, + { type: "null" }, + ]; + } + } + } +} +``` + +### Key Features + +1. **Type Safety**: Ensures all properties have types +2. **Recursive Processing**: Handles nested objects and arrays +3. **Array Item Processing**: Processes array item schemas +4. **Schema Cleanup**: Removes titles and descriptions from root schema + +## Widget System + +Widgets are the actual input components that users interact with. + +### Available Widgets + +#### TextInputWidget + +Handles text, number, password, and textarea inputs: + +```typescript +export const TextInputWidget = (props: WidgetProps) => { + const { schema } = props; + const mapped = mapJsonSchemaTypeToInputType(schema); + + const inputConfig = { + [InputType.TEXT_AREA]: { + htmlType: "textarea", + placeholder: "Enter text...", + handleChange: (v: string) => (v === "" ? undefined : v), + }, + [InputType.PASSWORD]: { + htmlType: "password", + placeholder: "Enter secret text...", + handleChange: (v: string) => (v === "" ? undefined : v), + }, + [InputType.NUMBER]: { + htmlType: "number", + placeholder: "Enter number value...", + handleChange: (v: string) => (v === "" ? undefined : Number(v)), + } + }; + + return ; +}; +``` + +#### SelectWidget + +Handles dropdown and multi-select inputs: + +```typescript +export const SelectWidget = (props: WidgetProps) => { + const { options, value, onChange, schema } = props; + const enumOptions = options.enumOptions || []; + const type = mapJsonSchemaTypeToInputType(schema); + + if (type === InputType.MULTI_SELECT) { + return ; + } + + return + {renderInput(currentTypeOption)} + + ); +}; +``` + +### ObjectField + +Handles free-form object editing: + +```typescript +export const ObjectField = (props: FieldProps) => { + const { schema, formData = {}, onChange, name, idSchema, formContext } = props; + + // Use default field for fixed-schema objects + if (idSchema?.$id === "root" || !isFreeForm) { + return ; + } + + // Use custom ObjectEditor for free-form objects + return ( + + ); +}; +``` + +### Field Registration + +Fields are registered in the fields registry: + +```typescript +export const fields: RegistryFieldsType = { + AnyOfField: AnyOfField, + credentials: CredentialsField, + ObjectField: ObjectField, +}; +``` + +## Template System + +Templates provide custom rendering for form structure elements. + +### FieldTemplate + +Custom field wrapper with connection handles: + +```typescript +const FieldTemplate: React.FC = ({ + id, label, required, description, children, schema, formContext, uiSchema +}) => { + const { isInputConnected } = useEdgeStore(); + const { nodeId } = formContext; + + const fieldKey = generateHandleId(id); + const isConnected = isInputConnected(nodeId, fieldKey); + + return ( +
+ {label && schema.type && ( + + )} + {!isConnected &&
{children}
} +
+ ); +}; +``` + +### ArrayFieldTemplate + +Custom array editing interface: + +```typescript +function ArrayFieldTemplate(props: ArrayFieldTemplateProps) { + const { items, canAdd, onAddClick, disabled, readonly, formContext, idSchema } = props; + const { nodeId } = formContext; + + return ( + + ); +} +``` + +## Customization Guide + +### Adding a Custom Widget + +1. **Create the Widget Component**: + +```typescript +import { WidgetProps } from "@rjsf/utils"; + +export const MyCustomWidget = (props: WidgetProps) => { + const { value, onChange, schema, disabled, readonly } = props; + + return ( +
+ onChange(e.target.value)} + disabled={disabled || readonly} + placeholder={schema.placeholder} + /> +
+ ); +}; +``` + +2. **Register the Widget**: + +```typescript +// In widgets/index.ts +export const widgets: RegistryWidgetsType = { + // ... existing widgets + MyCustomWidget: MyCustomWidget, +}; +``` + +3. **Use in Schema**: + +```typescript +const schema = { + type: "object", + properties: { + myField: { + type: "string", + "ui:widget": "MyCustomWidget", + }, + }, +}; +``` + +### Adding a Custom Field + +1. **Create the Field Component**: + +```typescript +import { FieldProps } from "@rjsf/utils"; + +export const MyCustomField = (props: FieldProps) => { + const { schema, formData, onChange, name, idSchema, formContext } = props; + + return ( +
+ {/* Custom field implementation */} +
+ ); +}; +``` + +2. **Register the Field**: + +```typescript +// In fields/index.ts +export const fields: RegistryFieldsType = { + // ... existing fields + MyCustomField: MyCustomField, +}; +``` + +3. **Use in Schema**: + +```typescript +const schema = { + type: "object", + properties: { + myField: { + type: "string", + "ui:field": "MyCustomField", + }, + }, +}; +``` + +### Customizing Templates + +1. **Create Custom Template**: + +```typescript +const MyCustomFieldTemplate: React.FC = (props) => { + return ( +
+ {/* Custom template implementation */} +
+ ); +}; +``` + +2. **Register Template**: + +```typescript +// In templates/index.ts +export const templates = { + FieldTemplate: MyCustomFieldTemplate, + // ... other templates +}; +``` + +## Advanced Features + +### Connection State Management + +The Form Creator integrates with the edge store to show/hide input fields based on connection state: + +```typescript +const FieldTemplate = ({ id, children, formContext }) => { + const { isInputConnected } = useEdgeStore(); + const { nodeId } = formContext; + + const fieldKey = generateHandleId(id); + const isConnected = isInputConnected(nodeId, fieldKey); + + return ( +
+ + {!isConnected && children} +
+ ); +}; +``` + +### Advanced Mode + +Fields can be hidden/shown based on advanced mode: + +```typescript +const FieldTemplate = ({ schema, formContext }) => { + const { nodeId } = formContext; + const showAdvanced = useNodeStore( + (state) => state.nodeAdvancedStates[nodeId] || false + ); + + if (!showAdvanced && schema.advanced === true) { + return null; + } + + return
{/* field content */}
; +}; +``` + +### Array Item Context + +Array items have special context for connection handling: + +```typescript +const ArrayEditor = ({ items, nodeId }) => { + return ( +
+ {items?.map((element) => { + const fieldKey = generateHandleId(id, [element.index.toString()], HandleIdType.ARRAY); + const isConnected = isInputConnected(nodeId, fieldKey); + + return ( + + {element.children} + + ); + })} +
+ ); +}; +``` + +### Handle ID Generation + +Handle IDs are generated based on field structure: + +```typescript +// Simple field +generateHandleId("message"); // "message" + +// Nested field +generateHandleId("config", ["api_key"]); // "config.api_key" + +// Array item +generateHandleId("items", ["0"]); // "items_$_0" + +// Key-value pair +generateHandleId("headers", ["Authorization"]); // "headers_#_Authorization" +``` diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/README.md b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/README.md new file mode 100644 index 0000000000..0cfbe68723 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/docs/README.md @@ -0,0 +1,159 @@ +# FlowEditor Component + +The FlowEditor is a powerful visual flow builder component built on top of React Flow that allows users to create, connect, and manage nodes in a visual workflow. It provides a comprehensive form system with dynamic input handling, connection management, and advanced features. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Store Management](#store-management) + +## Architecture Overview + +The FlowEditor follows a modular architecture with clear separation of concerns: + +``` +FlowEditor/ +├── Flow.tsx # Main component +├── nodes/ # Node-related components +│ ├── CustomNode.tsx # Main node component +│ ├── FormCreator.tsx # Dynamic form generator +│ ├── fields/ # Custom field components +│ ├── widgets/ # Custom input widgets +│ ├── templates/ # RJSF templates +│ └── helpers.ts # Utility functions +├── edges/ # Edge-related components +│ ├── CustomEdge.tsx # Custom edge component +│ ├── useCustomEdge.ts # Edge management hook +│ └── helpers.ts # Edge utilities +├── handlers/ # Connection handles +│ ├── NodeHandle.tsx # Connection handle component +│ └── helpers.ts # Handle utilities +├── components/ # Shared components +│ ├── ArrayEditor/ # Array editing components +│ └── ObjectEditor/ # Object editing components +└── processors/ # Data processors + └── input-schema-pre-processor.ts +``` + +## Store Management + +The FlowEditor uses Zustand for state management with two main stores: + +### NodeStore (`useNodeStore`) + +Manages all node-related state and operations. + +**Key Features:** + +- Node CRUD operations +- Advanced state management per node +- Form data persistence +- Node counter for unique IDs + +**Usage:** + +```typescript +import { useNodeStore } from "../stores/nodeStore"; + +// Get nodes +const nodes = useNodeStore(useShallow((state) => state.nodes)); + +// Add a new node +const addNode = useNodeStore((state) => state.addNode); + +// Update node data +const updateNodeData = useNodeStore((state) => state.updateNodeData); + +// Toggle advanced mode +const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced); +``` + +**Store Methods:** + +- `setNodes(nodes)` - Replace all nodes +- `addNode(node)` - Add a single node +- `addBlock(blockInfo)` - Add node from block info +- `updateNodeData(nodeId, data)` - Update node data +- `onNodesChange(changes)` - Handle node changes from React Flow +- `setShowAdvanced(nodeId, show)` - Toggle advanced mode +- `incrementNodeCounter()` - Get next node ID + +### EdgeStore (`useEdgeStore`) + +Manages all connection-related state and operations. + +**Key Features:** + +- Connection CRUD operations +- Connection validation +- Backend link conversion +- Connection state queries + +**Usage:** + +```typescript +import { useEdgeStore } from "../stores/edgeStore"; + +// Get connections +const connections = useEdgeStore((state) => state.connections); + +// Add connection +const addConnection = useEdgeStore((state) => state.addConnection); + +// Check if input is connected +const isInputConnected = useEdgeStore((state) => state.isInputConnected); +``` + +**Store Methods:** + +- `setConnections(connections)` - Replace all connections +- `addConnection(conn)` - Add a new connection +- `removeConnection(edgeId)` - Remove connection by ID +- `upsertMany(conns)` - Bulk update connections +- `isInputConnected(nodeId, handle)` - Check input connection +- `isOutputConnected(nodeId, handle)` - Check output connection +- `getNodeConnections(nodeId)` - Get all connections for a node +- `getBackendLinks()` - Convert to backend format + +## Form Creator System + +The FormCreator is a dynamic form generator built on React JSON Schema Form (RJSF) that automatically creates forms based on JSON schemas. + +### How It Works + +1. **Schema Processing**: Input schemas are preprocessed to ensure all properties have types +2. **Widget Mapping**: Schema types are mapped to appropriate input widgets +3. **Field Rendering**: Custom fields handle complex data structures +4. **State Management**: Form data is automatically synced with the node store + +### Key Components + +#### FormCreator + +```typescript + +``` + +#### Custom Widgets + +- `TextInputWidget` - Text, number, password inputs +- `SelectWidget` - Dropdown and multi-select +- `SwitchWidget` - Boolean toggles +- `FileWidget` - File upload +- `DateInputWidget` - Date picker +- `TimeInputWidget` - Time picker +- `DateTimeInputWidget` - DateTime picker + +#### Custom Fields + +- `AnyOfField` - Union type handling +- `ObjectField` - Free-form object editing +- `CredentialsField` - API credential management + +#### Templates + +- `FieldTemplate` - Custom field wrapper with handles +- `ArrayFieldTemplate` - Array editing interface diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx new file mode 100644 index 0000000000..92b0ef9bbe --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx @@ -0,0 +1,59 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, +} from "@xyflow/react"; + +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; +import { XIcon } from "@phosphor-icons/react"; + +const CustomEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + selected, +}: EdgeProps) => { + const removeConnection = useEdgeStore((state) => state.removeConnection); + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + return ( + <> + + + + + + ); +}; + +export default CustomEdge; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/helpers.ts new file mode 100644 index 0000000000..45d9831a45 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/helpers.ts @@ -0,0 +1,12 @@ +import { Link } from "@/app/api/__generated__/models/link"; +import { Connection } from "@xyflow/react"; + +export const convertConnectionsToBackendLinks = ( + connections: Connection[], +): Link[] => + connections.map((c) => ({ + source_id: c.source || "", + sink_id: c.target || "", + source_name: c.sourceHandle || "", + sink_name: c.targetHandle || "", + })); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts new file mode 100644 index 0000000000..53fd464f7c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts @@ -0,0 +1,70 @@ +import { + Connection as RFConnection, + Edge as RFEdge, + MarkerType, + EdgeChange, +} from "@xyflow/react"; +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; +import { useCallback, useMemo } from "react"; + +export const useCustomEdge = () => { + const connections = useEdgeStore((s) => s.connections); + const addConnection = useEdgeStore((s) => s.addConnection); + const removeConnection = useEdgeStore((s) => s.removeConnection); + + const edges: RFEdge[] = useMemo( + () => + connections.map((c) => ({ + id: c.edge_id, + type: "custom", + source: c.source, + target: c.target, + sourceHandle: c.sourceHandle, + targetHandle: c.targetHandle, + markerEnd: { + type: MarkerType.ArrowClosed, + strokeWidth: 2, + color: "#555", + }, + })), + [connections], + ); + + const onConnect = useCallback( + (conn: RFConnection) => { + if ( + !conn.source || + !conn.target || + !conn.sourceHandle || + !conn.targetHandle + ) + return; + const exists = connections.some( + (c) => + c.source === conn.source && + c.target === conn.target && + c.sourceHandle === conn.sourceHandle && + c.targetHandle === conn.targetHandle, + ); + if (exists) return; + addConnection({ + source: conn.source, + target: conn.target, + sourceHandle: conn.sourceHandle, + targetHandle: conn.targetHandle, + }); + }, + [connections, addConnection], + ); + + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => { + changes.forEach((ch) => { + if (ch.type === "remove") removeConnection(ch.id); + }); + }, + [removeConnection], + ); + + return { edges, onConnect, onEdgesChange }; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx new file mode 100644 index 0000000000..b0d72b4a62 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx @@ -0,0 +1,32 @@ +import { CircleIcon } from "@phosphor-icons/react"; +import { Handle, Position } from "@xyflow/react"; + +const NodeHandle = ({ + id, + isConnected, + side, +}: { + id: string; + isConnected: boolean; + side: "left" | "right"; +}) => { + console.log("id", id); + return ( + +
+ +
+
+ ); +}; + +export default NodeHandle; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts new file mode 100644 index 0000000000..17b31b1b80 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts @@ -0,0 +1,117 @@ +/** + * Handle ID Types for different input structures + * + * Examples: + * SIMPLE: "message" + * NESTED: "config.api_key" + * ARRAY: "items_$_0", "items_$_1" + * KEY_VALUE: "headers_#_Authorization", "params_#_limit" + * + * Note: All handle IDs are sanitized to remove spaces and special characters. + * Spaces become underscores, and special characters are removed. + * Example: "user name" becomes "user_name", "email@domain.com" becomes "emaildomaincom" + */ +export enum HandleIdType { + SIMPLE = "SIMPLE", + NESTED = "NESTED", + ARRAY = "ARRAY", + KEY_VALUE = "KEY_VALUE", +} + +const fromRjsfId = (id: string): string => { + if (!id) return ""; + const parts = id.split("_"); + const filtered = parts.filter( + (p) => p !== "root" && p !== "properties" && p.length > 0, + ); + return filtered.join("_") || ""; +}; + +const sanitizeForHandleId = (str: string): string => { + if (!str) return ""; + + return str + .trim() + .replace(/\s+/g, "_") // Replace spaces with underscores + .replace(/[^a-zA-Z0-9_-]/g, "") // Remove special characters except underscores and hyphens + .replace(/_+/g, "_") // Replace multiple consecutive underscores with single underscore + .replace(/^_|_$/g, ""); // Remove leading/trailing underscores +}; + +export const generateHandleId = ( + mainKey: string, + nestedValues: string[] = [], + type: HandleIdType = HandleIdType.SIMPLE, +): string => { + if (!mainKey) return ""; + + mainKey = fromRjsfId(mainKey); + mainKey = sanitizeForHandleId(mainKey); + + if (type === HandleIdType.SIMPLE || nestedValues.length === 0) { + return mainKey; + } + + const sanitizedNestedValues = nestedValues.map((value) => + sanitizeForHandleId(value), + ); + + switch (type) { + case HandleIdType.NESTED: + return [mainKey, ...sanitizedNestedValues].join("."); + + case HandleIdType.ARRAY: + return [mainKey, ...sanitizedNestedValues].join("_$_"); + + case HandleIdType.KEY_VALUE: + return [mainKey, ...sanitizedNestedValues].join("_#_"); + + default: + return mainKey; + } +}; + +export const parseHandleId = ( + handleId: string, +): { + mainKey: string; + nestedValues: string[]; + type: HandleIdType; +} => { + if (!handleId) { + return { mainKey: "", nestedValues: [], type: HandleIdType.SIMPLE }; + } + + if (handleId.includes("_#_")) { + const parts = handleId.split("_#_"); + return { + mainKey: parts[0], + nestedValues: parts.slice(1), + type: HandleIdType.KEY_VALUE, + }; + } + + if (handleId.includes("_$_")) { + const parts = handleId.split("_$_"); + return { + mainKey: parts[0], + nestedValues: parts.slice(1), + type: HandleIdType.ARRAY, + }; + } + + if (handleId.includes(".")) { + const parts = handleId.split("."); + return { + mainKey: parts[0], + nestedValues: parts.slice(1), + type: HandleIdType.NESTED, + }; + } + + return { + mainKey: handleId, + nestedValues: [], + type: HandleIdType.SIMPLE, + }; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode.tsx new file mode 100644 index 0000000000..aa8e85bf84 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Node as XYNode, NodeProps } from "@xyflow/react"; +import { FormCreator } from "./FormCreator"; +import { RJSFSchema } from "@rjsf/utils"; +import { Text } from "@/components/atoms/Text/Text"; + +import { Switch } from "@/components/atoms/Switch/Switch"; +import { preprocessInputSchema } from "../processors/input-schema-pre-processor"; +import { OutputHandler } from "./OutputHandler"; +import { useNodeStore } from "../../../stores/nodeStore"; + +export type CustomNodeData = { + hardcodedValues: { + [key: string]: any; + }; + title: string; + description: string; + inputSchema: RJSFSchema; + outputSchema: RJSFSchema; +}; + +export type CustomNode = XYNode; + +export const CustomNode: React.FC> = React.memo( + ({ data, id }) => { + const showAdvanced = useNodeStore( + (state) => state.nodeAdvancedStates[id] || false, + ); + const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced); + + return ( +
+ {/* Header */} +
+ + {data.title} #{id} + +
+ + {/* Input Handles */} +
+ +
+ + {/* Advanced Button */} +
+ + Advanced + + setShowAdvanced(id, checked)} + checked={showAdvanced} + /> +
+ + {/* Output Handles */} + +
+ ); + }, +); + +CustomNode.displayName = "CustomNode"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx new file mode 100644 index 0000000000..6eb6ae0333 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx @@ -0,0 +1,33 @@ +import Form from "@rjsf/core"; +import validator from "@rjsf/validator-ajv8"; +import { RJSFSchema } from "@rjsf/utils"; +import React from "react"; +import { widgets } from "./widgets"; +import { fields } from "./fields"; +import { templates } from "./templates"; +import { uiSchema } from "./uiSchema"; +import { useNodeStore } from "../../../stores/nodeStore"; + +export const FormCreator = React.memo( + ({ jsonSchema, nodeId }: { jsonSchema: RJSFSchema; nodeId: string }) => { + const updateNodeData = useNodeStore((state) => state.updateNodeData); + const handleChange = ({ formData }: any) => { + updateNodeData(nodeId, { hardcodedValues: formData }); + }; + + return ( + + ); + }, +); + +FormCreator.displayName = "FormCreator"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/OutputHandler.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/OutputHandler.tsx new file mode 100644 index 0000000000..723a2b641c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/OutputHandler.tsx @@ -0,0 +1,90 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react"; +import { RJSFSchema } from "@rjsf/utils"; +import { useState } from "react"; + +import NodeHandle from "../handlers/NodeHandle"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/atoms/Tooltip/BaseTooltip"; +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; +import { getTypeDisplayInfo } from "./helpers"; + +export const OutputHandler = ({ + outputSchema, + nodeId, +}: { + outputSchema: RJSFSchema; + nodeId: string; +}) => { + const { isOutputConnected } = useEdgeStore(); + const properties = outputSchema?.properties || {}; + const [isOutputVisible, setIsOutputVisible] = useState(false); + + return ( +
+ + + { +
+ {Object.entries(properties).map(([key, property]: [string, any]) => { + const isConnected = isOutputConnected(nodeId, key); + const shouldShow = isConnected || isOutputVisible; + const { displayType, colorClass } = getTypeDisplayInfo(property); + + return shouldShow ? ( +
+ + {property?.description && ( + + + + + + + + {property?.description} + + + )} + {property?.title || key}{" "} + + ({displayType}) + + + +
+ ) : null; + })} +
+ } +
+ ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/AnyOfField/AnyOfField.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/AnyOfField/AnyOfField.tsx new file mode 100644 index 0000000000..edbf8a3af2 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/AnyOfField/AnyOfField.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { FieldProps, RJSFSchema } from "@rjsf/utils"; + +import { Text } from "@/components/atoms/Text/Text"; +import { Switch } from "@/components/atoms/Switch/Switch"; +import { Select } from "@/components/atoms/Select/Select"; +import { InputType, mapJsonSchemaTypeToInputType } from "../../helpers"; + +import { InfoIcon } from "@phosphor-icons/react"; +import { useAnyOfField } from "./useAnyOfField"; +import NodeHandle from "../../../handlers/NodeHandle"; +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; +import { generateHandleId } from "../../../handlers/helpers"; +import { getTypeDisplayInfo } from "../../helpers"; +import merge from "lodash/merge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/atoms/Tooltip/BaseTooltip"; + +type TypeOption = { + type: string; + title: string; + index: number; + format?: string; + enum?: any[]; + secret?: boolean; + schema: RJSFSchema; +}; + +export const AnyOfField = ({ + schema, + formData, + onChange, + name, + idSchema, + formContext, + registry, + uiSchema, + disabled, + onBlur, + onFocus, +}: FieldProps) => { + const fieldKey = generateHandleId(idSchema.$id ?? ""); + const updatedFormContexrt = { ...formContext, fromAnyOf: true }; + + const { nodeId } = updatedFormContexrt; + const { isInputConnected } = useEdgeStore(); + const isConnected = isInputConnected(nodeId, fieldKey); + const { + isNullableType, + nonNull, + selectedType, + handleTypeChange, + handleNullableToggle, + handleValueChange, + currentTypeOption, + isEnabled, + typeOptions, + } = useAnyOfField(schema, formData, onChange); + + const renderInput = (typeOption: TypeOption) => { + const optionSchema = (typeOption.schema || { + type: typeOption.type, + format: typeOption.format, + secret: typeOption.secret, + enum: typeOption.enum, + }) as RJSFSchema; + const inputType = mapJsonSchemaTypeToInputType(optionSchema); + + // Help us to tell the field under the anyOf field that you are a part of anyOf field. + // We can't use formContext in this case that's why we are using this. + // We could use context api here, but i think it's better to keep it simple. + const uiSchemaFromAnyOf = merge({}, uiSchema, { + "ui:options": { fromAnyOf: true }, + }); + + // We are using SchemaField to render the field recursively. + if (inputType === InputType.ARRAY_EDITOR) { + const SchemaField = registry.fields.SchemaField; + return ( +
+ +
+ ); + } + + const SchemaField = registry.fields.SchemaField; + return ( +
+ +
+ ); + }; + + // I am doing this, because we need different UI for optional types. + if (isNullableType && nonNull) { + const { displayType, colorClass } = getTypeDisplayInfo(nonNull); + + return ( +
+
+
+ + + {name.charAt(0).toUpperCase() + name.slice(1)} + + + ({displayType} | null) + +
+ {!isConnected && ( + + )} +
+ {!isConnected && isEnabled && renderInput(nonNull)} +
+ ); + } + + return ( +
+
+ + + {name.charAt(0).toUpperCase() + name.slice(1)} + + {!isConnected && ( + setField("id", e.target.value)} + placeholder="Enter your API Key" + required + size="small" + wrapperClassName="mb-0" + /> +
+ ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/ObjectField.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/ObjectField.tsx new file mode 100644 index 0000000000..3557ee5365 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/ObjectField.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { FieldProps } from "@rjsf/utils"; +import { getDefaultRegistry } from "@rjsf/core"; +import { generateHandleId } from "../../handlers/helpers"; +import { ObjectEditor } from "../../components/ObjectEditor/ObjectEditor"; + +export const ObjectField = (props: FieldProps) => { + const { + schema, + formData = {}, + onChange, + name, + idSchema, + formContext, + } = props; + const DefaultObjectField = getDefaultRegistry().fields.ObjectField; + + // Let the default field render for root or fixed-schema objects + const isFreeForm = + !schema.properties || + Object.keys(schema.properties).length === 0 || + schema.additionalProperties === true; + + if (idSchema?.$id === "root" || !isFreeForm) { + return ; + } + + const fieldKey = generateHandleId(idSchema.$id ?? ""); + const { nodeId } = formContext; + + return ( + + ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/index.ts new file mode 100644 index 0000000000..bdf20788fb --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/fields/index.ts @@ -0,0 +1,10 @@ +import { RegistryFieldsType } from "@rjsf/utils"; +import { CredentialsField } from "./CredentialField"; +import { AnyOfField } from "./AnyOfField/AnyOfField"; +import { ObjectField } from "./ObjectField"; + +export const fields: RegistryFieldsType = { + AnyOfField: AnyOfField, + credentials: CredentialsField, + ObjectField: ObjectField, +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts new file mode 100644 index 0000000000..984357ed31 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts @@ -0,0 +1,141 @@ +import { RJSFSchema } from "@rjsf/utils"; + +export enum InputType { + SINGLE_LINE_TEXT = "single-line-text", + TEXT_AREA = "text-area", + PASSWORD = "password", + FILE = "file", + DATE = "date", + TIME = "time", + DATE_TIME = "datetime", + NUMBER = "number", + INTEGER = "integer", + SWITCH = "switch", + ARRAY_EDITOR = "array-editor", + SELECT = "select", + MULTI_SELECT = "multi-select", + OBJECT_EDITOR = "object-editor", + ENUM = "enum", +} + +// This helper function maps a JSONSchema type to an InputType [help us to determine the type of the input] +export function mapJsonSchemaTypeToInputType( + schema: RJSFSchema, +): InputType | undefined { + if (schema.type === "string") { + if (schema.secret) return InputType.PASSWORD; + if (schema.format === "date") return InputType.DATE; + if (schema.format === "time") return InputType.TIME; + if (schema.format === "date-time") return InputType.DATE_TIME; + if (schema.format === "long-text") return InputType.TEXT_AREA; + if (schema.format === "short-text") return InputType.SINGLE_LINE_TEXT; + if (schema.format === "file") return InputType.FILE; + return InputType.SINGLE_LINE_TEXT; + } + + if (schema.type === "number") return InputType.NUMBER; + if (schema.type === "integer") return InputType.INTEGER; + if (schema.type === "boolean") return InputType.SWITCH; + + if (schema.type === "array") { + if ( + schema.items && + typeof schema.items === "object" && + !Array.isArray(schema.items) && + schema.items.enum + ) { + return InputType.MULTI_SELECT; + } + console.log("schema", schema); + return InputType.ARRAY_EDITOR; + } + + if (schema.type === "object") { + return InputType.OBJECT_EDITOR; + } + + if (schema.enum) { + return InputType.SELECT; + } + + if (schema.type === "null") return; + + if (schema.anyOf || schema.oneOf) { + return undefined; + } + + return InputType.SINGLE_LINE_TEXT; +} + +// Helper to extract options from schema +export function extractOptions( + schema: any, +): { value: string; label: string }[] { + if (schema.enum) { + return schema.enum.map((value: any) => ({ + value: String(value), + label: String(value), + })); + } + + if (schema.type === "array" && schema.items?.enum) { + return schema.items.enum.map((value: any) => ({ + value: String(value), + label: String(value), + })); + } + + return []; +} + +// get display type and color for schema types [need for type display next to field name] +export const getTypeDisplayInfo = (schema: any) => { + if (schema?.type === "string" && schema?.format) { + const formatMap: Record< + string, + { displayType: string; colorClass: string } + > = { + file: { displayType: "file", colorClass: "!text-green-500" }, + date: { displayType: "date", colorClass: "!text-blue-500" }, + time: { displayType: "time", colorClass: "!text-blue-500" }, + "date-time": { displayType: "datetime", colorClass: "!text-blue-500" }, + "long-text": { displayType: "text", colorClass: "!text-green-500" }, + "short-text": { displayType: "text", colorClass: "!text-green-500" }, + }; + + const formatInfo = formatMap[schema.format]; + if (formatInfo) { + return formatInfo; + } + } + + const typeMap: Record = { + string: "text", + number: "number", + integer: "integer", + boolean: "true/false", + object: "object", + array: "list", + null: "null", + }; + + const displayType = typeMap[schema?.type] || schema?.type || "any"; + + const colorMap: Record = { + string: "!text-green-500", + number: "!text-blue-500", + integer: "!text-blue-500", + boolean: "!text-yellow-500", + object: "!text-purple-500", + array: "!text-indigo-500", + null: "!text-gray-500", + any: "!text-gray-500", + }; + + const colorClass = colorMap[schema?.type] || "!text-gray-500"; + + return { + displayType, + colorClass, + }; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/ArrayFieldTemplate.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/ArrayFieldTemplate.tsx new file mode 100644 index 0000000000..1b28767f25 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/ArrayFieldTemplate.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { ArrayFieldTemplateProps } from "@rjsf/utils"; +import { ArrayEditor } from "../../components/ArrayEditor/ArrayEditor"; + +function ArrayFieldTemplate(props: ArrayFieldTemplateProps) { + const { + items, + canAdd, + onAddClick, + disabled, + readonly, + formContext, + idSchema, + } = props; + const { nodeId } = formContext; + + return ( + + ); +} + +export default ArrayFieldTemplate; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/FieldTemplate.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/FieldTemplate.tsx new file mode 100644 index 0000000000..7846cf8ec1 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/FieldTemplate.tsx @@ -0,0 +1,103 @@ +import React, { useContext } from "react"; +import { FieldTemplateProps } from "@rjsf/utils"; +import { InfoIcon } from "@phosphor-icons/react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/atoms/Tooltip/BaseTooltip"; +import { Text } from "@/components/atoms/Text/Text"; + +import NodeHandle from "../../handlers/NodeHandle"; +import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; +import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; +import { generateHandleId } from "../../handlers/helpers"; +import { getTypeDisplayInfo } from "../helpers"; +import { ArrayEditorContext } from "../../components/ArrayEditor/ArrayEditorContext"; + +const FieldTemplate: React.FC = ({ + id, + label, + required, + description, + children, + schema, + formContext, + uiSchema, +}) => { + const { isInputConnected } = useEdgeStore(); + const { nodeId } = formContext; + + const showAdvanced = useNodeStore( + (state) => state.nodeAdvancedStates[nodeId] ?? false, + ); + + const { + isArrayItem, + fieldKey: arrayFieldKey, + isConnected: isArrayItemConnected, + } = useContext(ArrayEditorContext); + + let fieldKey = generateHandleId(id); + let isConnected = isInputConnected(nodeId, fieldKey); + if (isArrayItem) { + fieldKey = arrayFieldKey; + isConnected = isArrayItemConnected; + } + const isAnyOf = Array.isArray((schema as any)?.anyOf); + const isOneOf = Array.isArray((schema as any)?.oneOf); + const suppressHandle = isAnyOf || isOneOf; + + if (!showAdvanced && schema.advanced === true && !isConnected) { + return null; + } + + const fromAnyOf = + Boolean((uiSchema as any)?.["ui:options"]?.fromAnyOf) || + Boolean((formContext as any)?.fromAnyOf); + + const { displayType, colorClass } = getTypeDisplayInfo(schema); + + return ( +
+ {label && schema.type && ( + + )} + {(isAnyOf || !isConnected) &&
{children}
}{" "} +
+ ); +}; + +export default FieldTemplate; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/index.ts new file mode 100644 index 0000000000..203526cd75 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/templates/index.ts @@ -0,0 +1,10 @@ +import ArrayFieldTemplate from "./ArrayFieldTemplate"; +import FieldTemplate from "./FieldTemplate"; + +const NoSubmitButton = () => null; + +export const templates = { + FieldTemplate, + ButtonTemplates: { SubmitButton: NoSubmitButton }, + ArrayFieldTemplate, +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts new file mode 100644 index 0000000000..ad1fab7c95 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts @@ -0,0 +1,12 @@ +export const uiSchema = { + credentials: { + "ui:field": "credentials", + provider: { "ui:widget": "hidden" }, + type: { "ui:widget": "hidden" }, + id: { "ui:autofocus": true }, + title: { "ui:placeholder": "Optional title" }, + }, + properties: { + "ui:field": "CustomObjectField", + }, +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateInputWidget.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateInputWidget.tsx new file mode 100644 index 0000000000..c83cc2744c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateInputWidget.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { WidgetProps } from "@rjsf/utils"; +import { DateInput } from "@/components/atoms/DateInput/DateInput"; + +export const DateInputWidget = (props: WidgetProps) => { + const { value, onChange, disabled, readonly, placeholder, autofocus, id } = + props; + + return ( + + ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateTimeInputWidget.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateTimeInputWidget.tsx new file mode 100644 index 0000000000..65821bfdff --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/DateTimeInputWidget.tsx @@ -0,0 +1,21 @@ +import { WidgetProps } from "@rjsf/utils"; +import { DateTimeInput } from "@/components/atoms/DateTimeInput/DateTimeInput"; + +export const DateTimeInputWidget = (props: WidgetProps) => { + const { value, onChange, disabled, readonly, placeholder, autofocus, id } = + props; + return ( + + ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/FileWidget.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/FileWidget.tsx new file mode 100644 index 0000000000..e15d34a9ba --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/FileWidget.tsx @@ -0,0 +1,33 @@ +import { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/__legacy__/ui/input"; + +export const FileWidget = (props: WidgetProps) => { + const { onChange, multiple = false, disabled, readonly, id } = props; + + // TODO: It's temporary solution for file input, will complete it follow up prs + const handleChange = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) { + onChange(undefined); + return; + } + + const file = files[0]; + const reader = new FileReader(); + reader.onload = (e) => { + onChange(e.target?.result); + }; + reader.readAsDataURL(file); + }; + + return ( + + ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/SelectWidget.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/SelectWidget.tsx new file mode 100644 index 0000000000..1c5d54930e --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/SelectWidget.tsx @@ -0,0 +1,62 @@ +import { WidgetProps } from "@rjsf/utils"; +import { InputType, mapJsonSchemaTypeToInputType } from "../helpers"; +import { Select } from "@/components/atoms/Select/Select"; +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from "@/components/__legacy__/ui/multiselect"; + +export const SelectWidget = (props: WidgetProps) => { + const { options, value, onChange, disabled, readonly, id } = props; + const enumOptions = options.enumOptions || []; + const type = mapJsonSchemaTypeToInputType(props.schema); + + const renderInput = () => { + if (type === InputType.MULTI_SELECT) { + return ( + + + + + + + {enumOptions?.map((option) => ( + + {option.label} + + ))} + + + + ); + } + return ( + + ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/TimeInputWidget.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/TimeInputWidget.tsx new file mode 100644 index 0000000000..3279b1eec6 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/TimeInputWidget.tsx @@ -0,0 +1,20 @@ +import { WidgetProps } from "@rjsf/utils"; +import { TimeInput } from "@/components/atoms/TimeInput/TimeInput"; + +export const TimeInputWidget = (props: WidgetProps) => { + const { value, onChange, disabled, readonly, placeholder, id } = props; + return ( + + ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/index.ts new file mode 100644 index 0000000000..cdb02c728c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/widgets/index.ts @@ -0,0 +1,18 @@ +import { RegistryWidgetsType } from "@rjsf/utils"; +import { SelectWidget } from "./SelectWidget"; +import { TextInputWidget } from "./TextInputWidget"; +import { SwitchWidget } from "./SwitchWidget"; +import { FileWidget } from "./FileWidget"; +import { DateInputWidget } from "./DateInputWidget"; +import { TimeInputWidget } from "./TimeInputWidget"; +import { DateTimeInputWidget } from "./DateTimeInputWidget"; + +export const widgets: RegistryWidgetsType = { + TextWidget: TextInputWidget, + SelectWidget: SelectWidget, + CheckboxWidget: SwitchWidget, + FileWidget: FileWidget, + DateWidget: DateInputWidget, + TimeWidget: TimeInputWidget, + DateTimeWidget: DateTimeInputWidget, +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/processors/input-schema-pre-processor.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/processors/input-schema-pre-processor.ts new file mode 100644 index 0000000000..ffbcbf52b2 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/processors/input-schema-pre-processor.ts @@ -0,0 +1,112 @@ +import { RJSFSchema } from "@rjsf/utils"; + +/** + * Pre-processes the input schema to ensure all properties have a type defined. + * If a property doesn't have a type, it assigns a union of all supported JSON Schema types. + */ +export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema { + if (!schema || typeof schema !== "object") { + return schema; + } + + const processedSchema = { ...schema }; + + // Recursively process properties + if (processedSchema.properties) { + processedSchema.properties = { ...processedSchema.properties }; + + for (const [key, property] of Object.entries(processedSchema.properties)) { + if (property && typeof property === "object") { + const processedProperty = { ...property }; + + // Only add type if no type is defined AND no anyOf/oneOf/allOf is present + if ( + !processedProperty.type && + !processedProperty.anyOf && + !processedProperty.oneOf && + !processedProperty.allOf + ) { + processedProperty.anyOf = [ + { type: "string" }, + { type: "number" }, + { type: "integer" }, + { type: "boolean" }, + { type: "array", items: { type: "string" } }, + { type: "object" }, + { type: "null" }, + ]; + } + + // when encountering an array with items missing type + if (processedProperty.type === "array" && processedProperty.items) { + const items = processedProperty.items as RJSFSchema; + if (!items.type && !items.anyOf && !items.oneOf && !items.allOf) { + processedProperty.items = { + type: "string", + title: items.title ?? "", + }; + } else { + processedProperty.items = preprocessInputSchema(items); + } + } + + // Recursively process nested objects + if ( + processedProperty.type === "object" || + (Array.isArray(processedProperty.type) && + processedProperty.type.includes("object")) + ) { + processedProperty.properties = processProperties( + processedProperty.properties, + ); + } + + // Process array items + if ( + processedProperty.type === "array" || + (Array.isArray(processedProperty.type) && + processedProperty.type.includes("array")) + ) { + if (processedProperty.items) { + processedProperty.items = preprocessInputSchema( + processedProperty.items as RJSFSchema, + ); + } + } + + processedSchema.properties[key] = processedProperty; + } + } + } + + // Process array items at root level + if (processedSchema.items) { + processedSchema.items = preprocessInputSchema( + processedSchema.items as RJSFSchema, + ); + } + + processedSchema.title = ""; // Otherwise our form creator will show the title of the schema in the input field + processedSchema.description = ""; // Otherwise our form creator will show the description of the schema in the input field + + return processedSchema; +} + +/** + * Helper function to process properties object + */ +function processProperties(properties: any): any { + if (!properties || typeof properties !== "object") { + return properties; + } + + const processedProperties = { ...properties }; + + for (const [key, property] of Object.entries(processedProperties)) { + if (property && typeof property === "object") { + processedProperties[key] = preprocessInputSchema(property as RJSFSchema); + } + } + + return processedProperties; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/AllBlocksContent/AllBlocksContent.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/AllBlocksContent/AllBlocksContent.tsx index b6055d7498..5b4dcb2679 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/AllBlocksContent/AllBlocksContent.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/AllBlocksContent/AllBlocksContent.tsx @@ -6,6 +6,7 @@ import { beautifyString } from "@/lib/utils"; import { useAllBlockContent } from "./useAllBlockContent"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { blockMenuContainerStyle } from "../style"; +import { useNodeStore } from "../../../stores/nodeStore"; export const AllBlocksContent = () => { const { @@ -18,6 +19,8 @@ export const AllBlocksContent = () => { isErrorOnLoadingMore, } = useAllBlockContent(); + const addBlock = useNodeStore((state) => state.addBlock); + if (isLoading) { return (
@@ -71,6 +74,7 @@ export const AllBlocksContent = () => { key={`${category.name}-${block.id}`} title={block.name as string} description={block.name as string} + onClick={() => addBlock(block)} /> ))} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockList/BlockList.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockList/BlockList.tsx index 12ad298d6c..ea25af0da1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockList/BlockList.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockList/BlockList.tsx @@ -1,18 +1,11 @@ import React from "react"; import { Block } from "../Block"; import { blockMenuContainerStyle } from "../style"; - -export interface BlockType { - id: string; - name: string; - description: string; - category?: string; - type?: string; - provider?: string; -} +import { useNodeStore } from "../../../stores/nodeStore"; +import { BlockInfo } from "@/app/api/__generated__/models/blockInfo"; interface BlocksListProps { - blocks: BlockType[]; + blocks: BlockInfo[]; loading?: boolean; } @@ -20,6 +13,7 @@ export const BlocksList: React.FC = ({ blocks, loading = false, }) => { + const { addBlock } = useNodeStore(); if (loading) { return (
@@ -30,6 +24,11 @@ export const BlocksList: React.FC = ({ ); } return blocks.map((block) => ( - + addBlock(block)} + /> )); }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockMenu/BlockMenu.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockMenu/BlockMenu.tsx index 4de2e3f806..92a96d254f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockMenu/BlockMenu.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/BlockMenu/BlockMenu.tsx @@ -11,7 +11,7 @@ import { BlockMenuStateProvider } from "../block-menu-provider"; import { LegoIcon } from "@phosphor-icons/react"; interface BlockMenuProps { - pinBlocksPopover: boolean; + // pinBlocksPopover: boolean; blockMenuSelected: "save" | "block" | "search" | ""; setBlockMenuSelected: React.Dispatch< React.SetStateAction<"" | "save" | "block" | "search"> @@ -19,16 +19,17 @@ interface BlockMenuProps { } export const BlockMenu: React.FC = ({ - pinBlocksPopover, + // pinBlocksPopover, blockMenuSelected, setBlockMenuSelected, }) => { - const { open, onOpen } = useBlockMenu({ - pinBlocksPopover, + const { open: _open, onOpen } = useBlockMenu({ + // pinBlocksPopover, setBlockMenuSelected, }); return ( - + // pinBlocksPopover ? true : open + >; } export const useBlockMenu = ({ - pinBlocksPopover, + // pinBlocksPopover, setBlockMenuSelected, }: useBlockMenuProps) => { const [open, setOpen] = useState(false); const onOpen = (newOpen: boolean) => { - if (!pinBlocksPopover) { - setOpen(newOpen); - setBlockMenuSelected(newOpen ? "block" : ""); - } + // if (!pinBlocksPopover) { + setOpen(newOpen); + setBlockMenuSelected(newOpen ? "block" : ""); + // } }; return { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx index 54fd3f9769..3df64b0d68 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/IntegrationBlocks/IntegrationBlocks.tsx @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton"; import { useIntegrationBlocks } from "./useIntegrationBlocks"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll"; +import { useNodeStore } from "../../../stores/nodeStore"; export const IntegrationBlocks = () => { const { integration, setIntegration } = useBlockMenuContext(); @@ -20,6 +21,7 @@ export const IntegrationBlocks = () => { error, refetch, } = useIntegrationBlocks(); + const addBlock = useNodeStore((state) => state.addBlock); if (blocksLoading) { return ( @@ -92,6 +94,7 @@ export const IntegrationBlocks = () => { title={block.name} description={block.description} icon_url={`/integrations/${integration}.png`} + onClick={() => addBlock(block)} /> ))}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/NewControlPanel.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/NewControlPanel.tsx index 1ef85a1d20..5dbbacc7b3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/NewControlPanel.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/NewControlPanel.tsx @@ -1,15 +1,15 @@ -import { Separator } from "@/components/__legacy__/ui/separator"; +// import { Separator } from "@/components/__legacy__/ui/separator"; import { cn } from "@/lib/utils"; import React, { useMemo } from "react"; import { BlockMenu } from "../BlockMenu/BlockMenu"; import { useNewControlPanel } from "./useNewControlPanel"; -import { NewSaveControl } from "../SaveControl/NewSaveControl"; +// import { NewSaveControl } from "../SaveControl/NewSaveControl"; import { GraphExecutionID } from "@/lib/autogpt-server-api"; -import { history } from "@/app/(platform)/build/components/legacy-builder/history"; -import { ControlPanelButton } from "../ControlPanelButton"; +// import { ControlPanelButton } from "../ControlPanelButton"; import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react"; -import { GraphSearchMenu } from "../GraphMenu/GraphMenu"; -import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode"; +// import { GraphSearchMenu } from "../GraphMenu/GraphMenu"; +import { CustomNode } from "../../FlowEditor/nodes/CustomNode"; +import { history } from "@/app/(platform)/build/components/legacy-builder/history"; import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; export type Control = { @@ -19,44 +19,41 @@ export type Control = { onClick: () => void; }; -interface ControlPanelProps { - className?: string; - flowExecutionID: GraphExecutionID | undefined; - visualizeBeads: "no" | "static" | "animate"; - pinSavePopover: boolean; - pinBlocksPopover: boolean; - nodes: CustomNode[]; - onNodeSelect: (nodeId: string) => void; - onNodeHover?: (nodeId: string | null) => void; -} - +export type NewControlPanelProps = { + flowExecutionID?: GraphExecutionID | undefined; + visualizeBeads?: "no" | "static" | "animate"; + pinSavePopover?: boolean; + pinBlocksPopover?: boolean; + nodes?: CustomNode[]; + onNodeSelect?: (nodeId: string) => void; + onNodeHover?: (nodeId: string) => void; +}; export const NewControlPanel = ({ - flowExecutionID, - visualizeBeads, - pinSavePopover, - pinBlocksPopover, - nodes, - onNodeSelect, - onNodeHover, - className, -}: ControlPanelProps) => { - const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH); + flowExecutionID: _flowExecutionID, + visualizeBeads: _visualizeBeads, + pinSavePopover: _pinSavePopover, + pinBlocksPopover: _pinBlocksPopover, + nodes: _nodes, + onNodeSelect: _onNodeSelect, + onNodeHover: _onNodeHover, +}: NewControlPanelProps) => { + const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH); const { blockMenuSelected, setBlockMenuSelected, - agentDescription, - setAgentDescription, - saveAgent, - agentName, - setAgentName, - savedAgent, - isSaving, - isRunning, - isStopping, - } = useNewControlPanel({ flowExecutionID, visualizeBeads }); + // agentDescription, + // setAgentDescription, + // saveAgent, + // agentName, + // setAgentName, + // savedAgent, + // isSaving, + // isRunning, + // isStopping, + } = useNewControlPanel({}); - const controls: Control[] = useMemo( + const _controls: Control[] = useMemo( () => [ { label: "Undo", @@ -77,17 +74,16 @@ export const NewControlPanel = ({ return (
- + {/* {isGraphSearchEnabled && ( <> + /> */}
); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/useNewControlPanel.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/useNewControlPanel.ts index 90a5f3dfeb..c80ec1149a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/useNewControlPanel.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/NewControlPanel/useNewControlPanel.ts @@ -1,53 +1,54 @@ -import useAgentGraph from "@/hooks/useAgentGraph"; -import { GraphExecutionID, GraphID } from "@/lib/autogpt-server-api"; +import { GraphID } from "@/lib/autogpt-server-api"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; export interface NewControlPanelProps { - flowExecutionID: GraphExecutionID | undefined; - visualizeBeads: "no" | "static" | "animate"; + // flowExecutionID: GraphExecutionID | undefined; + visualizeBeads?: "no" | "static" | "animate"; } export const useNewControlPanel = ({ - flowExecutionID, - visualizeBeads, + // flowExecutionID, + visualizeBeads: _visualizeBeads, }: NewControlPanelProps) => { const [blockMenuSelected, setBlockMenuSelected] = useState< "save" | "block" | "search" | "" >(""); const query = useSearchParams(); const _graphVersion = query.get("flowVersion"); - const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined; + const _graphVersionParsed = _graphVersion + ? parseInt(_graphVersion) + : undefined; - const flowID = (query.get("flowID") as GraphID | null) ?? undefined; - const { - agentDescription, - setAgentDescription, - saveAgent, - agentName, - setAgentName, - savedAgent, - isSaving, - isRunning, - isStopping, - } = useAgentGraph( - flowID, - graphVersion, - flowExecutionID, - visualizeBeads !== "no", - ); + const _flowID = (query.get("flowID") as GraphID | null) ?? undefined; + // const { + // agentDescription, + // setAgentDescription, + // saveAgent, + // agentName, + // setAgentName, + // savedAgent, + // isSaving, + // isRunning, + // isStopping, + // } = useAgentGraph( + // flowID, + // graphVersion, + // flowExecutionID, + // visualizeBeads !== "no", + // ); return { blockMenuSelected, setBlockMenuSelected, - agentDescription, - setAgentDescription, - saveAgent, - agentName, - setAgentName, - savedAgent, - isSaving, - isRunning, - isStopping, + // agentDescription, + // setAgentDescription, + // saveAgent, + // agentName, + // setAgentName, + // savedAgent, + // isSaving, + // isRunning, + // isStopping, }; }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/SuggestionContent/SuggestionContent.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/SuggestionContent/SuggestionContent.tsx index 6e5b462554..222a4319ce 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/SuggestionContent/SuggestionContent.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewBlockMenu/SuggestionContent/SuggestionContent.tsx @@ -5,10 +5,12 @@ import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider"; import { useSuggestionContent } from "./useSuggestionContent"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { blockMenuContainerStyle } from "../style"; +import { useNodeStore } from "../../../stores/nodeStore"; export const SuggestionContent = () => { const { setIntegration, setDefaultState } = useBlockMenuContext(); const { data, isLoading, isError, error, refetch } = useSuggestionContent(); + const addBlock = useNodeStore((state) => state.addBlock); if (isError) { return ( @@ -73,6 +75,7 @@ export const SuggestionContent = () => { key={`block-${index}`} title={block.name} description={block.description} + onClick={() => addBlock(block)} /> )) : Array(3) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx new file mode 100644 index 0000000000..5298a8fe7f --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx @@ -0,0 +1,88 @@ +import { useMemo } from "react"; + +import { Link } from "@/app/api/__generated__/models/link"; +import { useEdgeStore } from "../stores/edgeStore"; +import { useNodeStore } from "../stores/nodeStore"; +import { scrollbarStyles } from "@/components/styles/scrollbars"; +import { cn } from "@/lib/utils"; + +export const RightSidebar = () => { + const connections = useEdgeStore((s) => s.connections); + const nodes = useNodeStore((s) => s.nodes); + + const backendLinks: Link[] = useMemo( + () => + connections.map((c) => ({ + source_id: c.source, + sink_id: c.target, + source_name: c.sourceHandle, + sink_name: c.targetHandle, + })), + [connections], + ); + + return ( +
+
+

+ Flow Debug Panel +

+
+ +
+

+ Nodes ({nodes.length}) +

+
+ {nodes.map((n) => ( +
+
+ #{n.id} {n.data?.title ? `– ${n.data.title}` : ""} +
+
+ hardcodedValues +
+
+                {JSON.stringify(n.data?.hardcodedValues ?? {}, null, 2)}
+              
+
+ ))} +
+ +

+ Links ({backendLinks.length}) +

+
+ {connections.map((c) => ( +
+
+ {c.source}[{c.sourceHandle}] → {c.target}[{c.targetHandle}] +
+
+ edge_id: {c.edge_id} +
+
+ ))} +
+ +

+ Backend Links JSON +

+
+          {JSON.stringify(backendLinks, null, 2)}
+        
+
+
+ ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts new file mode 100644 index 0000000000..4a60ac4136 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts @@ -0,0 +1,13 @@ +import { BlockInfo } from "@/app/api/__generated__/models/blockInfo"; +import { CustomNodeData } from "./FlowEditor/nodes/CustomNode"; + +export const convertBlockInfoIntoCustomNodeData = (block: BlockInfo) => { + const customNodeData: CustomNodeData = { + hardcodedValues: {}, + title: block.name, + description: block.description, + inputSchema: block.inputSchema, + outputSchema: block.outputSchema, + }; + return customNodeData; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx index 54d830bd85..ca2b317d85 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx @@ -858,7 +858,7 @@ const FlowEditor: React.FC<{ visualizeBeads={visualizeBeads} pinSavePopover={pinSavePopover} pinBlocksPopover={pinBlocksPopover} - nodes={nodes} + // nodes={nodes} onNodeSelect={navigateToNode} onNodeHover={highlightNode} /> diff --git a/autogpt_platform/frontend/src/app/(platform)/build/page.tsx b/autogpt_platform/frontend/src/app/(platform)/build/page.tsx index 832b56d772..6df571ef61 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/page.tsx @@ -2,10 +2,13 @@ import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; import FlowEditor from "@/app/(platform)/build/components/legacy-builder/Flow/Flow"; -import LoadingBox from "@/components/__legacy__/ui/loading"; +// import LoadingBox from "@/components/__legacy__/ui/loading"; import { GraphID } from "@/lib/autogpt-server-api/types"; import { useSearchParams } from "next/navigation"; -import { Suspense, useEffect } from "react"; +import { useEffect } from "react"; +import { Flow } from "./components/FlowEditor/Flow"; +import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs"; +import { useBuilderView } from "./components/BuilderViewTabs/useBuilderViewTabs"; function BuilderContent() { const query = useSearchParams(); @@ -19,7 +22,7 @@ function BuilderContent() { const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined; return ( @@ -27,9 +30,22 @@ function BuilderContent() { } export default function BuilderPage() { - return ( - }> - - - ); + const { + isSwitchEnabled, + selectedView, + setSelectedView, + isNewFlowEditorEnabled, + } = useBuilderView(); + + // Switch is temporary, we will remove it once our new flow editor is ready + if (isSwitchEnabled) { + return ( +
+ + {selectedView === "new" ? : } +
+ ); + } + + return isNewFlowEditorEnabled ? : ; } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts new file mode 100644 index 0000000000..6ee4e015a2 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts @@ -0,0 +1,82 @@ +import { create } from "zustand"; +import { convertConnectionsToBackendLinks } from "../components/FlowEditor/edges/helpers"; + +export type Connection = { + edge_id: string; + source: string; + sourceHandle: string; + target: string; + targetHandle: string; +}; + +type EdgeStore = { + connections: Connection[]; + + setConnections: (connections: Connection[]) => void; + addConnection: ( + conn: Omit & { edge_id?: string }, + ) => Connection; + removeConnection: (edge_id: string) => void; + upsertMany: (conns: Connection[]) => void; + + getNodeConnections: (nodeId: string) => Connection[]; + isInputConnected: (nodeId: string, handle: string) => boolean; + isOutputConnected: (nodeId: string, handle: string) => boolean; +}; + +function makeEdgeId(conn: Omit) { + return `${conn.source}:${conn.sourceHandle}->${conn.target}:${conn.targetHandle}`; +} + +export const useEdgeStore = create((set, get) => ({ + connections: [], + + setConnections: (connections) => set({ connections }), + + addConnection: (conn) => { + const edge_id = conn.edge_id || makeEdgeId(conn); + const newConn: Connection = { edge_id, ...conn }; + + set((state) => { + const exists = state.connections.some( + (c) => + c.source === newConn.source && + c.target === newConn.target && + c.sourceHandle === newConn.sourceHandle && + c.targetHandle === newConn.targetHandle, + ); + if (exists) return state; + return { connections: [...state.connections, newConn] }; + }); + + return { edge_id, ...conn }; + }, + + removeConnection: (edge_id) => + set((state) => ({ + connections: state.connections.filter((c) => c.edge_id !== edge_id), + })), + + upsertMany: (conns) => + set((state) => { + const byKey = new Map(state.connections.map((c) => [c.edge_id, c])); + conns.forEach((c) => { + byKey.set(c.edge_id, c); + }); + return { connections: Array.from(byKey.values()) }; + }), + + getNodeConnections: (nodeId) => + get().connections.filter((c) => c.source === nodeId || c.target === nodeId), + + isInputConnected: (nodeId, handle) => + get().connections.some( + (c) => c.target === nodeId && c.targetHandle === handle, + ), + + isOutputConnected: (nodeId, handle) => + get().connections.some( + (c) => c.source === nodeId && c.sourceHandle === handle, + ), + getBackendLinks: () => convertConnectionsToBackendLinks(get().connections), +})); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts new file mode 100644 index 0000000000..8a028a5692 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -0,0 +1,75 @@ +import { create } from "zustand"; +import { NodeChange, applyNodeChanges } from "@xyflow/react"; +import { CustomNode } from "../components/FlowEditor/nodes/CustomNode"; +import { BlockInfo } from "@/app/api/__generated__/models/blockInfo"; +import { convertBlockInfoIntoCustomNodeData } from "../components/helper"; + +type NodeStore = { + nodes: CustomNode[]; + nodeCounter: number; + nodeAdvancedStates: Record; + setNodes: (nodes: CustomNode[]) => void; + onNodesChange: (changes: NodeChange[]) => void; + addNode: (node: CustomNode) => void; + addBlock: (block: BlockInfo) => void; + incrementNodeCounter: () => void; + updateNodeData: (nodeId: string, data: Partial) => void; + toggleAdvanced: (nodeId: string) => void; + setShowAdvanced: (nodeId: string, show: boolean) => void; + getShowAdvanced: (nodeId: string) => boolean; +}; + +export const useNodeStore = create((set, get) => ({ + nodes: [], + setNodes: (nodes) => set({ nodes }), + nodeCounter: 0, + nodeAdvancedStates: {}, + incrementNodeCounter: () => + set((state) => ({ + nodeCounter: state.nodeCounter + 1, + })), + onNodesChange: (changes) => + set((state) => ({ + nodes: applyNodeChanges(changes, state.nodes), + })), + addNode: (node) => + set((state) => ({ + nodes: [...state.nodes, node], + })), + addBlock: (block: BlockInfo) => { + const customNodeData = convertBlockInfoIntoCustomNodeData(block); + get().incrementNodeCounter(); + const nodeNumber = get().nodeCounter; + const customNode: CustomNode = { + id: nodeNumber.toString(), + data: customNodeData, + type: "custom", + position: { x: 0, y: 0 }, + }; + set((state) => ({ + nodes: [...state.nodes, customNode], + })); + }, + updateNodeData: (nodeId, data) => + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n, + ), + })), + toggleAdvanced: (nodeId: string) => + set((state) => ({ + nodeAdvancedStates: { + ...state.nodeAdvancedStates, + [nodeId]: !state.nodeAdvancedStates[nodeId], + }, + })), + setShowAdvanced: (nodeId: string, show: boolean) => + set((state) => ({ + nodeAdvancedStates: { + ...state.nodeAdvancedStates, + [nodeId]: show, + }, + })), + getShowAdvanced: (nodeId: string) => + get().nodeAdvancedStates[nodeId] || false, +})); diff --git a/autogpt_platform/frontend/src/app/(platform)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/layout.tsx index 3146df9e6b..9a18e72beb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/layout.tsx @@ -3,9 +3,9 @@ import { ReactNode } from "react"; export default function PlatformLayout({ children }: { children: ReactNode }) { return ( - <> +
-
{children}
- +
{children}
+
); } diff --git a/autogpt_platform/frontend/src/components/atoms/DateInput/DateInput.tsx b/autogpt_platform/frontend/src/components/atoms/DateInput/DateInput.tsx new file mode 100644 index 0000000000..2c65e28df0 --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/DateInput/DateInput.tsx @@ -0,0 +1,143 @@ +"use client"; + +import * as React from "react"; +import { Calendar as CalendarIcon } from "lucide-react"; +import { Button } from "@/components/__legacy__/ui/button"; +import { cn } from "@/lib/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/__legacy__/ui/popover"; +import { Calendar } from "@/components/__legacy__/ui/calendar"; + +function toLocalISODateString(d: Date) { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function parseISODateString(s?: string): Date | undefined { + if (!s) return undefined; + // Expecting "YYYY-MM-DD" + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (!m) return undefined; + const [_, y, mo, d] = m; + const date = new Date(Number(y), Number(mo) - 1, Number(d)); + return isNaN(date.getTime()) ? undefined : date; +} + +export interface DateInputProps { + value?: string; + onChange?: (value?: string) => void; + disabled?: boolean; + readonly?: boolean; + placeholder?: string; + autoFocus?: boolean; + className?: string; + label?: string; + hideLabel?: boolean; + error?: string; + id?: string; + size?: "default" | "small"; +} + +export const DateInput = ({ + value, + onChange, + disabled, + readonly, + placeholder, + autoFocus, + className, + label, + hideLabel = false, + error, + id, + size = "default", +}: DateInputProps) => { + const selected = React.useMemo(() => parseISODateString(value), [value]); + const [open, setOpen] = React.useState(false); + + const setDate = (d?: Date) => { + onChange?.(d ? toLocalISODateString(d) : undefined); + setOpen(false); + }; + + const buttonText = + selected?.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) || + placeholder || + "Pick a date"; + + const isDisabled = disabled || readonly; + + const triggerStyles = cn( + // Base styles matching other form components + "rounded-3xl border border-zinc-200 bg-white px-4 shadow-none", + "font-normal text-black w-full text-sm", + "placeholder:font-normal !placeholder:text-zinc-400", + // Focus and hover states + "focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0", + // Error state + error && + "border-1.5 border-red-500 focus:border-red-500 focus:ring-red-500", + // Placeholder styling + !selected && "text-zinc-400", + "justify-start text-left", + // Size variants + size === "default" && "h-[2.875rem] py-2.5", + className, + size === "small" && [ + "min-h-[2.25rem]", // 36px minimum + "py-2", + "text-sm leading-[22px]", + "placeholder:text-sm placeholder:leading-[22px]", + ], + ); + + return ( +
+ {label && !hideLabel && ( + + )} + + + + + + + + + {error && {error}} +
+ ); +}; diff --git a/autogpt_platform/frontend/src/components/atoms/DateTimeInput/DateTimeInput.tsx b/autogpt_platform/frontend/src/components/atoms/DateTimeInput/DateTimeInput.tsx new file mode 100644 index 0000000000..52bf6ac249 --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/DateTimeInput/DateTimeInput.tsx @@ -0,0 +1,253 @@ +"use client"; + +import * as React from "react"; +import { Calendar as CalendarIcon, Clock } from "lucide-react"; +import { Button } from "@/components/atoms/Button/Button"; +import { cn } from "@/lib/utils"; + +import { Text } from "../Text/Text"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/__legacy__/ui/popover"; +import { Calendar } from "@/components/__legacy__/ui/calendar"; + +function toLocalISODateTimeString(d: Date) { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +function parseISODateTimeString(s?: string): Date | undefined { + if (!s) return undefined; + // Expecting "YYYY-MM-DDTHH:MM" or "YYYY-MM-DD HH:MM" + const normalized = s.replace(" ", "T"); + const date = new Date(normalized); + return isNaN(date.getTime()) ? undefined : date; +} + +export interface DateTimeInputProps { + value?: string; + onChange?: (value?: string) => void; + disabled?: boolean; + readonly?: boolean; + placeholder?: string; + autoFocus?: boolean; + className?: string; + label?: string; + hideLabel?: boolean; + error?: string; + hint?: React.ReactNode; + id?: string; + size?: "default" | "small"; + wrapperClassName?: string; +} + +export const DateTimeInput = ({ + value, + onChange, + disabled = false, + readonly = false, + placeholder, + autoFocus, + className, + label, + hideLabel = false, + error, + hint, + id, + size = "default", + wrapperClassName, +}: DateTimeInputProps) => { + const selected = React.useMemo(() => parseISODateTimeString(value), [value]); + const [open, setOpen] = React.useState(false); + const [timeValue, setTimeValue] = React.useState(""); + + // Update time value when selected date changes + React.useEffect(() => { + if (selected) { + const hours = String(selected.getHours()).padStart(2, "0"); + const minutes = String(selected.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } else { + setTimeValue(""); + } + }, [selected]); + + const setDate = (d?: Date) => { + if (!d) { + onChange?.(undefined); + setOpen(false); + return; + } + + // If we have a time value, apply it to the selected date + if (timeValue) { + const [hours, minutes] = timeValue.split(":").map(Number); + if (!isNaN(hours) && !isNaN(minutes)) { + d.setHours(hours, minutes, 0, 0); + } + } + + onChange?.(toLocalISODateTimeString(d)); + setOpen(false); + }; + + const handleTimeChange = (time: string) => { + setTimeValue(time); + + if (selected && time) { + const [hours, minutes] = time.split(":").map(Number); + if (!isNaN(hours) && !isNaN(minutes)) { + const newDate = new Date(selected); + newDate.setHours(hours, minutes, 0, 0); + onChange?.(toLocalISODateTimeString(newDate)); + } + } + }; + + const buttonText = selected + ? selected.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + + " " + + selected.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }) + : placeholder || "Pick date and time"; + + const isDisabled = disabled || readonly; + + const triggerStyles = cn( + // Base styles matching other form components + "rounded-3xl border border-zinc-200 bg-white px-4 shadow-none", + "font-normal text-black w-full text-sm", + "placeholder:font-normal !placeholder:text-zinc-400", + // Focus and hover states + "focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0", + // Error state + error && + "border-1.5 border-red-500 focus:border-red-500 focus:ring-red-500", + // Placeholder styling + !selected && "text-zinc-400", + "justify-start text-left", + // Size variants + size === "default" && "h-[2.875rem] py-2.5", + size === "small" && [ + "min-h-[2.25rem]", // 36px minimum + "py-2", + "text-sm leading-[22px]", + "placeholder:text-sm placeholder:leading-[22px]", + ], + className, + ); + + const timeInputStyles = cn( + // Base styles + "rounded-3xl border border-zinc-200 bg-white px-4 shadow-none", + "font-normal text-black w-full", + "placeholder:font-normal placeholder:text-zinc-400", + // Focus and hover states + "focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0", + // Size variants + size === "small" && [ + "h-[2.25rem]", // 36px + "py-2", + "text-sm leading-[22px]", // 14px font, 22px line height + "placeholder:text-sm placeholder:leading-[22px]", + ], + size === "default" && [ + "h-[2.875rem]", // 46px + "py-2.5", + ], + ); + + const inputWithError = ( +
+ + + + + +
+ +
+ + handleTimeChange(e.target.value)} + className={timeInputStyles} + disabled={isDisabled} + placeholder="HH:MM" + /> +
+
+
+
+ {error && ( + + {error || " "} + + )} +
+ ); + + return hideLabel || !label ? ( + inputWithError + ) : ( + + ); +}; diff --git a/autogpt_platform/frontend/src/components/atoms/Select/Select.tsx b/autogpt_platform/frontend/src/components/atoms/Select/Select.tsx index de5a108fce..eaa12f63df 100644 --- a/autogpt_platform/frontend/src/components/atoms/Select/Select.tsx +++ b/autogpt_platform/frontend/src/components/atoms/Select/Select.tsx @@ -36,6 +36,7 @@ export interface SelectFieldProps { options: SelectOption[]; size?: "small" | "medium"; renderItem?: (option: SelectOption) => React.ReactNode; + wrapperClassName?: string; } export function Select({ @@ -52,6 +53,7 @@ export function Select({ options, size = "medium", renderItem, + wrapperClassName, }: SelectFieldProps) { const triggerStyles = cn( // Base styles matching Input @@ -117,7 +119,7 @@ export function Select({ ); const selectWithError = ( -
+
{select} void; + className?: string; + disabled?: boolean; + placeholder?: string; + label?: string; + id?: string; + hideLabel?: boolean; + error?: string; + hint?: ReactNode; + size?: "small" | "medium"; + wrapperClassName?: string; +} + +export const TimeInput: React.FC = ({ + value = "", + onChange, + className, + disabled = false, + placeholder = "HH:MM", + label, + id, + hideLabel = false, + error, + hint, + size = "medium", + wrapperClassName, +}) => { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value); + }; + + const baseStyles = cn( + // Base styles + "rounded-3xl border border-zinc-200 bg-white px-4 shadow-none", + "font-normal text-black", + "placeholder:font-normal placeholder:text-zinc-400", + // Focus and hover states + "focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0", + className, + ); + + const errorStyles = + error && "!border !border-red-500 focus:border-red-500 focus:ring-red-500"; + + const input = ( +
+ +
+ ); + + const inputWithError = ( +
+ {input} + + {error || " "}{" "} + {/* Always render with space to maintain consistent height calculation */} + +
+ ); + + return hideLabel || !label ? ( + inputWithError + ) : ( + + ); +}; diff --git a/autogpt_platform/frontend/src/components/layout/Navbar/components/NavbarView.tsx b/autogpt_platform/frontend/src/components/layout/Navbar/components/NavbarView.tsx index 071a9a1c69..5c5b1320f6 100644 --- a/autogpt_platform/frontend/src/components/layout/Navbar/components/NavbarView.tsx +++ b/autogpt_platform/frontend/src/components/layout/Navbar/components/NavbarView.tsx @@ -27,7 +27,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => { return ( <> -