From 4a7bc006a8016a477448a7bce2832abfe4650d5e Mon Sep 17 00:00:00 2001 From: Ubbe Date: Thu, 18 Dec 2025 19:04:13 +0100 Subject: [PATCH 01/28] hotfix(frontend): chat should be disabled by default (#11639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes 🏗️ Chat should be disabled by default; otherwise, it flashes, and if Launch Darkly fails to fail, it is dangerous. ### 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] Run locally with Launch Darkly disabled and test the above --- .../frontend/src/services/feature-flags/use-get-flag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts b/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts index e80adeb7b5..b4f91b22ef 100644 --- a/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts +++ b/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts @@ -48,7 +48,7 @@ const mockFlags = { [Flag.AGENT_FAVORITING]: false, [Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS, [Flag.ENABLE_PLATFORM_PAYMENT]: false, - [Flag.CHAT]: true, + [Flag.CHAT]: false, }; export function useGetFlag(flag: T): FlagValues[T] | null { From e5031261706f7d3aefe78d4918b3b061bd1a4824 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:36:34 +0530 Subject: [PATCH 02/28] feat(frontend): upgrade RJSF to v6 and implement new FormRenderer system (#11677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #11686 ### Changes 🏗️ This PR upgrades the React JSON Schema Form (RJSF) library from v5 to v6 and introduces a complete rewrite of the form rendering system with improved architecture and new features. #### Core Library Updates - Upgraded `@rjsf/core` from 5.24.13 to 6.1.2 - Upgraded `@rjsf/utils` from 5.24.13 to 6.1.2 - Added `@radix-ui/react-slider` 1.3.6 for new slider components #### New Form Renderer Architecture - **Base Templates**: Created modular base templates for arrays, objects, and standard fields - **AnyOf Support**: Implemented `AnyOfField` component with type selector for union types - **Array Fields**: New `ArrayFieldTemplate`, `ArrayFieldItemTemplate`, and `ArraySchemaField` with context provider - **Object Fields**: Enhanced `ObjectFieldTemplate` with better support for additional properties via `WrapIfAdditionalTemplate` - **Field Templates**: New `TitleField`, `DescriptionField`, and `FieldTemplate` with improved styling - **Custom Widgets**: Implemented TextWidget, SelectWidget, CheckboxWidget, FileWidget, DateWidget, TimeWidget, and DateTimeWidget - **Button Components**: Custom AddButton, RemoveButton, and CopyButton components #### Node Handle System Refactor - Split `NodeHandle` into `InputNodeHandle` and `OutputNodeHandle` for better separation of concerns - Refactored handle ID generation logic in `helpers.ts` with new `generateHandleIdFromTitleId` function - Improved handle connection detection using edge store - Added support for nested output handles (objects within outputs) #### Edge Store Improvements - Added `removeEdgesByHandlePrefix` method for bulk edge removal - Improved `isInputConnected` with handle ID cleanup - Optimized `updateEdgeBeads` to only update when changes occur - Better edge management with `applyEdgeChanges` #### Node Store Enhancements - Added `syncHardcodedValuesWithHandleIds` method to maintain consistency between form data and handle connections - Better handling of additional properties in objects - Improved path parsing with `parseHandleIdToPath` and `ensurePathExists` #### Draft Recovery Improvements - Added diff calculation with `calculateDraftDiff` to show what changed - New `formatDiffSummary` to display changes in a readable format (e.g., "+2/-1 blocks, +3 connections") - Better visual feedback for draft changes #### UI/UX Enhancements - Fixed node container width to 350px for consistency - Improved field error display with inline error messages - Better spacing and styling throughout forms - Enhanced tooltip support for field descriptions - Improved array item controls with better button placement - Context-aware field sizing (small/large) #### Output Handler Updates - Recursive rendering of nested output properties - Better type display with color coding - Improved handle connections for complex output schemas #### Migration & Cleanup - Updated `RunInputDialog` to use new FormRenderer - Updated `FormCreator` to use new FormRenderer - Moved OAuth callback types to separate file - Updated import paths from `input-renderer` to `InputRenderer` - Removed unused console.log statements - Added `type="button"` to buttons to prevent form submission ### 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] Test form rendering with various field types (text, number, boolean, arrays, objects) - [x] Test anyOf field type selector functionality - [x] Test array item addition/removal - [x] Test nested object fields with additional properties - [x] Test input/output node handle connections - [x] Test draft recovery with diff display - [x] Verify backward compatibility with existing agents - [x] Test field validation and error display - [x] Verify handle ID generation for complex schemas ## Summary by CodeRabbit * **New Features** * Improved form field rendering with enhanced support for optional types, arrays, and nested objects. * Enhanced draft recovery display showing detailed difference tracking (added, removed, modified items). * Better OAuth popup callback handling with structured message types. * **Bug Fixes** * Improved node handle ID normalization and synchronization. * Enhanced edge management for complex field changes. * Fixed styling consistency across form components. * **Dependencies** * Updated React JSON Schema Form library to version 6.1.2. * Added Radix UI slider component support. ✏️ Tip: You can customize this high-level summary in your review settings. --- autogpt_platform/frontend/package.json | 5 +- autogpt_platform/frontend/pnpm-lock.yaml | 176 ++-- .../auth/integrations/oauth_callback/route.ts | 2 +- .../auth/integrations/oauth_callback/types.ts | 11 + .../RunInputDialog/RunInputDialog.tsx | 2 +- .../RunInputDialog/useRunInputDialog.ts | 2 +- .../DraftRecoveryPopup.tsx | 54 +- .../useDraftRecoveryPopup.tsx | 2 + .../Flow/helpers/resolve-collision.ts | 2 - .../FlowEditor/Flow/useDraftManager.ts | 27 +- .../components/FlowEditor/Flow/useFlow.ts | 8 + .../FlowEditor/edges/useCustomEdge.ts | 19 +- .../FlowEditor/handlers/NodeHandle.tsx | 59 +- .../components/FlowEditor/handlers/helpers.ts | 111 +-- .../nodes/CustomNode/CustomNode.tsx | 4 +- .../components/NodeAdvancedToggle.tsx | 2 +- .../CustomNode/components/NodeContainer.tsx | 2 +- .../CustomNode/components/NodeHeader.tsx | 10 +- .../components/NodeOutput/NodeOutput.tsx | 2 +- .../CustomNode/components/StickyNoteBlock.tsx | 2 +- .../FlowEditor/nodes/FormCreator.tsx | 2 +- .../FlowEditor/nodes/OutputHandler.tsx | 117 ++- .../components/FlowEditor/nodes/helpers.ts | 51 +- .../app/(platform)/build/stores/edgeStore.ts | 44 +- .../app/(platform)/build/stores/nodeStore.ts | 37 + .../CredentialsInputs/CredentialsInputs.tsx | 2 + .../profile/(user)/integrations/page.tsx | 2 +- .../GoogleDrivePicker/GoogleDrivePicker.tsx | 1 + .../FormRenderer.tsx | 27 +- .../InputRenderer/base/anyof/AnyOfField.tsx | 86 ++ .../base/anyof/components/AnyOfFieldTitle.tsx | 78 ++ .../InputRenderer/base/anyof/helpers.ts | 61 ++ .../InputRenderer/base/anyof/useAnyOfField.ts | 96 ++ .../base/array/ArrayFieldItemTemplate.tsx | 34 + .../base/array/ArrayFieldTemplate.tsx | 105 ++ .../base/array/ArraySchemaField.tsx | 29 + .../base/array/context/array-item-context.tsx | 33 + .../InputRenderer/base/array/helpers.ts | 3 + .../InputRenderer/base/array/index.ts | 7 + .../InputRenderer/base/base-registry.ts | 69 ++ .../renderers/InputRenderer/base/index.ts | 5 + .../base/object/ObjectFieldTemplate.tsx | 122 +++ .../object/OptionalDataControlsTemplate.tsx | 35 + .../base/object/WrapIfAdditionalTemplate.tsx | 114 +++ .../InputRenderer/base/object/index.ts | 3 + .../base/standard/DescriptionField.tsx | 32 + .../base/standard/FieldError.tsx | 27 + .../base/standard/FieldTemplate.tsx | 131 +++ .../base/standard/TitleField.tsx | 55 + .../base/standard/buttons/AddButton.tsx | 27 + .../base/standard/buttons/IconButton.tsx | 101 ++ .../base/standard/buttons/index.ts | 8 + .../base/standard/errors/ErrorList.tsx | 24 + .../base/standard/errors/index.ts | 1 + .../InputRenderer/base/standard/helpers.ts | 76 ++ .../InputRenderer/base/standard/index.ts | 3 + .../widgets/CheckboxInput/CheckBoxWidget.tsx} | 3 +- .../standard/widgets/CheckboxInput/index.ts | 1 + .../widgets/DateInput/DateWidget.tsx} | 3 +- .../base/standard/widgets/DateInput/index.ts | 1 + .../widgets/DateTimeInput/DateTimeWidget.tsx} | 2 +- .../standard/widgets/DateTimeInput/index.ts | 1 + .../widgets/FileInput}/FileWidget.tsx | 0 .../base/standard/widgets/FileInput/index.ts | 1 + .../widgets/SelectInput}/SelectWidget.tsx | 17 +- .../standard/widgets/SelectInput/index.ts | 1 + .../TextInput/TextInputExpanderModal.tsx} | 0 .../widgets/TextInput/TextWidget.tsx} | 19 +- .../base/standard/widgets/TextInput/index.ts | 2 + .../widgets/TimeInput/TimeWidget.tsx} | 2 +- .../base/standard/widgets/TimeInput/index.ts | 1 + .../base/standard/widgets/index.ts | 7 + .../renderers/InputRenderer/constants.ts | 8 + .../CredentialField/CredentialField.tsx | 73 ++ .../components/CredentialFieldTitle.tsx | 66 ++ .../custom}/CredentialField/helpers.ts | 0 .../GoogleDrivePickerField.tsx | 21 + .../InputRenderer/custom/custom-registry.ts | 52 + .../renderers/InputRenderer/docs/HEIRARCHY.md | 291 ++++++ .../renderers/InputRenderer/helpers.ts | 276 ++++++ .../renderers/InputRenderer/index.ts | 3 + .../renderers/InputRenderer/registry/Form.tsx | 23 + .../renderers/InputRenderer/registry/index.ts | 10 + .../renderers/InputRenderer/registry/types.ts | 7 + .../renderers/InputRenderer/types.ts | 15 + .../utils/custom-validator.ts | 0 .../utils/helpers.ts | 0 .../utils/input-schema-pre-processor.ts | 10 +- .../InputRenderer/utils/rjsf-utils.ts | 6 + .../InputRenderer/utils/schema-utils.ts | 35 + .../ARCHITECTURE_INPUT_RENDERER.md | 938 ------------------ .../fields/AnyOfField/AnyOfField.tsx | 232 ----- .../fields/AnyOfField/useAnyOfField.tsx | 105 -- .../CredentialField/CredentialField.tsx | 87 -- .../CredentialField/SelectCredential.tsx | 93 -- .../APIKeyCredentialModal.tsx | 123 --- .../useAPIKeyCredentialsModal.ts | 103 -- .../HostScopedCredentialsModal.tsx | 185 ---- .../useHostScopedCredentialsModal.ts | 167 ---- .../OAuthCredentialModal.tsx | 63 -- .../useOAuthCredentialModal.ts | 196 ---- .../PasswordCredentialModal.tsx | 102 -- .../usePasswordCredentialModal.ts | 75 -- .../CredentialField/useCredentialField.ts | 141 --- .../input-renderer/fields/ObjectField.tsx | 42 - .../renderers/input-renderer/fields/index.ts | 10 - .../templates/ArrayFieldTemplate.tsx | 30 - .../templates/FieldTemplate.tsx | 184 ---- .../input-renderer/templates/index.ts | 10 - .../ArrayEditorWidget/ArrayEditorContext.tsx | 11 - .../ArrayEditorWidget/ArrayEditorWidget.tsx | 92 -- .../ObjectEditorWidget/ObjectEditorWidget.tsx | 183 ---- .../renderers/input-renderer/widgets/index.ts | 18 - .../frontend/src/lib/dexie/draft-utils.ts | 79 ++ 114 files changed, 2873 insertions(+), 3503 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer => InputRenderer}/FormRenderer.tsx (62%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/AnyOfField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/components/AnyOfFieldTitle.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/useAnyOfField.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/array/ArrayFieldItemTemplate.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/array/ArrayFieldTemplate.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/array/ArraySchemaField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/array/context/array-item-context.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/array/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/array/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/base-registry.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/object/ObjectFieldTemplate.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/object/OptionalDataControlsTemplate.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/object/WrapIfAdditionalTemplate.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/object/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/DescriptionField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/FieldError.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/FieldTemplate.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/TitleField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/AddButton.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/IconButton.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/ErrorList.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets/SwitchWidget.tsx => InputRenderer/base/standard/widgets/CheckboxInput/CheckBoxWidget.tsx} (87%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/CheckboxInput/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets/DateInputWidget.tsx => InputRenderer/base/standard/widgets/DateInput/DateWidget.tsx} (88%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateInput/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets/DateTimeInputWidget.tsx => InputRenderer/base/standard/widgets/DateTimeInput/DateTimeWidget.tsx} (91%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets => InputRenderer/base/standard/widgets/FileInput}/FileWidget.tsx (100%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/FileInput/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets => InputRenderer/base/standard/widgets/SelectInput}/SelectWidget.tsx (87%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets/TextInputWidget/InputExpanderModal.tsx => InputRenderer/base/standard/widgets/TextInput/TextInputExpanderModal.tsx} (100%) rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets/TextInputWidget/TextInputWidget.tsx => InputRenderer/base/standard/widgets/TextInput/TextWidget.tsx} (91%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/index.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer/widgets/TimeInputWidget.tsx => InputRenderer/base/standard/widgets/TimeInput/TimeWidget.tsx} (91%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/constants.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/components/CredentialFieldTitle.tsx rename autogpt_platform/frontend/src/components/renderers/{input-renderer/fields => InputRenderer/custom}/CredentialField/helpers.ts (100%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/GoogleDrivePickerField/GoogleDrivePickerField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/docs/HEIRARCHY.md create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/Form.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/index.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/types.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/types.ts rename autogpt_platform/frontend/src/components/renderers/{input-renderer => InputRenderer}/utils/custom-validator.ts (100%) rename autogpt_platform/frontend/src/components/renderers/{input-renderer => InputRenderer}/utils/helpers.ts (100%) rename autogpt_platform/frontend/src/components/renderers/{input-renderer => InputRenderer}/utils/input-schema-pre-processor.ts (91%) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/rjsf-utils.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/schema-utils.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/ARCHITECTURE_INPUT_RENDERER.md delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/AnyOfField/AnyOfField.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/AnyOfField/useAnyOfField.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/CredentialField.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/SelectCredential.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/APIKeyCredentialModal.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/useAPIKeyCredentialsModal.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/HostScopedCredentialsModal.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/useHostScopedCredentialsModal.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/OAuthCredentialModal.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/PasswordCredentialModal.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/usePasswordCredentialModal.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/useCredentialField.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/ObjectField.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/fields/index.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/templates/ArrayFieldTemplate.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/templates/index.ts delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorContext.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorWidget.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ObjectEditorWidget/ObjectEditorWidget.tsx delete mode 100644 autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/index.ts diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 1708ac9053..fcaa150ee1 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -46,13 +46,14 @@ "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@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/core": "6.1.2", + "@rjsf/utils": "6.1.2", "@rjsf/validator-ajv8": "5.24.13", "@sentry/nextjs": "10.27.0", "@supabase/ssr": "0.7.0", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 355ffff129..82f516f115 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@radix-ui/react-separator': specifier: 1.1.7 version: 1.1.7(@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) + '@radix-ui/react-slider': + specifier: 1.3.6 + version: 1.3.6(@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) '@radix-ui/react-slot': specifier: 1.2.3 version: 1.2.3(@types/react@18.3.17)(react@18.3.1) @@ -78,14 +81,14 @@ importers: 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) + specifier: 6.1.2 + version: 6.1.2(@rjsf/utils@6.1.2(react@18.3.1))(react@18.3.1) '@rjsf/utils': - specifier: 5.24.13 - version: 5.24.13(react@18.3.1) + specifier: 6.1.2 + version: 6.1.2(react@18.3.1) '@rjsf/validator-ajv8': specifier: 5.24.13 - version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1)) + version: 5.24.13(@rjsf/utils@6.1.2(react@18.3.1)) '@sentry/nextjs': specifier: 10.27.0 version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(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)) @@ -2310,6 +2313,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2479,18 +2495,18 @@ packages: react-redux: optional: true - '@rjsf/core@5.24.13': - resolution: {integrity: sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==} - engines: {node: '>=14'} + '@rjsf/core@6.1.2': + resolution: {integrity: sha512-fcEO6kArMcVIzTBoBxNStqxzAL417NDw049nmNx11pIcMwUnU5sAkSW18c8kgZOT6v1xaZhQrY+X5cBzzHy9+g==} + engines: {node: '>=20'} peerDependencies: - '@rjsf/utils': ^5.24.x - react: ^16.14.0 || >=17 + '@rjsf/utils': ^6.x + react: '>=18' - '@rjsf/utils@5.24.13': - resolution: {integrity: sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==} - engines: {node: '>=14'} + '@rjsf/utils@6.1.2': + resolution: {integrity: sha512-Px3FIkE1KK0745Qng9v88RZ0O7hcLf/1JUu0j00g+r6C8Zyokna42Hz/5TKyyQSKJqgVYcj2Z47YroVLenUM3A==} + engines: {node: '>=20'} peerDependencies: - react: ^16.14.0 || >=17 + react: '>=18' '@rjsf/validator-ajv8@5.24.13': resolution: {integrity: sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==} @@ -3643,6 +3659,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@x0k/json-schema-merge@1.0.2': + resolution: {integrity: sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4159,12 +4178,6 @@ 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==} @@ -4829,6 +4842,9 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -5477,13 +5493,6 @@ 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==} @@ -5599,6 +5608,9 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.17.22: + resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -5688,11 +5700,14 @@ 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==} + markdown-to-jsx@8.0.0: + resolution: {integrity: sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==} engines: {node: '>= 10'} peerDependencies: react: '>= 0.14.0' + peerDependenciesMeta: + react: + optional: true math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -7578,21 +7593,6 @@ 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.20: resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==} engines: {node: '>= 0.10'} @@ -9903,6 +9903,25 @@ snapshots: '@types/react': 18.3.17 '@types/react-dom': 18.3.5(@types/react@18.3.17) + '@radix-ui/react-slider@1.3.6(@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)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@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) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.17)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.17)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.17)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@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) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.17)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.17)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.17)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.17)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.17 + '@types/react-dom': 18.3.5(@types/react@18.3.17) + '@radix-ui/react-slot@1.2.3(@types/react@18.3.17)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.17)(react@18.3.1) @@ -10065,27 +10084,28 @@ 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)': + '@rjsf/core@6.1.2(@rjsf/utils@6.1.2(react@18.3.1))(react@18.3.1)': dependencies: - '@rjsf/utils': 5.24.13(react@18.3.1) + '@rjsf/utils': 6.1.2(react@18.3.1) lodash: 4.17.21 - lodash-es: 4.17.21 - markdown-to-jsx: 7.7.13(react@18.3.1) + lodash-es: 4.17.22 + markdown-to-jsx: 8.0.0(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 - '@rjsf/utils@5.24.13(react@18.3.1)': + '@rjsf/utils@6.1.2(react@18.3.1)': dependencies: - json-schema-merge-allof: 0.8.1 + '@x0k/json-schema-merge': 1.0.2 + fast-uri: 3.1.0 jsonpointer: 5.0.1 lodash: 4.17.21 - lodash-es: 4.17.21 + lodash-es: 4.17.22 react: 18.3.1 react-is: 18.3.1 - '@rjsf/validator-ajv8@5.24.13(@rjsf/utils@5.24.13(react@18.3.1))': + '@rjsf/validator-ajv8@5.24.13(@rjsf/utils@6.1.2(react@18.3.1))': dependencies: - '@rjsf/utils': 5.24.13(react@18.3.1) + '@rjsf/utils': 6.1.2(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) lodash: 4.17.21 @@ -11502,6 +11522,10 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@x0k/json-schema-merge@1.0.2': + dependencies: + '@types/json-schema': 7.0.15 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -12041,19 +12065,6 @@ 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: @@ -12932,6 +12943,8 @@ snapshots: fast-uri@3.0.6: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -13641,16 +13654,6 @@ 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: {} @@ -13766,6 +13769,8 @@ snapshots: lodash-es@4.17.21: {} + lodash-es@4.17.22: {} + lodash.camelcase@4.3.0: {} lodash.debounce@4.0.8: {} @@ -13845,8 +13850,8 @@ snapshots: markdown-table@3.0.4: {} - markdown-to-jsx@7.7.13(react@18.3.1): - dependencies: + markdown-to-jsx@8.0.0(react@18.3.1): + optionalDependencies: react: 18.3.1 math-intrinsics@1.1.0: {} @@ -16202,21 +16207,6 @@ 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.20: {} 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): diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts index df1de26300..41d05a9afb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts +++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts @@ -1,4 +1,4 @@ -import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal"; +import { OAuthPopupResultMessage } from "./types"; import { NextResponse } from "next/server"; // This route is intended to be used as the callback for integration OAuth flows, diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts new file mode 100644 index 0000000000..9000adf392 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts @@ -0,0 +1,11 @@ +export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & ( + | { + success: true; + code: string; + state: string; + } + | { + success: false; + message: string; + } +); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx index 2d9f51c8bf..431feeaade 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx @@ -5,7 +5,7 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore"; import { Button } from "@/components/atoms/Button/Button"; import { ClockIcon, PlayIcon } from "@phosphor-icons/react"; import { Text } from "@/components/atoms/Text/Text"; -import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer"; +import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer"; import { useRunInputDialog } from "./useRunInputDialog"; import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts index f0bb3b1c98..a71ad0bd07 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts @@ -8,7 +8,7 @@ import { import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; import { useMemo, useState } from "react"; import { uiSchema } from "../../../FlowEditor/nodes/uiSchema"; -import { isCredentialFieldSchema } from "@/components/renderers/input-renderer/fields/CredentialField/helpers"; +import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers"; export const useRunInputDialog = ({ setIsOpen, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx index 520addd50f..905d1d4680 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx @@ -12,16 +12,59 @@ import { import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup"; import { Text } from "@/components/atoms/Text/Text"; import { AnimatePresence, motion } from "framer-motion"; +import { DraftDiff } from "@/lib/dexie/draft-utils"; interface DraftRecoveryPopupProps { isInitialLoadComplete: boolean; } +function formatDiffSummary(diff: DraftDiff | null): string { + if (!diff) return ""; + + const parts: string[] = []; + + // Node changes + const nodeChanges: string[] = []; + if (diff.nodes.added > 0) nodeChanges.push(`+${diff.nodes.added}`); + if (diff.nodes.removed > 0) nodeChanges.push(`-${diff.nodes.removed}`); + if (diff.nodes.modified > 0) nodeChanges.push(`~${diff.nodes.modified}`); + + if (nodeChanges.length > 0) { + parts.push( + `${nodeChanges.join("/")} block${diff.nodes.added + diff.nodes.removed + diff.nodes.modified !== 1 ? "s" : ""}`, + ); + } + + // Edge changes + const edgeChanges: string[] = []; + if (diff.edges.added > 0) edgeChanges.push(`+${diff.edges.added}`); + if (diff.edges.removed > 0) edgeChanges.push(`-${diff.edges.removed}`); + if (diff.edges.modified > 0) edgeChanges.push(`~${diff.edges.modified}`); + + if (edgeChanges.length > 0) { + parts.push( + `${edgeChanges.join("/")} connection${diff.edges.added + diff.edges.removed + diff.edges.modified !== 1 ? "s" : ""}`, + ); + } + + return parts.join(", "); +} + export function DraftRecoveryPopup({ isInitialLoadComplete, }: DraftRecoveryPopupProps) { - const { isOpen, popupRef, nodeCount, edgeCount, savedAt, onLoad, onDiscard } = - useDraftRecoveryPopup(isInitialLoadComplete); + const { + isOpen, + popupRef, + nodeCount, + edgeCount, + diff, + savedAt, + onLoad, + onDiscard, + } = useDraftRecoveryPopup(isInitialLoadComplete); + + const diffSummary = formatDiffSummary(diff); return ( @@ -72,10 +115,9 @@ export function DraftRecoveryPopup({ variant="small" className="text-amber-700 dark:text-amber-400" > - {nodeCount} block{nodeCount !== 1 ? "s" : ""}, {edgeCount}{" "} - connection - {edgeCount !== 1 ? "s" : ""} •{" "} - {formatTimeAgo(new Date(savedAt).toISOString())} + {diffSummary || + `${nodeCount} block${nodeCount !== 1 ? "s" : ""}, ${edgeCount} connection${edgeCount !== 1 ? "s" : ""}`}{" "} + • {formatTimeAgo(new Date(savedAt).toISOString())} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx index 0914b04952..7a77f7b4cc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx @@ -9,6 +9,7 @@ export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => { savedAt, nodeCount, edgeCount, + diff, loadDraft: onLoad, discardDraft: onDiscard, } = useDraftManager(isInitialLoadComplete); @@ -54,6 +55,7 @@ export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => { isOpen, nodeCount, edgeCount, + diff, savedAt, onLoad, onDiscard, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts index c05f00b5fb..890d1982c8 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts @@ -48,8 +48,6 @@ export const resolveCollisions: CollisionAlgorithm = ( const width = (node.width ?? node.measured?.width ?? 0) + margin * 2; const height = (node.height ?? node.measured?.height ?? 0) + margin * 2; - console.log("width", width); - console.log("height", height); const x = node.position.x - margin; const y = node.position.y - margin; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts index f6d03923bd..a38def74f6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts @@ -7,7 +7,12 @@ import { DraftData, } from "@/services/builder-draft/draft-service"; import { BuilderDraft } from "@/lib/dexie/db"; -import { cleanNodes, cleanEdges } from "@/lib/dexie/draft-utils"; +import { + cleanNodes, + cleanEdges, + calculateDraftDiff, + DraftDiff, +} from "@/lib/dexie/draft-utils"; import { useNodeStore } from "../../../stores/nodeStore"; import { useEdgeStore } from "../../../stores/edgeStore"; import { useGraphStore } from "../../../stores/graphStore"; @@ -19,6 +24,7 @@ const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds interface DraftRecoveryState { isOpen: boolean; draft: BuilderDraft | null; + diff: DraftDiff | null; } /** @@ -31,6 +37,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) { const [state, setState] = useState({ isOpen: false, draft: null, + diff: null, }); const [{ flowID, flowVersion }] = useQueryStates({ @@ -207,9 +214,16 @@ export function useDraftManager(isInitialLoadComplete: boolean) { ); if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) { + const diff = calculateDraftDiff( + draft.nodes, + draft.edges, + currentNodes, + currentEdges, + ); setState({ isOpen: true, draft, + diff, }); } else { await draftService.deleteDraft(effectiveFlowId); @@ -231,6 +245,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) { setState({ isOpen: false, draft: null, + diff: null, }); }, [flowID]); @@ -242,8 +257,10 @@ export function useDraftManager(isInitialLoadComplete: boolean) { try { useNodeStore.getState().setNodes(draft.nodes); useEdgeStore.getState().setEdges(draft.edges); + draft.nodes.forEach((node) => { + useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id); + }); - // Restore nodeCounter to prevent ID conflicts when adding new nodes if (draft.nodeCounter !== undefined) { useNodeStore.setState({ nodeCounter: draft.nodeCounter }); } @@ -267,6 +284,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) { setState({ isOpen: false, draft: null, + diff: null, }); } catch (error) { console.error("[DraftRecovery] Failed to load draft:", error); @@ -275,7 +293,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) { const discardDraft = useCallback(async () => { if (!state.draft) { - setState({ isOpen: false, draft: null }); + setState({ isOpen: false, draft: null, diff: null }); return; } @@ -285,7 +303,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) { console.error("[DraftRecovery] Failed to discard draft:", error); } - setState({ isOpen: false, draft: null }); + setState({ isOpen: false, draft: null, diff: null }); }, [state.draft]); return { @@ -294,6 +312,7 @@ export function useDraftManager(isInitialLoadComplete: boolean) { savedAt: state.draft?.savedAt ?? 0, nodeCount: state.draft?.nodes.length ?? 0, edgeCount: state.draft?.edges.length ?? 0, + diff: state.diff, loadDraft, discardDraft, }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts index 7514611f08..407482073f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts @@ -121,6 +121,14 @@ export const useFlow = () => { if (customNodes.length > 0) { useNodeStore.getState().setNodes([]); addNodes(customNodes); + + // Sync hardcoded values with handle IDs. + // If a key–value field has a key without a value, the backend omits it from hardcoded values. + // But if a handleId exists for that key, it causes inconsistency. + // This ensures hardcoded values stay in sync with handle IDs. + customNodes.forEach((node) => { + useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id); + }); } }, [customNodes, addNodes]); 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 index 8d27f346ef..bf4ba3a418 100644 --- 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 @@ -1,12 +1,17 @@ -import { Connection as RFConnection, EdgeChange } from "@xyflow/react"; +import { + Connection as RFConnection, + EdgeChange, + applyEdgeChanges, +} from "@xyflow/react"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useCallback } from "react"; import { useNodeStore } from "../../../stores/nodeStore"; +import { CustomEdge } from "./CustomEdge"; export const useCustomEdge = () => { const edges = useEdgeStore((s) => s.edges); const addEdge = useEdgeStore((s) => s.addEdge); - const removeEdge = useEdgeStore((s) => s.removeEdge); + const setEdges = useEdgeStore((s) => s.setEdges); const onConnect = useCallback( (conn: RFConnection) => { @@ -45,14 +50,10 @@ export const useCustomEdge = () => { ); const onEdgesChange = useCallback( - (changes: EdgeChange[]) => { - changes.forEach((change) => { - if (change.type === "remove") { - removeEdge(change.id); - } - }); + (changes: EdgeChange[]) => { + setEdges(applyEdgeChanges(changes, edges)); }, - [removeEdge], + [edges, setEdges], ); 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 index 4eb2437b65..99edb00c45 100644 --- 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 @@ -1,26 +1,32 @@ import { CircleIcon } from "@phosphor-icons/react"; import { Handle, Position } from "@xyflow/react"; +import { useEdgeStore } from "../../../stores/edgeStore"; +import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers"; +import { cn } from "@/lib/utils"; -const NodeHandle = ({ +const InputNodeHandle = ({ handleId, - isConnected, - side, + nodeId, }: { handleId: string; - isConnected: boolean; - side: "left" | "right"; + nodeId: string; }) => { + const cleanedHandleId = cleanUpHandleId(handleId); + const isInputConnected = useEdgeStore((state) => + state.isInputConnected(nodeId ?? "", cleanedHandleId), + ); + return (
@@ -28,4 +34,35 @@ const NodeHandle = ({ ); }; -export default NodeHandle; +const OutputNodeHandle = ({ + field_name, + nodeId, + hexColor, +}: { + field_name: string; + nodeId: string; + hexColor: string; +}) => { + const isOutputConnected = useEdgeStore((state) => + state.isOutputConnected(nodeId, field_name), + ); + return ( + +
+ +
+
+ ); +}; + +export { InputNodeHandle, OutputNodeHandle }; 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 index ecacc83146..afaa85a38a 100644 --- 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 @@ -1,31 +1,4 @@ -/** - * 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("_") || ""; -}; +// Here we are handling single level of nesting, if need more in future then i will update it const sanitizeForHandleId = (str: string): string => { if (!str) return ""; @@ -38,51 +11,53 @@ const sanitizeForHandleId = (str: string): string => { .replace(/^_|_$/g, ""); // Remove leading/trailing underscores }; -export const generateHandleId = ( +const cleanTitleId = (id: string): string => { + if (!id) return ""; + + if (id.endsWith("_title")) { + id = id.slice(0, -6); + } + const parts = id.split("_"); + const filtered = parts.filter( + (p) => p !== "root" && p !== "properties" && p.length > 0, + ); + const filtered_id = filtered.join("_") || ""; + return filtered_id; +}; + +export const generateHandleIdFromTitleId = ( fieldKey: string, - nestedValues: string[] = [], - type: HandleIdType = HandleIdType.SIMPLE, + { + isObjectProperty, + isAdditionalProperty, + isArrayItem, + }: { + isArrayItem?: boolean; + isObjectProperty?: boolean; + isAdditionalProperty?: boolean; + } = { + isArrayItem: false, + isObjectProperty: false, + isAdditionalProperty: false, + }, ): string => { if (!fieldKey) return ""; - fieldKey = fromRjsfId(fieldKey); - fieldKey = sanitizeForHandleId(fieldKey); + const filteredKey = cleanTitleId(fieldKey); + if (isAdditionalProperty || isArrayItem) { + return filteredKey; + } + const cleanedKey = sanitizeForHandleId(filteredKey); - if (type === HandleIdType.SIMPLE || nestedValues.length === 0) { - return fieldKey; + if (isObjectProperty) { + // "config_api_key" -> "config.api_key" + const parts = cleanedKey.split("_"); + if (parts.length >= 2) { + const baseName = parts[0]; + const propertyName = parts.slice(1).join("_"); + return `${baseName}.${propertyName}`; + } } - const sanitizedNestedValues = nestedValues.map((value) => - sanitizeForHandleId(value), - ); - - switch (type) { - case HandleIdType.NESTED: - return [fieldKey, ...sanitizedNestedValues].join("."); - - case HandleIdType.ARRAY: - return [fieldKey, ...sanitizedNestedValues].join("_$_"); - - case HandleIdType.KEY_VALUE: - return [fieldKey, ...sanitizedNestedValues].join("_#_"); - - default: - return fieldKey; - } -}; - -export const parseKeyValueHandleId = ( - handleId: string, - type: HandleIdType, -): string => { - if (type === HandleIdType.KEY_VALUE) { - return handleId.split("_#_")[1]; - } else if (type === HandleIdType.ARRAY) { - return handleId.split("_$_")[1]; - } else if (type === HandleIdType.NESTED) { - return handleId.split(".")[1]; - } else if (type === HandleIdType.SIMPLE) { - return handleId.split("_")[1]; - } - return ""; + return cleanedKey; }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx index 52068f3acb..3523079b71 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx @@ -10,7 +10,7 @@ import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutio import { NodeContainer } from "./components/NodeContainer"; import { NodeHeader } from "./components/NodeHeader"; import { FormCreator } from "../FormCreator"; -import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor"; +import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor"; import { OutputHandler } from "../OutputHandler"; import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle"; import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput"; @@ -99,7 +99,7 @@ export const CustomNode: React.FC> = React.memo( nodeId={nodeId} uiType={data.uiType} className={cn( - "bg-white pr-6", + "bg-white px-4", isWebhook && "pointer-events-none opacity-50", )} showHandles={showHandles} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx index 4903f2e020..950db1657f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx @@ -8,7 +8,7 @@ export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => { ); const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced); return ( -
+
Advanced diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx index f8d5b2e089..da9c13335f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx @@ -22,7 +22,7 @@ export const NodeContainer = ({ return (
state.updateNodeData); const title = (data.metadata?.customized_name as string) || data.title; const [isEditingTitle, setIsEditingTitle] = useState(false); - const [editedTitle, setEditedTitle] = useState(title); + const [editedTitle, setEditedTitle] = useState( + beautifyString(title).replace("Block", "").trim(), + ); const handleTitleEdit = () => { updateNodeData(nodeId, { @@ -41,7 +43,7 @@ export const NodeHeader = ({ }; return ( -
+
{/* Title row with context menu */}
@@ -68,12 +70,12 @@ export const NodeHeader = ({
- {beautifyString(title)} + {beautifyString(title).replace("Block", "").trim()}
-

{beautifyString(title)}

+

{beautifyString(title).replace("Block", "").trim()}

diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx index a4b53e3ac3..3f0ae6e350 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx @@ -23,7 +23,7 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => { } return ( -
+
Node Output diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx index 5d57c6c5b6..f900b1633f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { FormCreator } from "../../FormCreator"; -import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor"; +import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor"; import { CustomNodeData } from "../CustomNode"; import { Text } from "@/components/atoms/Text/Text"; import { cn } from "@/lib/utils"; 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 index cfee0bf89f..28d1bcc0ab 100644 --- 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 @@ -3,7 +3,7 @@ import React from "react"; import { uiSchema } from "./uiSchema"; import { useNodeStore } from "../../../stores/nodeStore"; import { BlockUIType } from "../../types"; -import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer"; +import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer"; export const FormCreator = React.memo( ({ 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 index ab3b648ba9..b70a8e239b 100644 --- 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 @@ -4,7 +4,7 @@ import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react"; import { RJSFSchema } from "@rjsf/utils"; import { useState } from "react"; -import NodeHandle from "../handlers/NodeHandle"; +import { OutputNodeHandle } from "../handlers/NodeHandle"; import { Tooltip, TooltipContent, @@ -13,7 +13,6 @@ import { } from "@/components/atoms/Tooltip/BaseTooltip"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { getTypeDisplayInfo } from "./helpers"; -import { generateHandleId } from "../handlers/helpers"; import { BlockUIType } from "../../types"; export const OutputHandler = ({ @@ -29,8 +28,73 @@ export const OutputHandler = ({ const properties = outputSchema?.properties || {}; const [isOutputVisible, setIsOutputVisible] = useState(true); + const showHandles = uiType !== BlockUIType.OUTPUT; + + const renderOutputHandles = ( + schema: RJSFSchema, + keyPrefix: string = "", + titlePrefix: string = "", + ): React.ReactNode[] => { + return Object.entries(schema).map( + ([key, fieldSchema]: [string, RJSFSchema]) => { + const fullKey = keyPrefix ? `${keyPrefix}_#_${key}` : key; + const fieldTitle = titlePrefix + (fieldSchema?.title || key); + + const isConnected = isOutputConnected(nodeId, fullKey); + const shouldShow = isConnected || isOutputVisible; + const { displayType, colorClass, hexColor } = + getTypeDisplayInfo(fieldSchema); + + return shouldShow ? ( +
+
+ {fieldSchema?.description && ( + + + + + + + + {fieldSchema?.description} + + + )} + + {fieldTitle} + + + ({displayType}) + + + {showHandles && ( + + )} +
+ + {/* Recursively render nested properties */} + {fieldSchema?.properties && + renderOutputHandles( + fieldSchema.properties, + fullKey, + `${fieldTitle}.`, + )} +
+ ) : null; + }, + ); + }; + 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; - })} -
- } +
+ {renderOutputHandles(properties)} +
); }; 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 index 5572426dc7..39384485f5 100644 --- 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 @@ -92,14 +92,38 @@ export const getTypeDisplayInfo = (schema: any) => { if (schema?.type === "string" && schema?.format) { const formatMap: Record< string, - { displayType: string; colorClass: string } + { displayType: string; colorClass: string; hexColor: 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" }, + file: { + displayType: "file", + colorClass: "!text-green-500", + hexColor: "#22c55e", + }, + date: { + displayType: "date", + colorClass: "!text-blue-500", + hexColor: "#3b82f6", + }, + time: { + displayType: "time", + colorClass: "!text-blue-500", + hexColor: "#3b82f6", + }, + "date-time": { + displayType: "datetime", + colorClass: "!text-blue-500", + hexColor: "#3b82f6", + }, + "long-text": { + displayType: "text", + colorClass: "!text-green-500", + hexColor: "#22c55e", + }, + "short-text": { + displayType: "text", + colorClass: "!text-green-500", + hexColor: "#22c55e", + }, }; const formatInfo = formatMap[schema.format]; @@ -131,10 +155,23 @@ export const getTypeDisplayInfo = (schema: any) => { any: "!text-gray-500", }; + const hexColorMap: Record = { + string: "#22c55e", + number: "#3b82f6", + integer: "#3b82f6", + boolean: "#eab308", + object: "#a855f7", + array: "#6366f1", + null: "#6b7280", + any: "#6b7280", + }; + const colorClass = colorMap[schema?.type] || "!text-gray-500"; + const hexColor = hexColorMap[schema?.type] || "#6b7280"; return { displayType, colorClass, + hexColor, }; }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts index cae1d995da..7b17eecfb3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts @@ -4,6 +4,7 @@ import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge"; import { customEdgeToLink, linkToCustomEdge } from "../components/helper"; import { MarkerType } from "@xyflow/react"; import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult"; +import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers"; type EdgeStore = { edges: CustomEdge[]; @@ -13,6 +14,8 @@ type EdgeStore = { removeEdge: (edgeId: string) => void; upsertMany: (edges: CustomEdge[]) => void; + removeEdgesByHandlePrefix: (nodeId: string, handlePrefix: string) => void; + getNodeEdges: (nodeId: string) => CustomEdge[]; isInputConnected: (nodeId: string, handle: string) => boolean; isOutputConnected: (nodeId: string, handle: string) => boolean; @@ -79,11 +82,27 @@ export const useEdgeStore = create((set, get) => ({ return { edges: Array.from(byKey.values()) }; }), + removeEdgesByHandlePrefix: (nodeId, handlePrefix) => + set((state) => ({ + edges: state.edges.filter( + (e) => + !( + e.target === nodeId && + e.targetHandle && + e.targetHandle.startsWith(handlePrefix) + ), + ), + })), + getNodeEdges: (nodeId) => get().edges.filter((e) => e.source === nodeId || e.target === nodeId), - isInputConnected: (nodeId, handle) => - get().edges.some((e) => e.target === nodeId && e.targetHandle === handle), + isInputConnected: (nodeId, handle) => { + const cleanedHandle = cleanUpHandleId(handle); + return get().edges.some( + (e) => e.target === nodeId && e.targetHandle === cleanedHandle, + ); + }, isOutputConnected: (nodeId, handle) => get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle), @@ -105,15 +124,15 @@ export const useEdgeStore = create((set, get) => ({ targetNodeId: string, executionResult: NodeExecutionResult, ) => { - set((state) => ({ - edges: state.edges.map((edge) => { + set((state) => { + let hasChanges = false; + + const newEdges = state.edges.map((edge) => { if (edge.target !== targetNodeId) { return edge; } - const beadData = - edge.data?.beadData ?? - new Map(); + const beadData = new Map(edge.data?.beadData ?? new Map()); const inputValue = edge.targetHandle ? executionResult.input_data[edge.targetHandle] @@ -137,6 +156,11 @@ export const useEdgeStore = create((set, get) => ({ beadUp = beadDown + 1; } + if (edge.data?.beadUp === beadUp && edge.data?.beadDown === beadDown) { + return edge; + } + + hasChanges = true; return { ...edge, data: { @@ -146,8 +170,10 @@ export const useEdgeStore = create((set, get) => ({ beadData, }, }; - }), - })); + }); + + return hasChanges ? { edges: newEdges } : state; + }); }, resetEdgeBeads: () => { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts index 2f41c3bb46..96478c5b6f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -13,6 +13,10 @@ import { useHistoryStore } from "./historyStore"; import { useEdgeStore } from "./edgeStore"; import { BlockUIType } from "../components/types"; import { pruneEmptyValues } from "@/lib/utils"; +import { + ensurePathExists, + parseHandleIdToPath, +} from "@/components/renderers/InputRenderer/helpers"; // Minimum movement (in pixels) required before logging position change to history // Prevents spamming history with small movements when clicking on inputs inside blocks @@ -62,6 +66,8 @@ type NodeStore = { errors: { [key: string]: string }, ) => void; clearAllNodeErrors: () => void; // Add this + + syncHardcodedValuesWithHandleIds: (nodeId: string) => void; }; export const useNodeStore = create((set, get) => ({ @@ -305,4 +311,35 @@ export const useNodeStore = create((set, get) => ({ })), })); }, + + syncHardcodedValuesWithHandleIds: (nodeId: string) => { + const node = get().nodes.find((n) => n.id === nodeId); + if (!node) return; + + const handleIds = useEdgeStore.getState().getAllHandleIdsOfANode(nodeId); + const additionalHandles = handleIds.filter((h) => h.includes("_#_")); + + if (additionalHandles.length === 0) return; + + const hardcodedValues = JSON.parse( + JSON.stringify(node.data.hardcodedValues || {}), + ); + + let modified = false; + + additionalHandles.forEach((handleId) => { + const segments = parseHandleIdToPath(handleId); + if (ensurePathExists(hardcodedValues, segments)) { + modified = true; + } + }); + + if (modified) { + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === nodeId ? { ...n, data: { ...n.data, hardcodedValues } } : n, + ), + })); + } + }, })); diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx index 07350fb610..60d61fab57 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx @@ -143,6 +143,7 @@ export function CredentialsInput({ size="small" onClick={handleActionButtonClick} className="w-fit" + type="button" > {actionButtonText} @@ -155,6 +156,7 @@ export function CredentialsInput({ size="small" onClick={handleActionButtonClick} className="w-fit" + type="button" > {actionButtonText} diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx index 7d8ead9df7..17800a03a4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/integrations/page.tsx @@ -13,7 +13,7 @@ import { import { Button } from "@/components/atoms/Button/Button"; import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { useToast } from "@/components/molecules/Toast/use-toast"; -import { providerIcons } from "@/components/renderers/input-renderer/fields/CredentialField/helpers"; +import { providerIcons } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers"; import { CredentialsProviderName } from "@/lib/autogpt-server-api"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider"; diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx index 01b98bcdac..e0a43b8c77 100644 --- a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx +++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx @@ -49,6 +49,7 @@ export function GoogleDrivePicker(props: Props) { )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/IconButton.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/IconButton.tsx new file mode 100644 index 0000000000..4a7f011b82 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/IconButton.tsx @@ -0,0 +1,101 @@ +import { + FormContextType, + IconButtonProps, + RJSFSchema, + StrictRJSFSchema, + TranslatableString, +} from "@rjsf/utils"; +import { ChevronDown, ChevronUp, Copy } from "lucide-react"; +import type { VariantProps } from "class-variance-authority"; + +import { Button } from "@/components/atoms/Button/Button"; +import { extendedButtonVariants } from "@/components/atoms/Button/helpers"; +import { TrashIcon } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; +import { Text } from "@/components/atoms/Text/Text"; + +export type AutogptIconButtonProps< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +> = IconButtonProps & VariantProps; + +export default function IconButton(props: AutogptIconButtonProps) { + const { + icon, + className, + uiSchema: _uiSchema, + registry: _registry, + iconType: _iconType, + ...otherProps + } = props; + + return ( + + ); +} + +export function CopyButton(props: AutogptIconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} + +export function MoveDownButton(props: AutogptIconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} + +export function MoveUpButton(props: AutogptIconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} + +export function RemoveButton(props: AutogptIconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/index.ts new file mode 100644 index 0000000000..f083306249 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/buttons/index.ts @@ -0,0 +1,8 @@ +export { default as AddButton } from "./AddButton"; +export { + default as IconButton, + CopyButton, + RemoveButton, + MoveUpButton, + MoveDownButton, +} from "./IconButton"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/ErrorList.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/ErrorList.tsx new file mode 100644 index 0000000000..39175ef13e --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/ErrorList.tsx @@ -0,0 +1,24 @@ +import { ErrorListProps, TranslatableString } from "@rjsf/utils"; +import { AlertCircle } from "lucide-react"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/molecules/Alert/Alert"; + +export default function ErrorList(props: ErrorListProps) { + const { errors, registry } = props; + const { translateString } = registry; + return ( + + + {translateString(TranslatableString.ErrorsLabel)} + + {errors.map((error, i: number) => { + return • {error.stack}; + })} + + + ); +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/index.ts new file mode 100644 index 0000000000..ccecd3c8c6 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/errors/index.ts @@ -0,0 +1 @@ +export { default as ErrorList } from "./ErrorList"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/helpers.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/helpers.ts new file mode 100644 index 0000000000..327a0e06b6 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/helpers.ts @@ -0,0 +1,76 @@ +import { RJSFSchema } from "@rjsf/utils"; + +export function parseFieldPath( + rootSchema: RJSFSchema, + id: string, + additional: boolean, + idSeparator: string = "_%_", +): { path: string[]; typeHints: string[] } { + const segments = id.split(idSeparator).filter(Boolean); + const typeHints: string[] = []; + + let currentSchema = rootSchema; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isNumeric = /^\d+$/.test(segment); + + if (isNumeric) { + typeHints.push("array"); + } else { + if (additional) { + typeHints.push("object-key"); + } else { + typeHints.push("object-property"); + } + currentSchema = (currentSchema.properties?.[segment] as RJSFSchema) || {}; + } + } + + return { path: segments, typeHints }; +} + +// This helper work is simple - it just help us to convert rjsf id to our backend compatible id +// Example : List[dict] = agpt_%_List_0_dict__title -> List_$_0_#_dict +// We remove the prefix and suffix and then we split id by our custom delimiter (_%_) +// then add _$_ delimiter for array and _#_ delimiter for object-key +// and for normal property we add . delimiter + +export function getHandleId( + rootSchema: RJSFSchema, + id: string, + additional: boolean, + idSeparator: string = "_%_", +): string { + const idPrefix = "agpt_%_"; + const idSuffix = "__title"; + + if (id.startsWith(idPrefix)) { + id = id.slice(idPrefix.length); + } + if (id.endsWith(idSuffix)) { + id = id.slice(0, -idSuffix.length); + } + + const { path, typeHints } = parseFieldPath( + rootSchema, + id, + additional, + idSeparator, + ); + + return path + .map((seg, i) => { + const type = typeHints[i]; + if (type === "array") { + return `_$_${seg}`; + } + if (type === "object-key") { + return `_${seg}`; // we haven't added _#_ delimiter for object-key because it's already added in the id - check WrapIfAdditionalTemplate.tsx + } + + return `.${seg}`; + }) + .join("") + .slice(1); +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/index.ts new file mode 100644 index 0000000000..870753151c --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/index.ts @@ -0,0 +1,3 @@ +export { default as FieldTemplate } from "./FieldTemplate"; +export { default as TitleField } from "./TitleField"; +export { default as DescriptionField } from "./DescriptionField"; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/SwitchWidget.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/CheckboxInput/CheckBoxWidget.tsx similarity index 87% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/SwitchWidget.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/CheckboxInput/CheckBoxWidget.tsx index d15ec18a9a..ff93528492 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/SwitchWidget.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/CheckboxInput/CheckBoxWidget.tsx @@ -1,8 +1,9 @@ import { WidgetProps } from "@rjsf/utils"; import { Switch } from "@/components/atoms/Switch/Switch"; -export function SwitchWidget(props: WidgetProps) { +export function CheckboxWidget(props: WidgetProps) { const { value = false, onChange, disabled, readonly, autofocus, id } = props; + return ( { +export const DateWidget = (props: WidgetProps) => { const { value, onChange, diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateInput/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateInput/index.ts new file mode 100644 index 0000000000..3ba465c4f4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateInput/index.ts @@ -0,0 +1 @@ +export { DateWidget } from "./DateWidget"; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/DateTimeInputWidget.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/DateTimeWidget.tsx similarity index 91% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/DateTimeInputWidget.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/DateTimeWidget.tsx index 2e85a610b5..50f6e378fb 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/DateTimeInputWidget.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/DateTimeWidget.tsx @@ -1,7 +1,7 @@ import { WidgetProps } from "@rjsf/utils"; import { DateTimeInput } from "@/components/atoms/DateTimeInput/DateTimeInput"; -export const DateTimeInputWidget = (props: WidgetProps) => { +export const DateTimeWidget = (props: WidgetProps) => { const { value, onChange, diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/index.ts new file mode 100644 index 0000000000..bf7c084f5a --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/DateTimeInput/index.ts @@ -0,0 +1 @@ +export { DateTimeWidget } from "./DateTimeWidget"; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/FileWidget.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/FileInput/FileWidget.tsx similarity index 100% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/FileWidget.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/FileInput/FileWidget.tsx diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/FileInput/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/FileInput/index.ts new file mode 100644 index 0000000000..e23b50bfd0 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/FileInput/index.ts @@ -0,0 +1 @@ +export { FileWidget } from "./FileWidget"; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/SelectWidget.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/SelectWidget.tsx similarity index 87% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/SelectWidget.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/SelectWidget.tsx index db68a1628c..894004db40 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/SelectWidget.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/SelectWidget.tsx @@ -14,8 +14,16 @@ import { } from "@/components/__legacy__/ui/multiselect"; export const SelectWidget = (props: WidgetProps) => { - const { options, value, onChange, disabled, readonly, id, formContext } = - props; + const { + options, + value, + onChange, + disabled, + readonly, + className, + id, + formContext, + } = props; const enumOptions = options.enumOptions || []; const type = mapJsonSchemaTypeToInputType(props.schema); const { size = "small" } = formContext || {}; @@ -36,7 +44,7 @@ export const SelectWidget = (props: WidgetProps) => { - {enumOptions?.map((option) => ( + {enumOptions?.map((option: any) => ( {option.label} @@ -56,12 +64,13 @@ export const SelectWidget = (props: WidgetProps) => { value={value ?? ""} onValueChange={onChange} options={ - enumOptions?.map((option) => ({ + enumOptions?.map((option: any) => ({ value: option.value, label: option.label, })) || [] } wrapperClassName="!mb-0 " + className={className} /> ); }; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/index.ts new file mode 100644 index 0000000000..9a64291abc --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/SelectInput/index.ts @@ -0,0 +1 @@ +export { SelectWidget } from "./SelectWidget"; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TextInputWidget/InputExpanderModal.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/TextInputExpanderModal.tsx similarity index 100% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TextInputWidget/InputExpanderModal.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/TextInputExpanderModal.tsx diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TextInputWidget/TextInputWidget.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/TextWidget.tsx similarity index 91% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TextInputWidget/TextInputWidget.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/TextWidget.tsx index 83fe826223..33a55581c7 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TextInputWidget/TextInputWidget.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/TextWidget.tsx @@ -14,15 +14,12 @@ import { TooltipTrigger, } from "@/components/atoms/Tooltip/BaseTooltip"; import { BlockUIType } from "@/lib/autogpt-server-api/types"; -import { InputExpanderModal } from "./InputExpanderModal"; import { ArrowsOutIcon } from "@phosphor-icons/react"; +import { InputExpanderModal } from "./TextInputExpanderModal"; -export const TextInputWidget = (props: WidgetProps) => { - const { schema, formContext } = props; - const { uiType, size = "small" } = formContext as { - uiType: BlockUIType; - size?: string; - }; +export default function TextWidget(props: WidgetProps) { + const { schema, placeholder, registry } = props; + const { size, uiType } = registry.formContext; const [isModalOpen, setIsModalOpen] = useState(false); @@ -51,7 +48,7 @@ export const TextInputWidget = (props: WidgetProps) => { handleChange: (v: string) => (v === "" ? undefined : Number(v)), }, [InputType.INTEGER]: { - htmlType: "number", + htmlType: "account", placeholder: "Enter integer value...", handleChange: (v: string) => (v === "" ? undefined : Number(v)), }, @@ -122,7 +119,7 @@ export const TextInputWidget = (props: WidgetProps) => { wrapperClassName="mb-0 flex-1" value={props.value ?? ""} onChange={handleChange} - placeholder={schema.placeholder || config.placeholder} + placeholder={placeholder || config.placeholder} required={props.required} disabled={props.disabled} className={showExpandButton ? "pr-8" : ""} @@ -152,8 +149,8 @@ export const TextInputWidget = (props: WidgetProps) => { title={schema.title || "Edit value"} description={schema.description || ""} defaultValue={props.value ?? ""} - placeholder={schema.placeholder || config.placeholder} + placeholder={placeholder || config.placeholder} /> ); -}; +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/index.ts new file mode 100644 index 0000000000..102db07ade --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TextInput/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TextWidget"; +export { InputExpanderModal } from "./TextInputExpanderModal"; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TimeInputWidget.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/TimeWidget.tsx similarity index 91% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TimeInputWidget.tsx rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/TimeWidget.tsx index 032c33e62c..152aae7298 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/TimeInputWidget.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/TimeWidget.tsx @@ -1,7 +1,7 @@ import { WidgetProps } from "@rjsf/utils"; import { TimeInput } from "@/components/atoms/TimeInput/TimeInput"; -export const TimeInputWidget = (props: WidgetProps) => { +export const TimeWidget = (props: WidgetProps) => { const { value, onChange, disabled, readonly, placeholder, id, formContext } = props; const { size = "small" } = formContext || {}; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/index.ts new file mode 100644 index 0000000000..488e184c08 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/TimeInput/index.ts @@ -0,0 +1 @@ +export { TimeWidget } from "./TimeWidget"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/index.ts new file mode 100644 index 0000000000..5a370422e2 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/standard/widgets/index.ts @@ -0,0 +1,7 @@ +export { default as TextWidget } from "./TextInput"; +export { SelectWidget } from "./SelectInput"; +export { CheckboxWidget } from "./CheckboxInput"; +export { FileWidget } from "./FileInput"; +export { DateWidget } from "./DateInput"; +export { TimeWidget } from "./TimeInput"; +export { DateTimeWidget } from "./DateTimeInput"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/constants.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/constants.ts new file mode 100644 index 0000000000..144d850a00 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/constants.ts @@ -0,0 +1,8 @@ +export const ANY_OF_FLAG = "__anyOf"; +export const ARRAY_FLAG = "__array"; +export const OBJECT_FLAG = "__object"; +export const KEY_PAIR_FLAG = "__keyPair"; +export const TITLE_FLAG = "__title"; +export const ARRAY_ITEM_FLAG = "__arrayItem"; +export const ID_PREFIX = "agpt_@_"; +export const ID_PREFIX_ARRAY = "agpt_%_"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx new file mode 100644 index 0000000000..f814fba93f --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from "react"; +import { FieldProps, getUiOptions } from "@rjsf/utils"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api"; +import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; +import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; +import { useShallow } from "zustand/react/shallow"; +import { CredentialFieldTitle } from "./components/CredentialFieldTitle"; + +export const CredentialsField = (props: FieldProps) => { + const { formData, onChange, schema, registry, fieldPathId } = props; + + const formContext = registry.formContext; + const uiOptions = getUiOptions(props.uiSchema); + const nodeId = formContext?.nodeId; + + // Get sibling inputs (hardcoded values) from the node store + const hardcodedValues = useNodeStore( + useShallow((state) => (nodeId ? state.getHardCodedValues(nodeId) : {})), + ); + + const handleChange = (newValue: any) => { + onChange(newValue, fieldPathId?.path); + }; + + const handleSelectCredentials = (credentialsMeta?: CredentialsMetaInput) => { + if (credentialsMeta) { + handleChange({ + id: credentialsMeta.id, + provider: credentialsMeta.provider, + title: credentialsMeta.title, + type: credentialsMeta.type, + }); + } else { + handleChange(undefined); + } + }; + + // Convert formData to CredentialsMetaInput format + const selectedCredentials: CredentialsMetaInput | undefined = useMemo( + () => + formData?.id + ? { + id: formData.id, + provider: formData.provider, + title: formData.title, + type: formData.type, + } + : undefined, + [formData?.id, formData?.provider, formData?.title, formData?.type], + ); + + return ( +
+ + +
+ ); +}; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/components/CredentialFieldTitle.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/components/CredentialFieldTitle.tsx new file mode 100644 index 0000000000..ca14c8a4ce --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/components/CredentialFieldTitle.tsx @@ -0,0 +1,66 @@ +import { + getTemplate, + UiSchema, + Registry, + RJSFSchema, + FieldPathId, + titleId, + descriptionId, +} from "@rjsf/utils"; +import { getCredentialProviderFromSchema, toDisplayName } from "../helpers"; +import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; +import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api"; +import { updateUiOption } from "../../../helpers"; +import { uiSchema } from "@/app/(platform)/build/components/FlowEditor/nodes/uiSchema"; + +export const CredentialFieldTitle = (props: { + registry: Registry; + uiOptions: UiSchema; + schema: RJSFSchema; + fieldPathId: FieldPathId; +}) => { + const { registry, uiOptions, schema, fieldPathId } = props; + const { nodeId } = registry.formContext; + + const TitleFieldTemplate = getTemplate( + "TitleFieldTemplate", + registry, + uiOptions, + ); + + const DescriptionFieldTemplate = getTemplate( + "DescriptionFieldTemplate", + registry, + uiOptions, + ); + + const credentialProvider = toDisplayName( + getCredentialProviderFromSchema( + useNodeStore.getState().getHardCodedValues(nodeId), + schema as BlockIOCredentialsSubSchema, + ) ?? "", + ); + + const updatedUiSchema = updateUiOption(uiSchema, { + showHandles: false, + }); + + return ( +
+ + +
+ ); +}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/helpers.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/helpers.ts similarity index 100% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/helpers.ts rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/helpers.ts diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/GoogleDrivePickerField/GoogleDrivePickerField.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/GoogleDrivePickerField/GoogleDrivePickerField.tsx new file mode 100644 index 0000000000..51c5806ae5 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/GoogleDrivePickerField/GoogleDrivePickerField.tsx @@ -0,0 +1,21 @@ +import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput"; +import { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api"; +import { FieldProps, getUiOptions } from "@rjsf/utils"; + +export const GoogleDrivePickerField = (props: FieldProps) => { + const { schema, uiSchema, onChange, fieldPathId, formData } = props; + const uiOptions = getUiOptions(uiSchema); + const config: GoogleDrivePickerConfig = schema.google_drive_picker_config; + + return ( +
+ onChange(value, fieldPathId.path)} + className={uiOptions.className} + showRemoveButton={true} + /> +
+ ); +}; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts new file mode 100644 index 0000000000..91850e3f10 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts @@ -0,0 +1,52 @@ +import { FieldProps, RJSFSchema, RegistryFieldsType } from "@rjsf/utils"; +import { CredentialsField } from "./CredentialField/CredentialField"; +import { GoogleDrivePickerField } from "./GoogleDrivePickerField/GoogleDrivePickerField"; + +export interface CustomFieldDefinition { + id: string; + matcher: (schema: any) => boolean; + component: (props: FieldProps) => JSX.Element | null; +} + +export const CUSTOM_FIELDS: CustomFieldDefinition[] = [ + { + id: "custom/credential_field", + matcher: (schema: any) => { + return ( + typeof schema === "object" && + schema !== null && + "credentials_provider" in schema + ); + }, + component: CredentialsField, + }, + { + id: "custom/google_drive_picker_field", + matcher: (schema: any) => { + return ( + "google_drive_picker_config" in schema || + ("format" in schema && schema.format === "google-drive-picker") + ); + }, + component: GoogleDrivePickerField, + }, +]; + +export function findCustomFieldId(schema: any): string | null { + for (const field of CUSTOM_FIELDS) { + if (field.matcher(schema)) { + return field.id; + } + } + return null; +} + +export function generateCustomFields(): RegistryFieldsType { + return CUSTOM_FIELDS.reduce( + (acc, field) => { + acc[field.id] = field.component; + return acc; + }, + {} as Record, + ); +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/docs/HEIRARCHY.md b/autogpt_platform/frontend/src/components/renderers/InputRenderer/docs/HEIRARCHY.md new file mode 100644 index 0000000000..45b4276946 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/docs/HEIRARCHY.md @@ -0,0 +1,291 @@ +# Input Renderer 2 - Hierarchy + +## Flow Overview + +``` +FormRenderer2 → Form (RJSF) → ObjectFieldTemplate → FieldTemplate → Widget/Field +``` + +--- + +## Component Layers + +### 1. Root (FormRenderer2) + +- Entry point +- Preprocesses schema +- Passes to RJSF Form + +### 2. Form (registry/Form.tsx) + +- RJSF themed form +- Combines: templates + widgets + fields + +### 3. Templates (decide layout/structure) + +| Template | When Used | +| -------------------------- | ------------------------------------------- | +| `ObjectFieldTemplate` | `type: "object"` | +| `ArrayFieldTemplate` | `type: "array"` | +| `FieldTemplate` | Wraps every field (title, errors, children) | +| `ArrayFieldItemTemplate` | Each array item | +| `WrapIfAdditionalTemplate` | Additional properties in objects | + +### 4. Fields (custom rendering logic) + +| Field | When Used | +| ------------------ | ---------------------------- | +| `AnyOfField` | `anyOf` or `oneOf` in schema | +| `ArraySchemaField` | Array type handling | + +### 5. Widgets (actual input elements) + +| Widget | Input Type | +| ---------------- | ----------------------- | +| `TextWidget` | string, number, integer | +| `SelectWidget` | enum, anyOf selector | +| `CheckboxWidget` | boolean | +| `FileWidget` | file upload | +| `DateWidget` | date | +| `TimeWidget` | time | +| `DateTimeWidget` | datetime | + +--- + +## Your Schema Hierarchy + +``` +Root (type: object) +└── ObjectFieldTemplate + │ + ├── name (string, required) + │ └── FieldTemplate → TextWidget + │ + ├── value (anyOf) + │ └── FieldTemplate → AnyOfField + │ └── Selector dropdown + selected type: + │ ├── String → TextWidget + │ ├── Number → TextWidget + │ ├── Integer → TextWidget + │ ├── Boolean → CheckboxWidget + │ ├── Array → ArrayFieldTemplate → items + │ ├── Object → ObjectFieldTemplate + │ └── Null → nothing + │ + ├── title (anyOf: string | null) + │ └── FieldTemplate → AnyOfField + │ └── String → TextWidget OR Null → nothing + │ + ├── description (anyOf: string | null) + │ └── FieldTemplate → AnyOfField + │ └── String → TextWidget OR Null → nothing + │ + ├── placeholder_values (array of strings) + │ └── FieldTemplate → ArrayFieldTemplate + │ └── ArrayFieldItemTemplate (per item) + │ └── TextWidget + │ + ├── advanced (boolean) + │ └── FieldTemplate → CheckboxWidget + │ + └── secret (boolean) + └── FieldTemplate → CheckboxWidget +``` + +--- + +## Nested Examples (up to 3 levels) + +### Simple Array (strings) + +```json +{ "tags": { "type": "array", "items": { "type": "string" } } } +``` + +``` +Level 1: ObjectFieldTemplate (root) +└── Level 2: FieldTemplate → ArrayFieldTemplate + └── Level 3: ArrayFieldItemTemplate → TextWidget +``` + +### Array of Objects + +```json +{ + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + } + } + } +} +``` + +``` +Level 1: ObjectFieldTemplate (root) +└── Level 2: FieldTemplate → ArrayFieldTemplate + └── Level 3: ArrayFieldItemTemplate → ObjectFieldTemplate + ├── FieldTemplate → TextWidget (name) + └── FieldTemplate → TextWidget (age) +``` + +### Nested Object (3 levels) + +```json +{ + "config": { + "type": "object", + "properties": { + "database": { + "type": "object", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer" } + } + } + } + } +} +``` + +``` +Level 1: ObjectFieldTemplate (root) +└── config + └── Level 2: FieldTemplate → ObjectFieldTemplate + └── database + └── Level 3: FieldTemplate → ObjectFieldTemplate + ├── FieldTemplate → TextWidget (host) + └── FieldTemplate → TextWidget (port) +``` + +### Array of Arrays (nested array) + +```json +{ + "matrix": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" } + } + } +} +``` + +``` +Level 1: ObjectFieldTemplate (root) +└── Level 2: FieldTemplate → ArrayFieldTemplate + └── Level 3: ArrayFieldItemTemplate → ArrayFieldTemplate + └── ArrayFieldItemTemplate → TextWidget +``` + +### Complex: Object → Array → Object + +```json +{ + "company": { + "type": "object", + "properties": { + "departments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "budget": { "type": "number" } + } + } + } + } + } +} +``` + +``` +Level 1: ObjectFieldTemplate (root) +└── company + └── Level 2: FieldTemplate → ObjectFieldTemplate + └── departments + └── Level 3: FieldTemplate → ArrayFieldTemplate + └── ArrayFieldItemTemplate → ObjectFieldTemplate + ├── FieldTemplate → TextWidget (name) + └── FieldTemplate → TextWidget (budget) +``` + +### anyOf inside Array + +```json +{ + "items": { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, + { "type": "object", "properties": { "id": { "type": "string" } } } + ] + } + } +} +``` + +``` +Level 1: ObjectFieldTemplate (root) +└── Level 2: FieldTemplate → ArrayFieldTemplate + └── Level 3: ArrayFieldItemTemplate → AnyOfField + └── Selector + selected: + ├── String → TextWidget + └── Object → ObjectFieldTemplate + └── FieldTemplate → TextWidget (id) +``` + +--- + +## Nesting Pattern Summary + +| Parent Type | Child Wrapper | +| ----------- | ----------------------------------------------- | +| object | `ObjectFieldTemplate` → `FieldTemplate` | +| array | `ArrayFieldTemplate` → `ArrayFieldItemTemplate` | +| anyOf | `AnyOfField` → selected schema's template | +| primitive | `Widget` (leaf - no children) | + +**Pattern:** Each level adds FieldTemplate wrapper except array items (use ArrayFieldItemTemplate) + +--- + +## Key Points + +1. **FieldTemplate wraps everything** - handles title, description, errors +2. **anyOf = AnyOfField** - shows dropdown to pick type, then renders selected schema +3. **ObjectFieldTemplate loops properties** - each property gets FieldTemplate +4. **ArrayFieldTemplate loops items** - each item gets ArrayFieldItemTemplate +5. **Widgets are leaf nodes** - actual input controls user interacts with +6. **Nesting repeats the pattern** - object/array/anyOf can contain object/array/anyOf recursively + +--- + +## Decision Flow + +``` +Schema Type? +├── object → ObjectFieldTemplate → loop properties +├── array → ArrayFieldTemplate → loop items +├── anyOf/oneOf → AnyOfField → selector + selected schema +└── primitive (string/number/boolean) → Widget +``` + +--- + +## Template Wrapping Order + +``` +ObjectFieldTemplate (root) +└── FieldTemplate (per property) + └── WrapIfAdditionalTemplate (if additionalProperties) + └── TitleField + DescriptionField + children + └── Widget OR nested Template/Field +``` diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/helpers.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/helpers.ts new file mode 100644 index 0000000000..f3302fcb85 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/helpers.ts @@ -0,0 +1,276 @@ +import { + RJSFSchema, + UIOptionsType, + StrictRJSFSchema, + FormContextType, + ADDITIONAL_PROPERTY_FLAG, +} from "@rjsf/utils"; + +import { + ANY_OF_FLAG, + ARRAY_ITEM_FLAG, + ID_PREFIX, + ID_PREFIX_ARRAY, + KEY_PAIR_FLAG, + OBJECT_FLAG, +} from "./constants"; +import { PathSegment } from "./types"; + +export function updateUiOption>( + uiSchema: T | undefined, + options: Record, +): T & { "ui:options": Record } { + return { + ...(uiSchema || {}), + "ui:options": { + ...uiSchema?.["ui:options"], + ...options, + }, + } as T & { "ui:options": Record }; +} + +export const cleanUpHandleId = (handleId: string) => { + let newHandleId = handleId; + if (handleId.includes(ANY_OF_FLAG)) { + newHandleId = newHandleId.replace(ANY_OF_FLAG, ""); + } + if (handleId.includes(ARRAY_ITEM_FLAG)) { + newHandleId = newHandleId.replace(ARRAY_ITEM_FLAG, ""); + } + if (handleId.includes(KEY_PAIR_FLAG)) { + newHandleId = newHandleId.replace(KEY_PAIR_FLAG, ""); + } + if (handleId.includes(OBJECT_FLAG)) { + newHandleId = newHandleId.replace(OBJECT_FLAG, ""); + } + if (handleId.includes(ID_PREFIX_ARRAY)) { + newHandleId = newHandleId.replace(ID_PREFIX_ARRAY, ""); + } + if (handleId.includes(ID_PREFIX)) { + newHandleId = newHandleId.replace(ID_PREFIX, ""); + } + return newHandleId; +}; + +export const isArrayItem = < + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + uiOptions, +}: { + uiOptions: UIOptionsType; +}) => { + return uiOptions.handleId?.endsWith(ARRAY_ITEM_FLAG); +}; + +export const isKeyValuePair = ({ schema }: { schema: RJSFSchema }) => { + return ADDITIONAL_PROPERTY_FLAG in schema; +}; + +export const isNormal = < + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + uiOptions, +}: { + uiOptions: UIOptionsType; +}) => { + return uiOptions.handleId === undefined; +}; + +export const isPartOfAnyOf = < + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + uiOptions, +}: { + uiOptions: UIOptionsType; +}) => { + return uiOptions.handleId?.endsWith(ANY_OF_FLAG); +}; +export const isObjectProperty = < + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + uiOptions, + schema, +}: { + uiOptions: UIOptionsType; + schema: RJSFSchema; +}) => { + return ( + !isArrayItem({ uiOptions }) && + !isKeyValuePair({ schema }) && + !isNormal({ uiOptions }) && + !isPartOfAnyOf({ uiOptions }) + ); +}; + +export const getHandleId = < + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + id, + schema, + uiOptions, +}: { + id: string; + schema: RJSFSchema; + uiOptions: UIOptionsType; +}) => { + const parentHandleId = uiOptions.handleId; + + if (isNormal({ uiOptions })) { + return id; + } + + if (isPartOfAnyOf({ uiOptions })) { + return parentHandleId + ANY_OF_FLAG; + } + + if (isKeyValuePair({ schema })) { + const key = id.split("_%_").at(-1); + let prefix = ""; + if (parentHandleId) { + prefix = parentHandleId; + } else { + prefix = id.split("_%_").slice(0, -1).join("_%_"); + } + + const handleId = `${prefix}_#_${key}`; + return handleId + KEY_PAIR_FLAG; + } + + if (isArrayItem({ uiOptions })) { + const index = id.split("_%_").at(-1); + const prefix = id.split("_%_").slice(0, -1).join("_%_"); + const handleId = `${prefix}_$_${index}`; + return handleId + ARRAY_ITEM_FLAG; + } + + if (isObjectProperty({ uiOptions, schema })) { + const key = id.split("_%_").at(-1); + const prefix = id.split("_%_").slice(0, -1).join("_%_"); + const handleId = `${prefix}_@_${key}`; + return handleId + OBJECT_FLAG; + } + return parentHandleId; +}; + +export function isCredentialFieldSchema(schema: any): boolean { + return ( + typeof schema === "object" && + schema !== null && + "credentials_provider" in schema + ); +} + +export function parseHandleIdToPath(handleId: string): PathSegment[] { + const cleanedId = cleanUpHandleId(handleId); + const segments: PathSegment[] = []; + const parts = cleanedId.split(/(_#_|_@_|_\$_|\.)/); + + let currentType: "property" | "item" | "additional" | "normal" = "normal"; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + if (part === "_#_") { + currentType = "additional"; + } else if (part === "_@_") { + currentType = "property"; + } else if (part === "_$_") { + currentType = "item"; + } else if (part === ".") { + currentType = "normal"; + } else if (part) { + const isNumeric = /^\d+$/.test(part); + if (currentType === "item" && isNumeric) { + segments.push({ + key: part, + type: "item", + index: parseInt(part, 10), + }); + } else { + segments.push({ + key: part, + type: currentType, + }); + } + currentType = "normal"; + } + } + + return segments; +} + +/** + * Ensure a path exists in an object, creating intermediate objects/arrays as needed + * Returns true if any modifications were made + */ +export function ensurePathExists( + obj: Record, + segments: PathSegment[], +): boolean { + if (segments.length === 0) return false; + + let current = obj; + let modified = false; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isLast = i === segments.length - 1; + const nextSegment = segments[i + 1]; + + const getDefaultValue = () => { + if (isLast) { + return ""; + } + if (nextSegment?.type === "item") { + return []; + } + return {}; + }; + + if (segment.type === "item" && segment.index !== undefined) { + if (!Array.isArray(current)) { + return modified; + } + + while (current.length <= segment.index) { + current.push(isLast ? "" : {}); + modified = true; + } + + if (!isLast) { + if ( + current[segment.index] === undefined || + current[segment.index] === null + ) { + current[segment.index] = getDefaultValue(); + modified = true; + } + current = current[segment.index]; + } + } else { + if (!(segment.key in current)) { + current[segment.key] = getDefaultValue(); + modified = true; + } else if (!isLast && current[segment.key] === undefined) { + current[segment.key] = getDefaultValue(); + modified = true; + } + + if (!isLast) { + current = current[segment.key]; + } + } + } + + return modified; +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/index.ts new file mode 100644 index 0000000000..c25af0b231 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/index.ts @@ -0,0 +1,3 @@ +export { FormRenderer } from "./FormRenderer"; +export { default as Form } from "./registry"; +export type { ExtendedFormContextType } from "./types"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/Form.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/Form.tsx new file mode 100644 index 0000000000..5bf720a994 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/Form.tsx @@ -0,0 +1,23 @@ +import { ComponentType } from "react"; +import { FormProps, withTheme, ThemeProps } from "@rjsf/core"; +import { + generateBaseFields, + generateBaseTemplates, + generateBaseWidgets, +} from "../base/base-registry"; +import { generateCustomFields } from "../custom/custom-registry"; + +export function generateForm(): ComponentType { + const theme: ThemeProps = { + templates: generateBaseTemplates(), + widgets: generateBaseWidgets(), + fields: { + ...generateBaseFields(), + ...generateCustomFields(), + }, + }; + + return withTheme(theme); +} + +export default generateForm(); diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/index.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/index.ts new file mode 100644 index 0000000000..641c2a97d6 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/index.ts @@ -0,0 +1,10 @@ +export { default, generateForm } from "./Form"; +export { + generateBaseFields, + generateBaseTemplates, + generateBaseWidgets, +} from "../base/base-registry"; +export { + generateCustomFields, + findCustomFieldId, +} from "../custom/custom-registry"; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/types.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/types.ts new file mode 100644 index 0000000000..cab84afcc3 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/registry/types.ts @@ -0,0 +1,7 @@ +import { BlockUIType } from "@/app/(platform)/build/components/types"; + +export type ExtraContext = { + nodeId?: string; + uiType?: BlockUIType; + size?: "small" | "medium" | "large"; +}; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/types.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/types.ts new file mode 100644 index 0000000000..af2e8b7866 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/types.ts @@ -0,0 +1,15 @@ +import { BlockUIType } from "@/lib/autogpt-server-api/types"; +import { FormContextType } from "@rjsf/utils"; + +export interface ExtendedFormContextType extends FormContextType { + nodeId?: string; + uiType?: BlockUIType; + showHandles?: boolean; + size?: "small" | "medium" | "large"; +} + +export type PathSegment = { + key: string; + type: "property" | "item" | "additional" | "normal"; + index?: number; +}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/custom-validator.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/custom-validator.ts similarity index 100% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/utils/custom-validator.ts rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/custom-validator.ts diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/helpers.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/helpers.ts similarity index 100% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/utils/helpers.ts rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/helpers.ts diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/input-schema-pre-processor.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/input-schema-pre-processor.ts similarity index 91% rename from autogpt_platform/frontend/src/components/renderers/input-renderer/utils/input-schema-pre-processor.ts rename to autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/input-schema-pre-processor.ts index ffbcbf52b2..dad95251ed 100644 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/utils/input-schema-pre-processor.ts +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/input-schema-pre-processor.ts @@ -1,9 +1,11 @@ import { RJSFSchema } from "@rjsf/utils"; +import { findCustomFieldId } from "../custom/custom-registry"; /** * 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; @@ -19,6 +21,12 @@ export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema { if (property && typeof property === "object") { const processedProperty = { ...property }; + // adding $id for custom field + const customFieldId = findCustomFieldId(processedProperty); + if (customFieldId) { + processedProperty.$id = customFieldId; + } + // Only add type if no type is defined AND no anyOf/oneOf/allOf is present if ( !processedProperty.type && @@ -32,7 +40,7 @@ export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema { { type: "integer" }, { type: "boolean" }, { type: "array", items: { type: "string" } }, - { type: "object" }, + { type: "object", title: "Object", additionalProperties: true }, { type: "null" }, ]; } diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/rjsf-utils.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/rjsf-utils.ts new file mode 100644 index 0000000000..365058cebd --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/rjsf-utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/schema-utils.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/schema-utils.ts new file mode 100644 index 0000000000..b1cfd37967 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/schema-utils.ts @@ -0,0 +1,35 @@ +import { getUiOptions, RJSFSchema, UiSchema } from "@rjsf/utils"; + +export function isAnyOfSchema(schema: RJSFSchema | undefined): boolean { + return Array.isArray(schema?.anyOf) && schema!.anyOf.length > 0; +} + +export const isAnyOfChild = ( + uiSchema: UiSchema | undefined, +): boolean => { + const uiOptions = getUiOptions(uiSchema); + return uiOptions.label === false; +}; + +export function isOptionalType(schema: RJSFSchema | undefined): { + isOptional: boolean; + type?: any; +} { + if ( + !Array.isArray(schema?.anyOf) || + schema!.anyOf.length !== 2 || + !schema!.anyOf.some((opt: any) => opt.type === "null") + ) { + return { isOptional: false }; + } + + const nonNullType = schema!.anyOf?.find((opt: any) => opt.type !== "null"); + + return { + isOptional: true, + type: nonNullType, + }; +} +export function isAnyOfSelector(name: string) { + return name.includes("anyof_select"); +} diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/ARCHITECTURE_INPUT_RENDERER.md b/autogpt_platform/frontend/src/components/renderers/input-renderer/ARCHITECTURE_INPUT_RENDERER.md deleted file mode 100644 index 7ae3d2b546..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/ARCHITECTURE_INPUT_RENDERER.md +++ /dev/null @@ -1,938 +0,0 @@ -# Input-Renderer Architecture Documentation - -## Overview - -The Input-Renderer is a **JSON Schema-based form generation system** built on top of **React JSON Schema Form (RJSF)**. It dynamically creates form inputs for block nodes in the FlowEditor based on JSON schemas defined in the backend. - -This system allows blocks to define their input requirements declaratively, and the frontend automatically generates appropriate UI components. - ---- - -## High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ FormRenderer │ -│ (Entry point, wraps RJSF Form) │ -└─────────────────────┬───────────────────────────────────┘ - │ - ┌─────────▼─────────┐ - │ RJSF Core │ - │ │ - └───────┬───────────┘ - │ - ┌───────────┼───────────┬──────────────┐ - │ │ │ │ - ┌────▼────┐ ┌───▼────┐ ┌────▼─────┐ ┌────▼────┐ - │ Fields │ │Templates│ │ Widgets │ │ Schemas │ - └─────────┘ └─────────┘ └──────────┘ └─────────┘ - │ │ │ │ - │ │ │ │ - Handles Wrapper Actual JSON Schema - complex layouts input (from backend) - types & labels components -``` - ---- - -## What is RJSF (React JSON Schema Form)? - -**RJSF** is a library that generates React forms from JSON Schema definitions. It follows a specific hierarchy to render forms: - -### **RJSF Rendering Flow:** - -``` -1. JSON Schema (defines data structure) - ↓ -2. Schema Field (decides which Field component to use) - ↓ -3. Field Component (handles specific type logic) - ↓ -4. Field Template (wraps field with label, description) - ↓ -5. Widget (actual input element - TextInput, Select, etc.) -``` - -### **Example Flow:** - -```json -// JSON Schema -{ - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Name" - } - } -} -``` - -**Becomes:** - -``` -SchemaField (detects "string" type) - ↓ -StringField (default RJSF field) - ↓ -FieldTemplate (adds label "Name") - ↓ -TextWidget (renders ) -``` - ---- - -## Core Components of Input-Renderer - -### 1. **FormRenderer** (`FormRenderer.tsx`) - -The main entry point that wraps RJSF `` component. - -```typescript -export const FormRenderer = ({ - jsonSchema, // JSON Schema from backend - handleChange, // Callback when form changes - uiSchema, // UI customization - initialValues, // Pre-filled values - formContext, // Extra context (nodeId, uiType, etc.) -}: FormRendererProps) => { - const preprocessedSchema = preprocessInputSchema(jsonSchema); - - return ( - - ); -}; -``` - -**Key Props:** - -- **`fields`** - Custom components for complex types (anyOf, credentials, objects) -- **`templates`** - Layout wrappers (FieldTemplate, ArrayFieldTemplate) -- **`widgets`** - Actual input components (TextInput, Select, FileWidget) -- **`formContext`** - Shared data (nodeId, showHandles, size) - ---- - -### 2. **Schema Pre-Processing** (`utils/input-schema-pre-processor.ts`) - -Before rendering, schemas are transformed to ensure RJSF compatibility. - -**Purpose:** - -- Add missing `type` fields (prevents RJSF errors) -- Recursively process nested objects and arrays -- Normalize inconsistent schemas from backend - -**Example:** - -```typescript -// Backend schema (missing type) -{ - "properties": { - "value": {} // No type defined! - } -} - -// After pre-processing -{ - "properties": { - "value": { - "anyOf": [ - { "type": "string" }, - { "type": "number" }, - { "type": "boolean" }, - // ... all possible types - ] - } - } -} -``` - -**Why?** RJSF requires explicit types. Without this, it would crash or render incorrectly. - ---- - -## The Three Pillars: Fields, Templates, Widgets - -### **A. Fields** (`fields/`) - -Fields handle **complex type logic** that goes beyond simple inputs. - -**Registered Fields:** - -```typescript -export const fields: RegistryFieldsType = { - AnyOfField: AnyOfField, // Handles anyOf/oneOf - credentials: CredentialsField, // OAuth/API key handling - ObjectField: ObjectField, // Free-form objects -}; -``` - -#### **1. AnyOfField** (`fields/AnyOfField/AnyOfField.tsx`) - -Handles schemas with multiple possible types (union types). - -**When Used:** - -```json -{ - "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }] -} -``` - -**Rendering:** - -``` -┌─────────────────────────────────────┐ -│ Parameter Name (string) ▼ │ ← Type selector dropdown -├─────────────────────────────────────┤ -│ [Text Input] │ ← Widget for selected type -└─────────────────────────────────────┘ -``` - -**Features:** - -- Type selector dropdown -- Nullable types (with toggle switch) -- Recursive rendering (can contain arrays, objects) -- Connection-aware (hides input when connected) - -**Special Case: Nullable Types** - -```json -{ - "anyOf": [{ "type": "string" }, { "type": "null" }] -} -``` - -**Renders as:** - -``` -┌─────────────────────────────────────┐ -│ Parameter Name (string | null) [✓] │ ← Toggle switch -├─────────────────────────────────────┤ -│ [Text Input] (only if enabled) │ -└─────────────────────────────────────┘ -``` - ---- - -#### **2. CredentialsField** (`fields/CredentialField/CredentialField.tsx`) - -Handles authentication credentials (OAuth, API Keys, Passwords). - -**When Used:** - -```json -{ - "type": "object", - "credentials": { - "provider": "google", - "scopes": ["email", "profile"] - } -} -``` - -**Flow:** - -``` -1. Renders SelectCredential dropdown - ↓ -2. User selects existing credential OR clicks "Add New" - ↓ -3. Modal opens (OAuthModal/APIKeyModal/PasswordModal) - ↓ -4. User authorizes/enters credentials - ↓ -5. Credential saved to backend - ↓ -6. Dropdown shows selected credential -``` - -**Credential Types:** - -- **OAuth** - 3rd party authorization (Google, GitHub, etc.) -- **API Key** - Simple key-based auth -- **Password** - Username/password pairs - ---- - -#### **3. ObjectField** (`fields/ObjectField.tsx`) - -Handles free-form objects (key-value pairs). - -**When Used:** - -```json -{ - "type": "object", - "additionalProperties": true // Free-form -} -``` - -vs - -```json -{ - "type": "object", - "properties": { - "name": { "type": "string" } // Fixed schema - } -} -``` - -**Behavior:** - -- **Fixed schema** → Uses default RJSF rendering -- **Free-form** → Uses ObjectEditorWidget (JSON editor) - ---- - -### **B. Templates** (`templates/`) - -Templates control **layout and wrapping** of fields. - -#### **1. FieldTemplate** (`templates/FieldTemplate.tsx`) - -Wraps every field with label, type indicator, and connection handle. - -**Rendering Structure:** - -``` -┌────────────────────────────────────────┐ -│ ○ Label (type) ⓘ │ ← Handle + Label + Type + Info icon -├────────────────────────────────────────┤ -│ [Actual Input Widget] │ ← The input itself -└────────────────────────────────────────┘ -``` - -**Responsibilities:** - -- Shows/hides input based on connection status -- Renders connection handle (NodeHandle) -- Displays type information -- Shows tooltip with description -- Handles "advanced" field visibility -- Formats credential field labels - -**Key Logic:** - -```typescript -// Hide input if connected -{(isAnyOf || !isConnected) && ( -
{children}
-)} - -// Show handle for most fields -{shouldShowHandle && ( - -)} -``` - -**Context-Aware Behavior:** - -- Inside `AnyOfField` → No handle (parent handles it) -- Credential field → Special label formatting -- Array item → Uses parent handle -- INPUT/OUTPUT/WEBHOOK blocks → Different handle positioning - ---- - -#### **2. ArrayFieldTemplate** (`templates/ArrayFieldTemplate.tsx`) - -Wraps array fields to use custom ArrayEditorWidget. - -**Simple Wrapper:** - -```typescript -function ArrayFieldTemplate(props: ArrayFieldTemplateProps) { - const { items, canAdd, onAddClick, nodeId } = props; - - return ( - - ); -} -``` - ---- - -### **C. Widgets** (`widgets/`) - -Widgets are **actual input components** - the final rendered HTML elements. - -**Registered Widgets:** - -```typescript -export const widgets: RegistryWidgetsType = { - TextWidget: TextInputWidget, // - SelectWidget: SelectWidget, // - ↓ -8. User types "Hello" - ↓ -9. onChange callback fires - ↓ -10. FormCreator updates nodeStore.updateNodeData() -``` - ---- - -### **Example: Rendering AnyOf Field** - -```json -// Backend Schema -{ - "anyOf": [{ "type": "string" }, { "type": "number" }], - "title": "Value" -} -``` - -**Rendering Flow:** - -``` -1. RJSF detects "anyOf" - ↓ -2. Uses AnyOfField (custom field) - ↓ -3. AnyOfField renders: - ┌─────────────────────────────────┐ - │ ○ Value (string) ▼ │ ← Self-managed handle & selector - ├─────────────────────────────────┤ - │ [Text Input] │ ← Recursively renders SchemaField - └─────────────────────────────────┘ - ↓ -4. User changes type to "number" - ↓ -5. AnyOfField re-renders with NumberWidget - ↓ -6. User enters "42" - ↓ -7. onChange({ type: "number", value: 42 }) -``` - -**Key Point:** AnyOfField **does NOT use FieldTemplate** for itself. It manages its own handle and label to avoid duplication. But it **recursively calls SchemaField** for the selected type, which may use FieldTemplate. - ---- - -### **Example: Rendering Array Field** - -```json -// Backend Schema -{ - "type": "array", - "items": { - "type": "string" - }, - "title": "Tags" -} -``` - -**Rendering Flow:** - -``` -1. RJSF detects "array" type - ↓ -2. Uses ArrayFieldTemplate - ↓ -3. ArrayFieldTemplate passes to ArrayEditorWidget - ↓ -4. ArrayEditorWidget renders: - ┌─────────────────────────────────┐ - │ ○ Tag 1 [Text Input] [X] │ ← Each item wrapped in context - │ ○ Tag 2 [Text Input] [X] │ - │ [+ Add Item] │ - └─────────────────────────────────┘ - ↓ -5. Each item wrapped in ArrayEditorContext - ↓ -6. FieldTemplate reads context: - - isArrayItem = true - - Uses arrayFieldHandleId instead of own handle - ↓ -7. TextWidget renders for each item -``` - ---- - -## Hierarchy: What Comes First? - -This is the **order of execution** from schema to rendered input: - -``` -1. JSON Schema (from backend) - ↓ -2. preprocessInputSchema() (normalization) - ↓ -3. RJSF (library entry point) - ↓ -4. SchemaField (RJSF internal - decides which field) - ↓ -5. Field Component (AnyOfField, CredentialsField, or default) - ↓ -6. Template (FieldTemplate or ArrayFieldTemplate) - ↓ -7. Widget (TextWidget, SelectWidget, etc.) - ↓ -8. Actual HTML (, onChange(e.target.value)} />; -}; -``` - -2. Register in `widgets/index.ts`: - -```typescript -export const widgets: RegistryWidgetsType = { - // ... - MyCustomWidget: MyWidget, -}; -``` - -3. Use in uiSchema or schema format: - -```json -{ - "type": "string", - "format": "my-custom-format" // RJSF maps format → widget -} -``` - ---- - -### **Adding a New Field** - -1. Create field component in `fields/`: - -```typescript -export const MyField = ({ schema, formData, onChange, ...props }: FieldProps) => { - // Custom logic here - return
...
; -}; -``` - -2. Register in `fields/index.ts`: - -```typescript -export const fields: RegistryFieldsType = { - // ... - MyField: MyField, -}; -``` - -3. RJSF uses it based on schema structure (e.g., custom keyword). - ---- - -## Integration with FlowEditor - -``` -CustomNode - ↓ -FormCreator - ↓ -FormRenderer ← YOU ARE HERE - ↓ -RJSF - ↓ -(Fields, Templates, Widgets) - ↓ -User Input - ↓ -onChange callback - ↓ -FormCreator.handleChange() - ↓ -nodeStore.updateNodeData(nodeId, { hardcodedValues }) - ↓ -historyStore.pushState() (undo/redo) -``` - ---- - -## Debugging Tips - -### **Field Not Rendering** - -- Check if `preprocessInputSchema()` is handling it correctly -- Verify schema has `type` field -- Check RJSF console for validation errors - -### **Widget Wrong Type** - -- Check schema `type` and `format` fields -- Verify widget is registered in `widgets/index.ts` -- Check if custom field is overriding default behavior - -### **Handle Not Appearing** - -- Check `showHandles` in formContext -- Verify not inside `fromAnyOf` context -- Check if field is credential or array item - -### **Value Not Saving** - -- Verify `onChange` callback is firing -- Check `handleChange` in FormCreator -- Look for console errors in `updateNodeData` - ---- - -## Summary - -The Input-Renderer is a sophisticated form system that: - -1. **Uses RJSF** as the foundation for JSON Schema → React forms -2. **Extends RJSF** with custom Fields, Templates, and Widgets -3. **Integrates** with FlowEditor's connection system -4. **Handles** complex types (anyOf, credentials, free-form objects) -5. **Provides** connection-aware, type-safe input rendering - -**Key Hierarchy (What Comes First):** - -``` -JSON Schema - → Pre-processing - → RJSF Form - → SchemaField (RJSF internal) - → Field (AnyOfField, CredentialsField, etc.) - → Template (FieldTemplate, ArrayFieldTemplate) - → Widget (TextWidget, SelectWidget, etc.) - → HTML Element -``` - -**Mental Model:** - -- **Fields** = Smart logic layers (type selection, OAuth flows) -- **Templates** = Layout wrappers (handles, labels, tooltips) -- **Widgets** = Actual inputs (text boxes, dropdowns) - -**Integration Point:** - -- FormRenderer receives schema from `node.data.inputSchema` -- User edits form → `onChange` → `nodeStore.updateNodeData()` -- Values saved as `node.data.hardcodedValues` diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/AnyOfField/AnyOfField.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/AnyOfField/AnyOfField.tsx deleted file mode 100644 index 79fa15304d..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/AnyOfField/AnyOfField.tsx +++ /dev/null @@ -1,232 +0,0 @@ -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 "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; - -import { InfoIcon } from "@phosphor-icons/react"; -import { useAnyOfField } from "./useAnyOfField"; -import NodeHandle from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle"; -import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; -import { generateHandleId } from "@/app/(platform)/build/components/FlowEditor/handlers/helpers"; -import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; -import merge from "lodash/merge"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/atoms/Tooltip/BaseTooltip"; -import { cn } from "@/lib/utils"; -import { BlockUIType } from "@/app/(platform)/build/components/types"; - -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 handleId = - formContext.uiType === BlockUIType.AGENT - ? (idSchema.$id ?? "") - .split("_") - .filter((p) => p !== "root" && p !== "properties" && p.length > 0) - .join("_") || "" - : generateHandleId(idSchema.$id ?? ""); - - const updatedFormContexrt = { ...formContext, fromAnyOf: true }; - - const { nodeId, showHandles = true } = updatedFormContexrt; - const { isInputConnected } = useEdgeStore(); - const isConnected = showHandles ? isInputConnected(nodeId, handleId) : false; - 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 ( -
-
-
- {showHandles && ( - - )} - - {schema.title || name.charAt(0).toUpperCase() + name.slice(1)} - - - ({displayType} | null) - -
- {!isConnected && ( - - )} -
-
- {!isConnected && isEnabled && renderInput(nonNull)} -
-
- ); - } - - return ( -
-
- {showHandles && ( - - )} - - {schema.title || name.charAt(0).toUpperCase() + name.slice(1)} - - {!isConnected && ( - - - -
- ); -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/APIKeyCredentialModal.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/APIKeyCredentialModal.tsx deleted file mode 100644 index 5a51e1e36c..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/APIKeyCredentialModal.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Input } from "@/components/atoms/Input/Input"; -import { Button } from "@/components/atoms/Button/Button"; -import { Dialog } from "@/components/molecules/Dialog/Dialog"; -import { - Form, - FormDescription, - FormField, -} from "@/components/__legacy__/ui/form"; -import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; // we need to find a way to replace it with autogenerated types -import { useAPIKeyCredentialsModal } from "./useAPIKeyCredentialsModal"; -import { toDisplayName } from "../../helpers"; -import { KeyIcon } from "@phosphor-icons/react"; -import { Text } from "@/components/atoms/Text/Text"; - -type Props = { - schema: BlockIOCredentialsSubSchema; - provider: string; -}; - -export function APIKeyCredentialsModal({ schema, provider }: Props) { - const { form, schemaDescription, onSubmit, isOpen, setIsOpen } = - useAPIKeyCredentialsModal({ schema, provider }); - - return ( - <> - { - if (!isOpen) setIsOpen(false); - }, - }} - onClose={() => setIsOpen(false)} - styling={{ - maxWidth: "25rem", - }} - > - - {schemaDescription && ( -

{schemaDescription}

- )} - - - - ( - <> - - Required scope(s) for this block:{" "} - {schema.credentials_scopes?.map((s, i, a) => ( - - {s} - {i < a.length - 1 && ", "} - - ))} - - ) : null - } - {...field} - /> - - )} - /> - ( - - )} - /> - ( - - )} - /> - - - -
-
- - - ); -} diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/useAPIKeyCredentialsModal.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/useAPIKeyCredentialsModal.ts deleted file mode 100644 index 499cacf1ce..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/APIKeyCredentialModal/useAPIKeyCredentialsModal.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { z } from "zod"; -import { useForm, type UseFormReturn } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; -import { - getGetV1ListCredentialsQueryKey, - usePostV1CreateCredentials, -} from "@/app/api/__generated__/endpoints/integrations/integrations"; -import { useToast } from "@/components/molecules/Toast/use-toast"; -import { APIKeyCredentials } from "@/app/api/__generated__/models/aPIKeyCredentials"; -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; - -export type APIKeyFormValues = { - apiKey: string; - title: string; - expiresAt?: string; -}; - -type useAPIKeyCredentialsModalType = { - schema: BlockIOCredentialsSubSchema; - provider: string; -}; - -export function useAPIKeyCredentialsModal({ - schema, - provider, -}: useAPIKeyCredentialsModalType): { - form: UseFormReturn; - schemaDescription?: string; - onSubmit: (values: APIKeyFormValues) => Promise; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; -} { - const { toast } = useToast(); - const [isOpen, setIsOpen] = useState(false); - const queryClient = useQueryClient(); - - const { mutateAsync: createCredentials } = usePostV1CreateCredentials({ - mutation: { - onSuccess: async () => { - form.reset(); - setIsOpen(false); - toast({ - title: "Success", - description: "Credentials created successfully", - variant: "default", - }); - - await queryClient.refetchQueries({ - queryKey: getGetV1ListCredentialsQueryKey(), - }); - }, - onError: () => { - toast({ - title: "Error", - description: "Failed to create credentials.", - variant: "destructive", - }); - }, - }, - }); - - const formSchema = z.object({ - apiKey: z.string().min(1, "API Key is required"), - title: z.string().min(1, "Name is required"), - expiresAt: z.string().optional(), - }); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - apiKey: "", - title: "", - expiresAt: "", - }, - }); - - async function onSubmit(values: APIKeyFormValues) { - const expiresAt = values.expiresAt - ? new Date(values.expiresAt).getTime() / 1000 - : undefined; - - createCredentials({ - provider: provider, - data: { - provider: provider, - type: "api_key", - api_key: values.apiKey, - title: values.title, - expires_at: expiresAt, - } as APIKeyCredentials, - }); - } - - return { - form, - schemaDescription: schema.description, - onSubmit, - isOpen, - setIsOpen, - }; -} diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/HostScopedCredentialsModal.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/HostScopedCredentialsModal.tsx deleted file mode 100644 index 3264fca76f..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/HostScopedCredentialsModal.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { Input } from "@/components/atoms/Input/Input"; -import { Button } from "@/components/atoms/Button/Button"; -import { Dialog } from "@/components/molecules/Dialog/Dialog"; -import { - Form, - FormDescription, - FormField, - FormLabel, -} from "@/components/__legacy__/ui/form"; -import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; -import { useHostScopedCredentialsModal } from "./useHostScopedCredentialsModal"; -import { toDisplayName } from "../../helpers"; -import { GlobeIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; -import { Text } from "@/components/atoms/Text/Text"; - -type Props = { - schema: BlockIOCredentialsSubSchema; - provider: string; - discriminatorValue?: string; -}; - -export function HostScopedCredentialsModal({ - schema, - provider, - discriminatorValue, -}: Props) { - const { - form, - schemaDescription, - onSubmit, - isOpen, - setIsOpen, - headerPairs, - addHeaderPair, - removeHeaderPair, - updateHeaderPair, - currentHost, - } = useHostScopedCredentialsModal({ schema, provider, discriminatorValue }); - - return ( - <> - { - if (!isOpen) setIsOpen(false); - }, - }} - onClose={() => setIsOpen(false)} - styling={{ - maxWidth: "38rem", - }} - > - -
- {schemaDescription && ( -

{schemaDescription}

- )} - -
- - ( - - )} - /> - - ( - - )} - /> - -
- Headers - - Add sensitive headers (like Authorization, X-API-Key) that - should be automatically included in requests to the - specified host. - - - {headerPairs.map((pair, index) => ( -
- - updateHeaderPair(index, "key", e.target.value) - } - /> - - - updateHeaderPair(index, "value", e.target.value) - } - /> - -
- ))} - - -
- - - - -
-
-
- - - ); -} diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/useHostScopedCredentialsModal.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/useHostScopedCredentialsModal.ts deleted file mode 100644 index 066bc05b51..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/HostScopedCredentialsModal/useHostScopedCredentialsModal.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { z } from "zod"; -import { useForm, type UseFormReturn } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; -import { - getGetV1ListCredentialsQueryKey, - usePostV1CreateCredentials, -} from "@/app/api/__generated__/endpoints/integrations/integrations"; -import { useToast } from "@/components/molecules/Toast/use-toast"; -import { HostScopedCredentialsInput } from "@/app/api/__generated__/models/hostScopedCredentialsInput"; -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { getHostFromUrl } from "@/lib/utils/url"; - -export type HeaderPair = { - key: string; - value: string; -}; - -export type HostScopedFormValues = { - host: string; - title?: string; -}; - -type UseHostScopedCredentialsModalType = { - schema: BlockIOCredentialsSubSchema; - provider: string; - discriminatorValue?: string; -}; - -export function useHostScopedCredentialsModal({ - schema, - provider, - discriminatorValue, -}: UseHostScopedCredentialsModalType): { - form: UseFormReturn; - schemaDescription?: string; - onSubmit: (values: HostScopedFormValues) => Promise; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; - headerPairs: HeaderPair[]; - addHeaderPair: () => void; - removeHeaderPair: (index: number) => void; - updateHeaderPair: ( - index: number, - field: "key" | "value", - value: string, - ) => void; - currentHost: string | null; -} { - const { toast } = useToast(); - const [isOpen, setIsOpen] = useState(false); - const [headerPairs, setHeaderPairs] = useState([ - { key: "", value: "" }, - ]); - const queryClient = useQueryClient(); - - // Get current host from discriminatorValue (URL field) - const currentHost = discriminatorValue - ? getHostFromUrl(discriminatorValue) - : null; - - const { mutateAsync: createCredentials } = usePostV1CreateCredentials({ - mutation: { - onSuccess: async () => { - form.reset(); - setHeaderPairs([{ key: "", value: "" }]); - setIsOpen(false); - toast({ - title: "Success", - description: "Host-scoped credentials created successfully", - variant: "default", - }); - - await queryClient.refetchQueries({ - queryKey: getGetV1ListCredentialsQueryKey(), - }); - }, - onError: () => { - toast({ - title: "Error", - description: "Failed to create host-scoped credentials.", - variant: "destructive", - }); - }, - }, - }); - - const formSchema = z.object({ - host: z.string().min(1, "Host is required"), - title: z.string().optional().default(""), - }); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - host: currentHost || "", - title: currentHost || "Manual Entry", - }, - }); - - // Update form values when modal opens and discriminatorValue changes - const handleSetIsOpen = (open: boolean) => { - if (open && currentHost) { - form.setValue("host", currentHost); - form.setValue("title", currentHost); - } - setIsOpen(open); - }; - - const addHeaderPair = () => { - setHeaderPairs([...headerPairs, { key: "", value: "" }]); - }; - - const removeHeaderPair = (index: number) => { - if (headerPairs.length > 1) { - setHeaderPairs(headerPairs.filter((_, i) => i !== index)); - } - }; - - const updateHeaderPair = ( - index: number, - field: "key" | "value", - value: string, - ) => { - const newPairs = [...headerPairs]; - newPairs[index][field] = value; - setHeaderPairs(newPairs); - }; - - async function onSubmit(values: HostScopedFormValues) { - // Convert header pairs to object, filtering out empty pairs - const headers = headerPairs.reduce( - (acc, pair) => { - if (pair.key.trim() && pair.value.trim()) { - acc[pair.key.trim()] = pair.value.trim(); - } - return acc; - }, - {} as Record, - ); - - createCredentials({ - provider: provider, - data: { - provider: provider, - type: "host_scoped", - host: values.host, - title: values.title || values.host, - headers: headers, - } as HostScopedCredentialsInput, - }); - } - - return { - form, - schemaDescription: schema.description, - onSubmit, - isOpen, - setIsOpen: handleSetIsOpen, - headerPairs, - addHeaderPair, - removeHeaderPair, - updateHeaderPair, - currentHost, - }; -} diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/OAuthCredentialModal.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/OAuthCredentialModal.tsx deleted file mode 100644 index 9824ace169..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/OAuthCredentialModal.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Button } from "@/components/atoms/Button/Button"; -import { Dialog } from "@/components/molecules/Dialog/Dialog"; -import { providerIcons, toDisplayName } from "../../helpers"; -import { useOAuthCredentialModal } from "./useOAuthCredentialModal"; -import { Text } from "@/components/atoms/Text/Text"; - -type OAuthCredentialModalProps = { - provider: string; -}; - -export const OAuthCredentialModal = ({ - provider, -}: OAuthCredentialModalProps) => { - const Icon = providerIcons[provider]; - const { handleOAuthLogin, loading, error, onClose, open, setOpen } = - useOAuthCredentialModal({ - provider, - }); - return ( - <> - { - if (!isOpen) setOpen(false); - }, - }} - onClose={onClose} - > - -

- Complete the sign-in process in the pop-up window. -
- Closing this dialog will cancel the sign-in process. -

-
-
- - - {error && ( -
- - {error as string} - -
- )} - - ); -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal.ts deleted file mode 100644 index cf82ebca70..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - getGetV1ListCredentialsQueryKey, - useGetV1InitiateOauthFlow, - usePostV1ExchangeOauthCodeForTokens, -} from "@/app/api/__generated__/endpoints/integrations/integrations"; -import { LoginResponse } from "@/app/api/__generated__/models/loginResponse"; -import { useToast } from "@/components/molecules/Toast/use-toast"; -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; - -type useOAuthCredentialModalProps = { - provider: string; - scopes?: string[]; -}; - -export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & ( - | { - success: true; - code: string; - state: string; - } - | { - success: false; - message: string; - } -); - -export const useOAuthCredentialModal = ({ - provider, - scopes, -}: useOAuthCredentialModalProps) => { - const { toast } = useToast(); - - const [open, setOpen] = useState(false); - const [oAuthPopupController, setOAuthPopupController] = - useState(null); - const [oAuthError, setOAuthError] = useState(null); - const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false); - - const queryClient = useQueryClient(); - - const { - refetch: initiateOauthFlow, - isRefetching: isInitiatingOauthFlow, - isRefetchError: initiatingOauthFlowError, - } = useGetV1InitiateOauthFlow( - provider, - { - scopes: scopes?.join(","), - }, - { - query: { - enabled: false, - select: (res) => { - return res.data as LoginResponse; - }, - }, - }, - ); - - const { - mutateAsync: oAuthCallback, - isPending: isOAuthCallbackPending, - error: oAuthCallbackError, - } = usePostV1ExchangeOauthCodeForTokens({ - mutation: { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: getGetV1ListCredentialsQueryKey(), - }); - setOpen(false); - toast({ - title: "Success", - description: "Credential added successfully", - variant: "default", - }); - }, - }, - }); - - const handleOAuthLogin = async () => { - const { data } = await initiateOauthFlow(); - if (!data || !data.login_url || !data.state_token) { - toast({ - title: "Failed to initiate OAuth flow", - variant: "destructive", - }); - setOAuthError( - data && typeof data === "object" && "detail" in data - ? (data.detail as string) - : "Failed to initiate OAuth flow", - ); - return; - } - - setOpen(true); - setOAuth2FlowInProgress(true); - - const { login_url, state_token } = data; - - const popup = window.open(login_url, "_blank", "popup=true"); - - if (!popup) { - throw new Error( - "Failed to open popup window. Please allow popups for this site.", - ); - } - - const controller = new AbortController(); - setOAuthPopupController(controller); - - controller.signal.onabort = () => { - console.debug("OAuth flow aborted"); - popup.close(); - }; - - const handleMessage = async (e: MessageEvent) => { - console.debug("Message received:", e.data); - if ( - typeof e.data != "object" || - !("message_type" in e.data) || - e.data.message_type !== "oauth_popup_result" - ) { - console.debug("Ignoring irrelevant message"); - return; - } - - if (!e.data.success) { - console.error("OAuth flow failed:", e.data.message); - setOAuthError(`OAuth flow failed: ${e.data.message}`); - setOAuth2FlowInProgress(false); - return; - } - - if (e.data.state !== state_token) { - console.error("Invalid state token received"); - setOAuthError("Invalid state token received"); - setOAuth2FlowInProgress(false); - return; - } - - try { - console.debug("Processing OAuth callback"); - await oAuthCallback({ - provider, - data: { - code: e.data.code, - state_token: e.data.state, - }, - }); - - console.debug("OAuth callback processed successfully"); - } catch (error) { - console.error("Error in OAuth callback:", error); - setOAuthError( - `Error in OAuth callback: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } finally { - console.debug("Finalizing OAuth flow"); - setOAuth2FlowInProgress(false); - controller.abort("success"); - } - }; - - window.addEventListener("message", handleMessage, { - signal: controller.signal, - }); - - setTimeout( - () => { - console.debug("OAuth flow timed out"); - controller.abort("timeout"); - setOAuth2FlowInProgress(false); - setOAuthError("OAuth flow timed out"); - }, - 5 * 60 * 1000, - ); - }; - - const onClose = () => { - oAuthPopupController?.abort("canceled"); - setOpen(false); - }; - - return { - handleOAuthLogin, - loading: - isOAuth2FlowInProgress || isOAuthCallbackPending || isInitiatingOauthFlow, - error: oAuthError || initiatingOauthFlowError || oAuthCallbackError, - onClose, - open, - setOpen, - }; -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/PasswordCredentialModal.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/PasswordCredentialModal.tsx deleted file mode 100644 index 9abaa8b328..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/PasswordCredentialModal.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Input } from "@/components/atoms/Input/Input"; -import { Button } from "@/components/atoms/Button/Button"; -import { Dialog } from "@/components/molecules/Dialog/Dialog"; -import { Form, FormField } from "@/components/__legacy__/ui/form"; -import { usePasswordCredentialModal } from "./usePasswordCredentialModal"; -import { toDisplayName } from "../../helpers"; -import { UserIcon } from "@phosphor-icons/react"; -import { Text } from "@/components/atoms/Text/Text"; - -type Props = { - provider: string; -}; - -export function PasswordCredentialsModal({ provider }: Props) { - const { form, onSubmit, open, setOpen } = usePasswordCredentialModal({ - provider, - }); - - return ( - <> - { - if (!isOpen) setOpen(false); - }, - }} - onClose={() => setOpen(false)} - styling={{ - maxWidth: "25rem", - }} - > - -
- - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - - -
-
- - - ); -} diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/usePasswordCredentialModal.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/usePasswordCredentialModal.ts deleted file mode 100644 index e0a4e4a805..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/models/PasswordCredentialModal/usePasswordCredentialModal.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useState } from "react"; -import z from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - getGetV1ListCredentialsQueryKey, - usePostV1CreateCredentials, -} from "@/app/api/__generated__/endpoints/integrations/integrations"; -import { useToast } from "@/components/molecules/Toast/use-toast"; -import { useQueryClient } from "@tanstack/react-query"; - -type usePasswordCredentialModalType = { - provider: string; -}; - -export const usePasswordCredentialModal = ({ - provider, -}: usePasswordCredentialModalType) => { - const [open, setOpen] = useState(false); - const { toast } = useToast(); - const queryClient = useQueryClient(); - - const formSchema = z.object({ - username: z.string().min(1, "Username is required"), - password: z.string().min(1, "Password is required"), - title: z.string().min(1, "Name is required"), - }); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - username: "", - password: "", - title: "", - }, - }); - - const { mutateAsync: createCredentials } = usePostV1CreateCredentials({ - mutation: { - onSuccess: async () => { - form.reset(); - setOpen(false); - toast({ - title: "Success", - description: "Credentials created successfully", - variant: "default", - }); - - await queryClient.refetchQueries({ - queryKey: getGetV1ListCredentialsQueryKey(), - }); - }, - }, - }); - - async function onSubmit(values: z.infer) { - createCredentials({ - provider: provider, - data: { - provider: provider, - type: "user_password", - username: values.username, - password: values.password, - title: values.title, - }, - }); - } - - return { - form, - onSubmit, - open, - setOpen, - }; -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/useCredentialField.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/useCredentialField.ts deleted file mode 100644 index a90add8aeb..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/CredentialField/useCredentialField.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { useGetV1ListCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations"; -import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse"; -import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api"; -import { - filterCredentialsByProvider, - getCredentialProviderFromSchema, - getDiscriminatorValue, -} from "./helpers"; -import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; -import { useEffect, useRef } from "react"; -import { useShallow } from "zustand/react/shallow"; - -export const useCredentialField = ({ - credentialSchema, - formData, - nodeId, - onChange, -}: { - credentialSchema: BlockIOCredentialsSubSchema; // Here we are using manual typing, we need to fix it with automatic one - formData: Record; - nodeId: string; - onChange: (value: Record) => void; -}) => { - const previousProviderRef = useRef(null); - - // Fetch all the credentials from the backend - // We will save it in cache for 10 min, if user edits the credential, we will invalidate the cache - // Whenever user adds a block, we filter the credentials list and check if this block's provider is in the list - const { data: credentials, isLoading: isCredentialListLoading } = - useGetV1ListCredentials({ - query: { - refetchInterval: 10 * 60 * 1000, - select: (x) => { - return x.data as CredentialsMetaResponse[]; - }, - }, - }); - - const hardcodedValues = useNodeStore( - useShallow((state) => state.getHardCodedValues(nodeId)), - ); - - const credentialProvider = getCredentialProviderFromSchema( - hardcodedValues, - credentialSchema, - ); - - const discriminatorValue = getDiscriminatorValue( - hardcodedValues, - credentialSchema, - ); - - const supportsApiKey = credentialSchema.credentials_types.includes("api_key"); - const supportsOAuth2 = credentialSchema.credentials_types.includes("oauth2"); - const supportsUserPassword = - credentialSchema.credentials_types.includes("user_password"); - const supportsHostScoped = - credentialSchema.credentials_types.includes("host_scoped"); - - const { credentials: filteredCredentials, exists: credentialsExists } = - filterCredentialsByProvider( - credentials, - credentialProvider ?? "", - credentialSchema, - discriminatorValue, - ); - - const setCredential = (credentialId: string) => { - const selectedCredential = filteredCredentials.find( - (c) => c.id === credentialId, - ); - if (selectedCredential) { - onChange({ - ...formData, - id: selectedCredential.id, - provider: selectedCredential.provider, - title: selectedCredential.title, - type: selectedCredential.type, - }); - } - }; - - // This side effect is used to clear the hardcoded value in credential formData when the provider changes - useEffect(() => { - if (!credentialProvider) return; - // If provider has changed and we have a credential selected - if ( - previousProviderRef.current !== null && - previousProviderRef.current !== credentialProvider && - formData.id - ) { - // Check if the current credential belongs to the new provider - const currentCredentialBelongsToProvider = filteredCredentials.some( - (c) => c.id === formData.id, - ); - - // If not, clear the credential - if (!currentCredentialBelongsToProvider) { - onChange({ - id: "", - provider: "", - title: "", - type: "", - }); - } - } - previousProviderRef.current = credentialProvider; - }, [credentialProvider, formData.id, credentials, onChange]); - - // This side effect is used to auto-select the latest credential when none is selected [latest means last one in the list of credentials] - useEffect(() => { - if ( - !isCredentialListLoading && - filteredCredentials.length > 0 && - !formData.id && // No credential currently selected - credentialProvider // Provider is set - ) { - const latestCredential = - filteredCredentials[filteredCredentials.length - 1]; - setCredential(latestCredential.id); - } - }, [ - isCredentialListLoading, - filteredCredentials.length, - formData.id, - credentialProvider, - ]); - - return { - credentials: filteredCredentials, - isCredentialListLoading, - setCredential, - supportsApiKey, - supportsOAuth2, - supportsUserPassword, - supportsHostScoped, - credentialsExists, - credentialProvider, - discriminatorValue, - }; -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/ObjectField.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/ObjectField.tsx deleted file mode 100644 index 8189116a4e..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/ObjectField.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; -import { FieldProps } from "@rjsf/utils"; -import { getDefaultRegistry } from "@rjsf/core"; -import { generateHandleId } from "@/app/(platform)/build/components/FlowEditor/handlers/helpers"; -import { ObjectEditor } from "../widgets/ObjectEditorWidget/ObjectEditorWidget"; - -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 - let isFreeForm = false; - if ("additionalProperties" in schema || !("properties" in schema)) { - isFreeForm = true; - } - - if (idSchema?.$id === "root" || !isFreeForm) { - // TODO : We need to create better one - return ; - } - - const fieldKey = generateHandleId(idSchema.$id ?? ""); - const { nodeId } = formContext; - - return ( - - ); -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/index.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/index.ts deleted file mode 100644 index 6f826495d1..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/fields/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RegistryFieldsType } from "@rjsf/utils"; -import { CredentialsField } from "./CredentialField/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/components/renderers/input-renderer/templates/ArrayFieldTemplate.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/ArrayFieldTemplate.tsx deleted file mode 100644 index e9f708de5a..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/ArrayFieldTemplate.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { ArrayFieldTemplateProps } from "@rjsf/utils"; -import { ArrayEditorWidget } from "../widgets/ArrayEditorWidget/ArrayEditorWidget"; - -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/components/renderers/input-renderer/templates/FieldTemplate.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx deleted file mode 100644 index ebc8a1f038..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/FieldTemplate.tsx +++ /dev/null @@ -1,184 +0,0 @@ -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 { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; -import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; -import { generateHandleId } from "@/app/(platform)/build/components/FlowEditor/handlers/helpers"; -import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; -import { ArrayEditorContext } from "../widgets/ArrayEditorWidget/ArrayEditorContext"; -import { - isCredentialFieldSchema, - toDisplayName, - getCredentialProviderFromSchema, -} from "../fields/CredentialField/helpers"; -import { cn } from "@/lib/utils"; -import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api"; -import { BlockUIType } from "@/lib/autogpt-server-api"; -import NodeHandle from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle"; -import { getFieldErrorKey } from "../utils/helpers"; - -const FieldTemplate: React.FC = ({ - id: fieldId, - label, - required, - description, - children, - schema, - formContext, - uiSchema, -}) => { - const { isInputConnected } = useEdgeStore(); - const { nodeId, showHandles = true, size = "small" } = formContext; - const uiType = formContext.uiType; - - const showAdvanced = useNodeStore( - (state) => state.nodeAdvancedStates[nodeId] ?? false, - ); - - const nodeErrors = useNodeStore((state) => { - const node = state.nodes.find((n) => n.id === nodeId); - return node?.data?.errors; - }); - - const { isArrayItem, arrayFieldHandleId } = useContext(ArrayEditorContext); - - const isAnyOf = - Array.isArray((schema as any)?.anyOf) && !(schema as any)?.enum; - const isOneOf = Array.isArray((schema as any)?.oneOf); - const isCredential = isCredentialFieldSchema(schema); - const suppressHandle = isAnyOf || isOneOf; - - let handleId = null; - if (!isArrayItem) { - if (uiType === BlockUIType.AGENT) { - const parts = fieldId.split("_"); - const filtered = parts.filter( - (p) => p !== "root" && p !== "properties" && p.length > 0, - ); - handleId = filtered.join("_") || ""; - } else { - handleId = generateHandleId(fieldId); - } - } else { - handleId = arrayFieldHandleId; - } - - const isConnected = showHandles ? isInputConnected(nodeId, handleId) : false; - - 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); - - let credentialProvider = null; - if (isCredential) { - credentialProvider = getCredentialProviderFromSchema( - useNodeStore.getState().getHardCodedValues(nodeId), - schema as BlockIOCredentialsSubSchema, - ); - } - if (formContext.uiType === BlockUIType.NOTE) { - return
{children}
; - } - - // Size-based styling - let shouldShowHandle = - showHandles && !suppressHandle && !fromAnyOf && !isCredential; - - // We do not want handle for output block's name field - if (uiType === BlockUIType.OUTPUT && fieldId === "root_name") { - shouldShowHandle = false; - } - - const fieldErrorKey = getFieldErrorKey(fieldId); - const fieldError = - nodeErrors?.[fieldErrorKey] || - nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] || - nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] || - null; - - return ( -
- {!isAnyOf && !fromAnyOf && label && ( - - )} - {(isAnyOf || !isConnected) && ( -
- {children} -
- )} - {fieldError && ( - - {fieldError} - - )} -
- ); -}; - -export default FieldTemplate; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/index.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/index.ts deleted file mode 100644 index 203526cd75..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/templates/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorContext.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorContext.tsx deleted file mode 100644 index 09fe11bc94..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorContext.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createContext } from "react"; - -export const ArrayEditorContext = createContext<{ - isArrayItem: boolean; - arrayFieldHandleId: string; - isConnected: boolean; -}>({ - isArrayItem: false, - arrayFieldHandleId: "", - isConnected: false, -}); diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorWidget.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorWidget.tsx deleted file mode 100644 index 01a2af2e40..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ArrayEditorWidget/ArrayEditorWidget.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { ArrayFieldTemplateItemType, RJSFSchema } from "@rjsf/utils"; -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"; -import { - generateHandleId, - HandleIdType, -} from "@/app/(platform)/build/components/FlowEditor/handlers/helpers"; - -export interface ArrayEditorProps { - items?: ArrayFieldTemplateItemType[]; - nodeId: string; - canAdd: boolean | undefined; - onAddClick?: () => void; - disabled: boolean | undefined; - readonly: boolean | undefined; - id: string; -} - -export const ArrayEditorWidget = ({ - items, - nodeId, - canAdd, - onAddClick, - disabled, - readonly, - id: fieldId, -}: ArrayEditorProps) => { - const { isInputConnected } = useEdgeStore(); - - return ( -
-
-
- {items?.map((element) => { - const arrayFieldHandleId = generateHandleId( - fieldId, - [element.index.toString()], - HandleIdType.ARRAY, - ); - const isConnected = isInputConnected(nodeId, arrayFieldHandleId); - return ( -
- - {element.children} - - - {element.hasRemove && - !readonly && - !disabled && - !isConnected && ( - - )} -
- ); - })} -
-
- - {canAdd && !readonly && !disabled && ( - - )} -
- ); -}; diff --git a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ObjectEditorWidget/ObjectEditorWidget.tsx b/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ObjectEditorWidget/ObjectEditorWidget.tsx deleted file mode 100644 index 80d504421d..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/ObjectEditorWidget/ObjectEditorWidget.tsx +++ /dev/null @@ -1,183 +0,0 @@ -"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 "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle"; -import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; -import { - generateHandleId, - HandleIdType, - parseKeyValueHandleId, -} from "@/app/(platform)/build/components/FlowEditor/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: parentFieldId, - value = {}, - onChange, - placeholder = "Enter value", - disabled = false, - className, - nodeId, - }, - ref, - ) => { - const getAllHandleIdsOfANode = useEdgeStore( - (state) => state.getAllHandleIdsOfANode, - ); - 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(); - - const allHandleIdsOfANode = getAllHandleIdsOfANode(nodeId); - const allKeyValueHandleIdsOfANode = allHandleIdsOfANode.filter((handleId) => - handleId.includes("_#_"), - ); - allKeyValueHandleIdsOfANode.forEach((handleId) => { - const key = parseKeyValueHandleId(handleId, HandleIdType.KEY_VALUE); - if (!value[key]) { - value[key] = null; - } - }); - - // Note: ObjectEditor is always used in node context, so showHandles is always true - // If you need to use it in dialog context, you'll need to pass showHandles via props - const showHandles = true; - - return ( -
- {Object.entries(value).map(([key, propertyValue], idx) => { - const handleId = generateHandleId( - parentFieldId, - [key], - HandleIdType.KEY_VALUE, - ); - const isDynamicPropertyConnected = isInputConnected(nodeId, handleId); - - return ( -
-
- {showHandles && ( - - )} - - #{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/components/renderers/input-renderer/widgets/index.ts b/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/index.ts deleted file mode 100644 index 3788e74fbf..0000000000 --- a/autogpt_platform/frontend/src/components/renderers/input-renderer/widgets/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { RegistryWidgetsType } from "@rjsf/utils"; -import { SelectWidget } from "./SelectWidget"; -import { TextInputWidget } from "./TextInputWidget/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/lib/dexie/draft-utils.ts b/autogpt_platform/frontend/src/lib/dexie/draft-utils.ts index 185ebf92b4..03232ede30 100644 --- a/autogpt_platform/frontend/src/lib/dexie/draft-utils.ts +++ b/autogpt_platform/frontend/src/lib/dexie/draft-utils.ts @@ -1,5 +1,6 @@ import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode"; import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge"; +import isEqual from "lodash/isEqual"; export function cleanNode(node: CustomNode) { return { @@ -31,3 +32,81 @@ export function cleanNodes(nodes: CustomNode[]) { export function cleanEdges(edges: CustomEdge[]) { return edges.map(cleanEdge); } + +export interface DraftDiff { + nodes: { + added: number; + removed: number; + modified: number; + }; + edges: { + added: number; + removed: number; + modified: number; + }; +} + +/** + * Calculate the diff between draft and current nodes/edges. + * - Added: items in draft but not in current (will be restored) + * - Removed: items in current but not in draft (will be removed if draft is loaded) + * - Modified: items with same ID but different content + */ +export function calculateDraftDiff( + draftNodes: CustomNode[], + draftEdges: CustomEdge[], + currentNodes: CustomNode[], + currentEdges: CustomEdge[], +): DraftDiff { + const draftNodeIds = new Set(draftNodes.map((n) => n.id)); + const currentNodeIds = new Set(currentNodes.map((n) => n.id)); + const draftEdgeIds = new Set(draftEdges.map((e) => e.id)); + const currentEdgeIds = new Set(currentEdges.map((e) => e.id)); + + // Nodes diff + const nodesAdded = draftNodes.filter((n) => !currentNodeIds.has(n.id)).length; + const nodesRemoved = currentNodes.filter( + (n) => !draftNodeIds.has(n.id), + ).length; + + // Modified nodes: same ID but different content + const draftNodeMap = new Map(draftNodes.map((n) => [n.id, cleanNode(n)])); + const currentNodeMap = new Map(currentNodes.map((n) => [n.id, cleanNode(n)])); + let nodesModified = 0; + for (const [id, draftClean] of draftNodeMap) { + const currentClean = currentNodeMap.get(id); + if (currentClean && !isEqual(draftClean, currentClean)) { + nodesModified++; + } + } + + // Edges diff + const edgesAdded = draftEdges.filter((e) => !currentEdgeIds.has(e.id)).length; + const edgesRemoved = currentEdges.filter( + (e) => !draftEdgeIds.has(e.id), + ).length; + + // Modified edges: same ID but different content + const draftEdgeMap = new Map(draftEdges.map((e) => [e.id, cleanEdge(e)])); + const currentEdgeMap = new Map(currentEdges.map((e) => [e.id, cleanEdge(e)])); + let edgesModified = 0; + for (const [id, draftClean] of draftEdgeMap) { + const currentClean = currentEdgeMap.get(id); + if (currentClean && !isEqual(draftClean, currentClean)) { + edgesModified++; + } + } + + return { + nodes: { + added: nodesAdded, + removed: nodesRemoved, + modified: nodesModified, + }, + edges: { + added: edgesAdded, + removed: edgesRemoved, + modified: edgesModified, + }, + }; +} From df87867625b786832c1ee7c2887c555c107161ca Mon Sep 17 00:00:00 2001 From: Swifty Date: Wed, 7 Jan 2026 09:25:10 +0100 Subject: [PATCH 03/28] extracted frontend changes out of the hackathon/copilot branch --- .../AgentOnboardingCredentials.tsx | 2 +- .../app/(no-navbar)/onboarding/5-run/page.tsx | 2 +- .../app/(platform)/PlatformLayoutContent.tsx | 46 ++ .../auth/integrations/setup-wizard/page.tsx | 2 +- .../components/AgentOutputs/AgentOutputs.tsx | 10 +- .../NodeOutput/components/ContentRenderer.tsx | 4 +- .../NodeDataViewer/NodeDataViewer.tsx | 8 +- .../NodeDataViewer/useNodeDataViewer.ts | 6 +- .../components/WebhookDisclaimer.tsx | 10 +- .../legacy-builder/ExpandableOutputDialog.tsx | 4 +- .../components/legacy-builder/NodeInputs.tsx | 2 +- .../frontend/src/app/(platform)/chat/page.tsx | 136 ++++-- .../src/app/(platform)/chat/useChatPage.ts | 10 +- .../frontend/src/app/(platform)/layout.tsx | 9 +- .../AgentInputsReadOnly.tsx | 4 +- .../ModalRunSection/ModalRunSection.tsx | 4 +- .../SelectedRunView/components/RunOutputs.tsx | 4 +- .../SelectedTemplateView.tsx | 4 +- .../SelectedTriggerView.tsx | 4 +- .../components/agent-run-draft-view.tsx | 4 +- .../components/agent-run-output-view.tsx | 4 +- .../chat/sessions/[sessionId]/stream/route.ts | 87 +++- autogpt_platform/frontend/src/app/globals.css | 46 ++ .../src/components/atoms/Input/Input.tsx | 7 +- .../src/components/contextual/Chat/Chat.tsx | 136 ++++++ .../components/contextual/Chat/ChatDrawer.tsx | 77 +++ .../AgentCarouselMessage.tsx | 119 +++++ .../AgentInputsSetup/AgentInputsSetup.tsx | 145 ++++++ .../AgentInputsSetup/useAgentInputsSetup.ts | 38 ++ .../AuthPromptWidget/AuthPromptWidget.tsx | 120 +++++ .../ChatContainer/ChatContainer.tsx | 89 ++++ .../createStreamEventDispatcher.ts | 60 +++ .../Chat/components/ChatContainer/helpers.ts | 379 +++++++++++++++ .../useChatContainer.handlers.ts | 224 +++++++++ .../ChatContainer/useChatContainer.ts | 210 ++++++++ .../ChatCredentialsSetup.tsx | 149 ++++++ .../useChatCredentialsSetup.ts | 36 ++ .../ChatErrorState/ChatErrorState.tsx | 30 ++ .../Chat/components/ChatInput/ChatInput.tsx | 64 +++ .../Chat/components/ChatInput/useChatInput.ts | 60 +++ .../ChatLoadingState/ChatLoadingState.tsx | 19 + .../components/ChatMessage/ChatMessage.tsx | 295 +++++++++++ .../components/ChatMessage/useChatMessage.ts | 113 +++++ .../ExecutionStartedMessage.tsx | 90 ++++ .../MarkdownContent/MarkdownContent.tsx | 215 +++++++++ .../MessageBubble/MessageBubble.tsx | 56 +++ .../components/MessageList/MessageList.tsx | 119 +++++ .../components/MessageList/useMessageList.ts | 28 ++ .../NoResultsMessage/NoResultsMessage.tsx | 64 +++ .../QuickActionsWelcome.tsx | 92 ++++ .../SessionsDrawer/SessionsDrawer.tsx | 136 ++++++ .../StreamingMessage/StreamingMessage.tsx | 42 ++ .../StreamingMessage/useStreamingMessage.ts | 25 + .../ThinkingMessage/ThinkingMessage.tsx | 70 +++ .../ToolCallMessage/ToolCallMessage.tsx | 24 + .../ToolResponseMessage.tsx | 260 ++++++++++ .../src/components/contextual/Chat/helpers.ts | 73 +++ .../src/components/contextual/Chat/useChat.ts | 119 +++++ .../contextual/Chat/useChatDrawer.ts | 17 + .../contextual/Chat/useChatSession.ts | 271 +++++++++++ .../contextual/Chat/useChatStream.ts | 248 ++++++++++ .../contextual/Chat/usePageContext.ts | 42 ++ .../CredentialsInputs/CredentialsInputs.tsx | 228 +++++++++ .../APIKeyCredentialsModal.tsx | 129 +++++ .../useAPIKeyCredentialsModal.ts | 82 ++++ .../CredentialRow/CredentialRow.tsx | 105 ++++ .../CredentialsSelect/CredentialsSelect.tsx | 86 ++++ .../DeleteConfirmationModal.tsx | 49 ++ .../HotScopedCredentialsModal.tsx | 242 ++++++++++ .../OAuthWaitingModal/OAuthWaitingModal.tsx | 30 ++ .../PasswordCredentialsModal.tsx | 138 ++++++ .../contextual/CredentialsInputs/helpers.ts | 102 ++++ .../CredentialsInputs/useCredentialsInput.ts | 315 ++++++++++++ .../GoogleDrivePicker/GoogleDrivePicker.tsx | 2 +- .../components/OutputActions.tsx | 106 ++++ .../OutputRenderers/components/OutputItem.tsx | 30 ++ .../contextual/OutputRenderers/index.ts | 20 + .../renderers/CodeRenderer.tsx | 135 ++++++ .../renderers/ImageRenderer.tsx | 209 ++++++++ .../renderers/JSONRenderer.tsx | 204 ++++++++ .../renderers/MarkdownRenderer.tsx | 456 ++++++++++++++++++ .../renderers/TextRenderer.tsx | 71 +++ .../renderers/VideoRenderer.tsx | 169 +++++++ .../contextual/OutputRenderers/types.ts | 60 +++ .../contextual/OutputRenderers/utils/copy.ts | 115 +++++ .../OutputRenderers/utils/download.ts | 74 +++ .../RunAgentInputs/RunAgentInputs.tsx | 364 ++++++++++++++ .../RunAgentInputs/useRunAgentInputs.ts | 19 + .../layout/Navbar/components/NavbarLink.tsx | 16 +- 89 files changed, 8228 insertions(+), 101 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/PlatformLayoutContent.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/ChatDrawer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.handlers.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatErrorState/ChatErrorState.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoadingState/ChatLoadingState.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/MessageBubble/MessageBubble.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/useMessageList.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/NoResultsMessage/NoResultsMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/SessionsDrawer/SessionsDrawer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/StreamingMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/useStreamingMessage.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ToolCallMessage/ToolCallMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/usePageContext.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/CredentialsInputs.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/APIKeyCredentialsModal.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialRow/CredentialRow.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/HotScopedCredentialsModal/HotScopedCredentialsModal.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/OAuthWaitingModal/OAuthWaitingModal.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/PasswordCredentialsModal/PasswordCredentialsModal.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/CredentialsInputs/useCredentialsInput.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputActions.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputItem.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/index.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/CodeRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/ImageRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/JSONRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/MarkdownRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/TextRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/VideoRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/types.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/utils/copy.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/utils/download.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/RunAgentInputs/RunAgentInputs.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/RunAgentInputs/useRunAgentInputs.ts diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/components/AgentOnboardingCredentials/AgentOnboardingCredentials.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/components/AgentOnboardingCredentials/AgentOnboardingCredentials.tsx index 3176ec7f70..f0c1d208a2 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/components/AgentOnboardingCredentials/AgentOnboardingCredentials.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/components/AgentOnboardingCredentials/AgentOnboardingCredentials.tsx @@ -1,6 +1,6 @@ -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput"; import { GraphMeta } from "@/app/api/__generated__/models/graphMeta"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; import { useState } from "react"; import { getSchemaDefaultCredentials } from "../../helpers"; import { areAllCredentialsSet, getCredentialFields } from "./helpers"; diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/page.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/page.tsx index 30e1b67090..db04278d80 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/page.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/page.tsx @@ -1,12 +1,12 @@ "use client"; -import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs"; import { Card, CardContent, CardHeader, CardTitle, } from "@/components/__legacy__/ui/card"; +import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr"; import { Play } from "lucide-react"; diff --git a/autogpt_platform/frontend/src/app/(platform)/PlatformLayoutContent.tsx b/autogpt_platform/frontend/src/app/(platform)/PlatformLayoutContent.tsx new file mode 100644 index 0000000000..eb980e7dcf --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/PlatformLayoutContent.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ChatDrawer } from "@/components/contextual/Chat/ChatDrawer"; +import { usePathname } from "next/navigation"; +import { Children, ReactNode } from "react"; + +interface PlatformLayoutContentProps { + children: ReactNode; +} + +export function PlatformLayoutContent({ + children, +}: PlatformLayoutContentProps) { + const pathname = usePathname(); + const isAuthPage = + pathname?.includes("/login") || pathname?.includes("/signup"); + + // Extract Navbar, AdminImpersonationBanner, and page content from children + const childrenArray = Children.toArray(children); + const navbar = childrenArray[0]; + const adminBanner = childrenArray[1]; + const pageContent = childrenArray.slice(2); + + // For login/signup pages, use a simpler layout that doesn't interfere with centering + if (isAuthPage) { + return ( +
+ {navbar} + {adminBanner} +
{pageContent}
+
+ ); + } + + // For logged-in pages, use the drawer layout + return ( +
+ {navbar} + {adminBanner} +
+ {pageContent} +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx index 3372772c89..32cd4c19e7 100644 --- a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx @@ -8,7 +8,7 @@ import { AuthCard } from "@/components/auth/AuthCard"; import { Text } from "@/components/atoms/Text/Text"; import { Button } from "@/components/atoms/Button/Button"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; import type { BlockIOCredentialsSubSchema, CredentialsMetaInput, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx index de56bb46b8..a7a772dc90 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx @@ -1,11 +1,6 @@ import { BlockUIType } from "@/app/(platform)/build/components/types"; import { useGraphStore } from "@/app/(platform)/build/stores/graphStore"; import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; -import { - globalRegistry, - OutputActions, - OutputItem, -} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; import { Label } from "@/components/__legacy__/ui/label"; import { ScrollArea } from "@/components/__legacy__/ui/scroll-area"; import { @@ -23,6 +18,11 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/atoms/Tooltip/BaseTooltip"; +import { + globalRegistry, + OutputActions, + OutputItem, +} from "@/components/contextual/OutputRenderers"; import { BookOpenIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx index 9cb1a62e3d..6571bc7b6f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx @@ -1,7 +1,7 @@ "use client"; -import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; -import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; +import type { OutputMetadata } from "@/components/contextual/OutputRenderers"; +import { globalRegistry } from "@/components/contextual/OutputRenderers"; export const TextRenderer: React.FC<{ value: any; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx index c505282e7b..bf2d5fe07b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx @@ -1,7 +1,3 @@ -import { - OutputActions, - OutputItem, -} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; import { ScrollArea } from "@/components/__legacy__/ui/scroll-area"; import { Button } from "@/components/atoms/Button/Button"; import { Text } from "@/components/atoms/Text/Text"; @@ -11,6 +7,10 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/atoms/Tooltip/BaseTooltip"; +import { + OutputActions, + OutputItem, +} from "@/components/contextual/OutputRenderers"; import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { beautifyString } from "@/lib/utils"; import { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts index 1adec625a0..d3c555970c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts @@ -1,6 +1,6 @@ -import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; -import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; -import { downloadOutputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers/utils/download"; +import type { OutputMetadata } from "@/components/contextual/OutputRenderers"; +import { globalRegistry } from "@/components/contextual/OutputRenderers"; +import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download"; import { useToast } from "@/components/molecules/Toast/use-toast"; import { beautifyString } from "@/lib/utils"; import React, { useMemo, useState } from "react"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx index 044bf994ad..3083af8a69 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx @@ -1,10 +1,10 @@ -import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert"; -import { Text } from "@/components/atoms/Text/Text"; -import Link from "next/link"; import { useGetV2GetLibraryAgentByGraphId } from "@/app/api/__generated__/endpoints/library/library"; import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; -import { useQueryStates, parseAsString } from "nuqs"; -import { isValidUUID } from "@/app/(platform)/chat/helpers"; +import { Text } from "@/components/atoms/Text/Text"; +import { isValidUUID } from "@/components/contextual/Chat/helpers"; +import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert"; +import Link from "next/link"; +import { parseAsString, useQueryStates } from "nuqs"; export const WebhookDisclaimer = ({ nodeId }: { nodeId: string }) => { const [{ flowID }] = useQueryStates({ diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx index 98edbca2fb..1ccb3d1261 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx @@ -1,9 +1,9 @@ -import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; +import type { OutputMetadata } from "@/components/contextual/OutputRenderers"; import { globalRegistry, OutputActions, OutputItem, -} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; +} from "@/components/contextual/OutputRenderers"; import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { beautifyString } from "@/lib/utils"; import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx index e9d077bde1..c87a879df1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx @@ -3,7 +3,6 @@ import { CustomNodeData, } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode"; import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput"; -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; import { Button } from "@/components/__legacy__/ui/button"; import { Calendar } from "@/components/__legacy__/ui/calendar"; import { LocalValuedInput } from "@/components/__legacy__/ui/input"; @@ -28,6 +27,7 @@ import { SelectValue, } from "@/components/__legacy__/ui/select"; import { Switch } from "@/components/atoms/Switch/Switch"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput"; import { BlockIOArraySubSchema, diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx index 09e530c296..2890f4e9a2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx @@ -1,16 +1,24 @@ "use client"; -import { useChatPage } from "./useChatPage"; -import { ChatContainer } from "./components/ChatContainer/ChatContainer"; -import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState"; -import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState"; -import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag"; -import { useRouter } from "next/navigation"; +import { Button } from "@/components/__legacy__/ui/button"; +import { scrollbarStyles } from "@/components/styles/scrollbars"; +import { cn } from "@/lib/utils"; +import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; +import { X } from "@phosphor-icons/react"; +import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; +import { Drawer } from "vaul"; + +import { ChatContainer } from "@/components/contextual/Chat/components/ChatContainer/ChatContainer"; +import { ChatErrorState } from "@/components/contextual/Chat/components/ChatErrorState/ChatErrorState"; +import { ChatLoadingState } from "@/components/contextual/Chat/components/ChatLoadingState/ChatLoadingState"; +import { useChatPage } from "./useChatPage"; export default function ChatPage() { const isChatEnabled = useGetFlag(Flag.CHAT); const router = useRouter(); + const pathname = usePathname(); + const isOpen = pathname === "/chat"; const { messages, isLoading, @@ -28,56 +36,88 @@ export default function ChatPage() { } }, [isChatEnabled, router]); + function handleOpenChange(open: boolean) { + if (!open) { + router.replace("/marketplace"); + } + } + if (isChatEnabled === null || isChatEnabled === false) { return null; } return ( -
- {/* Header */} -
-
-

Chat

- {sessionId && ( -
- - Session: {sessionId.slice(0, 8)}... - - -
+ + + -
+ > + {/* Header */} +
+
+ + Chat + +
+ {sessionId && ( + <> + + Session: {sessionId.slice(0, 8)}... + + + + )} + +
+
+
- {/* Main Content */} -
- {/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */} - {(isLoading || isCreating || (!sessionId && !error)) && ( - - )} + {/* Main Content */} +
+ {/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */} + {(isLoading || isCreating || (!sessionId && !error)) && ( + + )} - {/* Error State */} - {error && !isLoading && ( - - )} + {/* Error State */} + {error && !isLoading && ( + + )} - {/* Session Content */} - {sessionId && !isLoading && !error && ( - - )} -
-
+ {/* Session Content */} + {sessionId && !isLoading && !error && ( + + )} + + + + ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts b/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts index 4f1db5471a..70c86de2da 100644 --- a/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useRef } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { toast } from "sonner"; -import { useChatSession } from "@/app/(platform)/chat/useChatSession"; +import { useChatSession } from "@/components/contextual/Chat/useChatSession"; +import { useChatStream } from "@/components/contextual/Chat/useChatStream"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; -import { useChatStream } from "@/app/(platform)/chat/useChatStream"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; export function useChatPage() { const router = useRouter(); diff --git a/autogpt_platform/frontend/src/app/(platform)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/layout.tsx index 2975e7d097..3cb7283b53 100644 --- a/autogpt_platform/frontend/src/app/(platform)/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/layout.tsx @@ -1,13 +1,14 @@ import { Navbar } from "@/components/layout/Navbar/Navbar"; -import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner"; import { ReactNode } from "react"; +import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner"; +import { PlatformLayoutContent } from "./PlatformLayoutContent"; export default function PlatformLayout({ children }: { children: ReactNode }) { return ( -
+ -
{children}
-
+ {children} + ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx index bc9918c2bb..5ced0992f9 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx @@ -3,8 +3,8 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { Text } from "@/components/atoms/Text/Text"; import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types"; -import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs"; -import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs"; +import { CredentialsInput } from "../../../../../../../../../../components/contextual/CredentialsInputs/CredentialsInputs"; +import { RunAgentInputs } from "../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs"; import { getAgentCredentialsFields, getAgentInputFields } from "./helpers"; type Props = { diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx index f3b02bfbc9..30483ac58f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx @@ -1,7 +1,7 @@ -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; import { Input } from "@/components/atoms/Input/Input"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; -import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs"; +import { RunAgentInputs } from "../../../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs"; import { useRunAgentModalContext } from "../../context"; import { ModalSection } from "../ModalSection/ModalSection"; import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner"; diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx index 20e218abb2..9824283c40 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx @@ -3,12 +3,12 @@ import type { OutputMetadata, OutputRenderer, -} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; +} from "@/components/contextual/OutputRenderers"; import { globalRegistry, OutputActions, OutputItem, -} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers"; +} from "@/components/contextual/OutputRenderers"; import React, { useMemo } from "react"; type OutputsRecord = Record>; diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTemplateView/SelectedTemplateView.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTemplateView/SelectedTemplateView.tsx index b5ecb7ae5c..03621ad582 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTemplateView/SelectedTemplateView.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTemplateView/SelectedTemplateView.tsx @@ -4,12 +4,12 @@ import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExe import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { Input } from "@/components/atoms/Input/Input"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; +import { CredentialsInput } from "../../../../../../../../../../components/contextual/CredentialsInputs/CredentialsInputs"; +import { RunAgentInputs } from "../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs"; import { getAgentCredentialsFields, getAgentInputFields, } from "../../modals/AgentInputsReadOnly/helpers"; -import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs"; -import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs"; import { LoadingSelectedContent } from "../LoadingSelectedContent"; import { RunDetailCard } from "../RunDetailCard/RunDetailCard"; import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader"; diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTriggerView/SelectedTriggerView.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTriggerView/SelectedTriggerView.tsx index f92c91112e..168adca7dc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTriggerView/SelectedTriggerView.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedTriggerView/SelectedTriggerView.tsx @@ -3,12 +3,12 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { Input } from "@/components/atoms/Input/Input"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; +import { CredentialsInput } from "../../../../../../../../../../components/contextual/CredentialsInputs/CredentialsInputs"; +import { RunAgentInputs } from "../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs"; import { getAgentCredentialsFields, getAgentInputFields, } from "../../modals/AgentInputsReadOnly/helpers"; -import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs"; -import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs"; import { LoadingSelectedContent } from "../LoadingSelectedContent"; import { RunDetailCard } from "../RunDetailCard/RunDetailCard"; import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader"; diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx index 5f57032618..afc3b64a29 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx @@ -12,8 +12,6 @@ import { } from "@/lib/autogpt-server-api"; import { useBackendAPI } from "@/lib/autogpt-server-api/context"; -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; -import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs"; import { ScheduleTaskDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog"; import ActionButtonGroup from "@/components/__legacy__/action-button-group"; import type { ButtonAction } from "@/components/__legacy__/types"; @@ -30,6 +28,8 @@ import { } from "@/components/__legacy__/ui/icons"; import { Input } from "@/components/__legacy__/ui/input"; import { Button } from "@/components/atoms/Button/Button"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; +import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; import { useToast, diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-output-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-output-view.tsx index e55914b4ea..668ac2e215 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-output-view.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-output-view.tsx @@ -11,12 +11,12 @@ import { } from "@/components/__legacy__/ui/card"; import LoadingBox from "@/components/__legacy__/ui/loading"; -import type { OutputMetadata } from "../../NewAgentLibraryView/components/selected-views/OutputRenderers"; +import type { OutputMetadata } from "../../../../../../../../components/contextual/OutputRenderers"; import { globalRegistry, OutputActions, OutputItem, -} from "../../NewAgentLibraryView/components/selected-views/OutputRenderers"; +} from "../../../../../../../../components/contextual/OutputRenderers"; export function AgentRunOutputView({ agentRunOutputs, diff --git a/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts b/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts index ca33483587..d63eed0ca2 100644 --- a/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts +++ b/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts @@ -4,8 +4,91 @@ import { NextRequest } from "next/server"; /** * SSE Proxy for chat streaming. - * EventSource doesn't support custom headers, so we need a server-side proxy - * that adds authentication and forwards the SSE stream to the client. + * Supports POST with context (page content + URL) in the request body. + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await params; + + try { + const body = await request.json(); + const { message, is_user_message, context } = body; + + if (!message) { + return new Response( + JSON.stringify({ error: "Missing message parameter" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + // Get auth token from server-side session + const token = await getServerAuthToken(); + + // Build backend URL + const backendUrl = environment.getAGPTServerBaseUrl(); + const streamUrl = new URL( + `/api/chat/sessions/${sessionId}/stream`, + backendUrl, + ); + + // Forward request to backend with auth header + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(streamUrl.toString(), { + method: "POST", + headers, + body: JSON.stringify({ + message, + is_user_message: is_user_message ?? true, + context: context || null, + }), + }); + + if (!response.ok) { + const error = await response.text(); + return new Response(error, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } + + // Return the SSE stream directly + return new Response(response.body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); + } catch (error) { + console.error("SSE proxy error:", error); + return new Response( + JSON.stringify({ + error: "Failed to connect to chat service", + detail: error instanceof Error ? error.message : String(error), + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } +} + +/** + * Legacy GET endpoint for backward compatibility */ export async function GET( request: NextRequest, diff --git a/autogpt_platform/frontend/src/app/globals.css b/autogpt_platform/frontend/src/app/globals.css index 1f782f753b..0625c26082 100644 --- a/autogpt_platform/frontend/src/app/globals.css +++ b/autogpt_platform/frontend/src/app/globals.css @@ -141,6 +141,52 @@ } } +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes l3 { + 25% { + background-position: + 0 0, + 100% 100%, + 100% calc(100% - 5px); + } + 50% { + background-position: + 0 100%, + 100% 100%, + 0 calc(100% - 5px); + } + 75% { + background-position: + 0 100%, + 100% 0, + 100% 5px; + } +} + +.loader { + width: 80px; + height: 70px; + border: 5px solid rgb(241 245 249); + padding: 0 8px; + box-sizing: border-box; + background: + linear-gradient(rgb(15 23 42) 0 0) 0 0/8px 20px, + linear-gradient(rgb(15 23 42) 0 0) 100% 0/8px 20px, + radial-gradient(farthest-side, rgb(15 23 42) 90%, #0000) 0 5px/8px 8px + content-box, + transparent; + background-repeat: no-repeat; + animation: l3 2s infinite linear; +} + input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; diff --git a/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx b/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx index f59c01e77d..711f6cc6c6 100644 --- a/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx +++ b/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx @@ -92,7 +92,7 @@ export function Input({ className={cn( baseStyles, errorStyles, - "-mb-1 h-auto min-h-[2.875rem] rounded-medium", + "-mb-1 h-auto min-h-[2.875rem] rounded-full", // Size variants for textarea size === "small" && [ "min-h-[2.25rem]", // 36px minimum @@ -107,6 +107,11 @@ export function Input({ )} placeholder={placeholder || label} onChange={handleTextareaChange} + onKeyDown={ + props.onKeyDown as + | React.KeyboardEventHandler + | undefined + } rows={props.rows || 3} {...(hideLabel ? { "aria-label": label } : {})} id={props.id} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx new file mode 100644 index 0000000000..6d8769b27b --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; +import { List } from "@phosphor-icons/react"; +import React, { useState } from "react"; +import { ChatContainer } from "./components/ChatContainer/ChatContainer"; +import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState"; +import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState"; +import { SessionsDrawer } from "./components/SessionsDrawer/SessionsDrawer"; +import { useChat } from "./useChat"; + +export interface ChatProps { + className?: string; + headerTitle?: React.ReactNode; + showHeader?: boolean; + showSessionInfo?: boolean; + showNewChatButton?: boolean; + onNewChat?: () => void; + headerActions?: React.ReactNode; +} + +export function Chat({ + className, + headerTitle = "AutoGPT Copilot", + showHeader = true, + showSessionInfo = true, + showNewChatButton = true, + onNewChat, + headerActions, +}: ChatProps) { + const { + messages, + isLoading, + isCreating, + error, + sessionId, + createSession, + clearSession, + refreshSession, + loadSession, + } = useChat(); + + const [isSessionsDrawerOpen, setIsSessionsDrawerOpen] = useState(false); + + const handleNewChat = () => { + clearSession(); + onNewChat?.(); + }; + + const handleSelectSession = async (sessionId: string) => { + try { + await loadSession(sessionId); + } catch (err) { + console.error("Failed to load session:", err); + } + }; + + return ( +
+ {/* Header */} + {showHeader && ( +
+
+
+ + {typeof headerTitle === "string" ? ( + + {headerTitle} + + ) : ( + headerTitle + )} +
+
+ {showSessionInfo && sessionId && ( + <> + {showNewChatButton && ( + + )} + + )} + {headerActions} +
+
+
+ )} + + {/* Main Content */} +
+ {/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */} + {(isLoading || isCreating || (!sessionId && !error)) && ( + + )} + + {/* Error State */} + {error && !isLoading && ( + + )} + + {/* Session Content */} + {sessionId && !isLoading && !error && ( + + )} +
+ + {/* Sessions Drawer */} + setIsSessionsDrawerOpen(false)} + onSelectSession={handleSelectSession} + currentSessionId={sessionId} + /> +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/ChatDrawer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/ChatDrawer.tsx new file mode 100644 index 0000000000..4dc79ece92 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/ChatDrawer.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { scrollbarStyles } from "@/components/styles/scrollbars"; +import { cn } from "@/lib/utils"; +import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; +import { X } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { Drawer } from "vaul"; +import { Chat } from "./Chat"; +import { useChatDrawer } from "./useChatDrawer"; + +interface ChatDrawerProps { + blurBackground?: boolean; +} + +export function ChatDrawer({ blurBackground = true }: ChatDrawerProps) { + const [isMounted, setIsMounted] = useState(false); + const isChatEnabled = useGetFlag(Flag.CHAT); + const { isOpen, close } = useChatDrawer(); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (isChatEnabled === false && isOpen) { + close(); + } + }, [isChatEnabled, isOpen, close]); + + // Don't render on server - vaul drawer accesses document during SSR + if (!isMounted || isChatEnabled === null || isChatEnabled === false) { + return null; + } + + return ( + { + if (!open) { + close(); + } + }} + direction="right" + modal={false} + > + {blurBackground && isOpen && ( +
+ )} + e.stopPropagation()} + onInteractOutside={blurBackground ? close : undefined} + className={cn( + "fixed right-0 top-[60px] z-50 flex h-[calc(100vh-60px)] w-1/2 flex-col border-l border-zinc-200 bg-white", + scrollbarStyles, + )} + > + + AutoGPT Copilot + + } + headerActions={ + + } + /> + + + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx new file mode 100644 index 0000000000..582b24de5e --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx @@ -0,0 +1,119 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { Card } from "@/components/atoms/Card/Card"; +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; +import { ArrowRight, List, Robot } from "@phosphor-icons/react"; +import Image from "next/image"; + +export interface Agent { + id: string; + name: string; + description: string; + version?: number; + image_url?: string; +} + +export interface AgentCarouselMessageProps { + agents: Agent[]; + totalCount?: number; + onSelectAgent?: (agentId: string) => void; + className?: string; +} + +export function AgentCarouselMessage({ + agents, + totalCount, + onSelectAgent, + className, +}: AgentCarouselMessageProps) { + const displayCount = totalCount ?? agents.length; + + return ( +
+ {/* Header */} +
+
+ +
+
+ + Found {displayCount} {displayCount === 1 ? "Agent" : "Agents"} + + + Select an agent to view details or run it + +
+
+ + {/* Agent Cards */} +
+ {agents.map((agent) => ( + +
+
+ {agent.image_url ? ( + {`${agent.name} + ) : ( +
+ +
+ )} +
+
+
+ + {agent.name} + + {agent.version && ( + + v{agent.version} + + )} +
+ + {agent.description} + + {onSelectAgent && ( + + )} +
+
+
+ ))} +
+ + {totalCount && totalCount > agents.length && ( + + Showing {agents.length} of {totalCount} results + + )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx new file mode 100644 index 0000000000..9fdcd7b194 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { Card } from "@/components/atoms/Card/Card"; +import { Text } from "@/components/atoms/Text/Text"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; +import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs"; +import type { + BlockIOCredentialsSubSchema, + BlockIOSubSchema, +} from "@/lib/autogpt-server-api/types"; +import { cn } from "@/lib/utils"; +import { PlayIcon, WarningIcon } from "@phosphor-icons/react"; +import { useMemo } from "react"; +import { useAgentInputsSetup } from "./useAgentInputsSetup"; + +interface Props { + agentName?: string; + inputSchema: Record; + credentialsSchema?: Record; + message: string; + onRun: ( + inputs: Record, + credentials: Record, + ) => void; + onCancel?: () => void; + className?: string; +} + +export function AgentInputsSetup({ + agentName, + inputSchema, + credentialsSchema, + message, + onRun, + onCancel, + className, +}: Props) { + const { inputValues, setInputValue, credentialsValues, setCredentialsValue } = + useAgentInputsSetup(); + + const inputFields = Object.entries(inputSchema || {}); + const credentialFields = Object.entries(credentialsSchema || {}); + + const allRequiredInputsAreSet = useMemo(() => { + const requiredFields = Object.entries(inputSchema || {}).filter( + ([_, schema]) => !schema.hidden, + ); + return requiredFields.every(([key]) => { + const value = inputValues[key]; + return value !== undefined && value !== null && value !== ""; + }); + }, [inputSchema, inputValues]); + + const allCredentialsAreSet = useMemo(() => { + if (!credentialsSchema || Object.keys(credentialsSchema).length === 0) { + return true; + } + return Object.keys(credentialsSchema).every( + (key) => credentialsValues[key] !== undefined, + ); + }, [credentialsSchema, credentialsValues]); + + const canRun = allRequiredInputsAreSet && allCredentialsAreSet; + + function handleRun() { + if (canRun) { + onRun(inputValues, credentialsValues); + } + } + + return ( + +
+
+ +
+
+ + {agentName ? `Configure ${agentName}` : "Agent Configuration"} + + + {message} + + + {inputFields.length > 0 && ( +
+ {inputFields.map(([key, schema]) => { + if (schema.hidden) return null; + const defaultValue = (schema as any).default; + return ( + setInputValue(key, value)} + /> + ); + })} +
+ )} + + {credentialFields.length > 0 && ( +
+ {credentialFields.map(([key, schema]) => ( + + setCredentialsValue(key, value) + } + siblingInputs={inputValues} + /> + ))} +
+ )} + +
+ + {onCancel && ( + + )} +
+
+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts new file mode 100644 index 0000000000..e36a3f3c5d --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts @@ -0,0 +1,38 @@ +import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types"; +import { useState } from "react"; + +export function useAgentInputsSetup() { + const [inputValues, setInputValues] = useState>({}); + const [credentialsValues, setCredentialsValues] = useState< + Record + >({}); + + function setInputValue(key: string, value: any) { + setInputValues((prev) => ({ + ...prev, + [key]: value, + })); + } + + function setCredentialsValue(key: string, value?: CredentialsMetaInput) { + if (value) { + setCredentialsValues((prev) => ({ + ...prev, + [key]: value, + })); + } else { + setCredentialsValues((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + } + } + + return { + inputValues, + setInputValue, + credentialsValues, + setCredentialsValue, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx new file mode 100644 index 0000000000..33f02e660f --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { cn } from "@/lib/utils"; +import { ShieldIcon, SignInIcon, UserPlusIcon } from "@phosphor-icons/react"; +import { useRouter } from "next/navigation"; + +export interface AuthPromptWidgetProps { + message: string; + sessionId: string; + agentInfo?: { + graph_id: string; + name: string; + trigger_type: string; + }; + returnUrl?: string; + className?: string; +} + +export function AuthPromptWidget({ + message, + sessionId, + agentInfo, + returnUrl = "/chat", + className, +}: AuthPromptWidgetProps) { + const router = useRouter(); + + function handleSignIn() { + if (typeof window !== "undefined") { + localStorage.setItem("pending_chat_session", sessionId); + if (agentInfo) { + localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo)); + } + } + const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`; + const encodedReturnUrl = encodeURIComponent(returnUrlWithSession); + router.push(`/login?returnUrl=${encodedReturnUrl}`); + } + + function handleSignUp() { + if (typeof window !== "undefined") { + localStorage.setItem("pending_chat_session", sessionId); + if (agentInfo) { + localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo)); + } + } + const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`; + const encodedReturnUrl = encodeURIComponent(returnUrlWithSession); + router.push(`/signup?returnUrl=${encodedReturnUrl}`); + } + + return ( +
+
+
+
+ +
+
+

+ Authentication Required +

+

+ Sign in to set up and manage agents +

+
+
+ +
+

{message}

+ {agentInfo && ( +
+

+ Ready to set up:{" "} + {agentInfo.name} +

+

+ Type:{" "} + {agentInfo.trigger_type} +

+
+ )} +
+ +
+ + +
+ +
+ Your chat session will be preserved after signing in +
+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx new file mode 100644 index 0000000000..63667d8c55 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx @@ -0,0 +1,89 @@ +import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; +import { cn } from "@/lib/utils"; +import { useCallback } from "react"; +import { usePageContext } from "../../usePageContext"; +import { ChatInput } from "../ChatInput/ChatInput"; +import { MessageList } from "../MessageList/MessageList"; +import { QuickActionsWelcome } from "../QuickActionsWelcome/QuickActionsWelcome"; +import { useChatContainer } from "./useChatContainer"; + +export interface ChatContainerProps { + sessionId: string | null; + initialMessages: SessionDetailResponse["messages"]; + onRefreshSession: () => Promise; + className?: string; +} + +export function ChatContainer({ + sessionId, + initialMessages, + onRefreshSession, + className, +}: ChatContainerProps) { + const { messages, streamingChunks, isStreaming, sendMessage } = + useChatContainer({ + sessionId, + initialMessages, + onRefreshSession, + }); + const { capturePageContext } = usePageContext(); + + // Wrap sendMessage to automatically capture page context + const sendMessageWithContext = useCallback( + async (content: string, isUserMessage: boolean = true) => { + const context = capturePageContext(); + await sendMessage(content, isUserMessage, context); + }, + [sendMessage, capturePageContext], + ); + + const quickActions = [ + "Find agents for social media management", + "Show me agents for content creation", + "Help me automate my business", + "What can you help me with?", + ]; + + return ( +
+ {/* Messages or Welcome Screen */} + {messages.length === 0 ? ( + + ) : ( + + )} + + {/* Input - Always visible */} +
+ +
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts new file mode 100644 index 0000000000..55a44af8c7 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts @@ -0,0 +1,60 @@ +import type { StreamChunk } from "@/components/contextual/Chat/useChatStream"; +import { toast } from "sonner"; +import type { HandlerDependencies } from "./useChatContainer.handlers"; +import { + handleError, + handleLoginNeeded, + handleStreamEnd, + handleTextChunk, + handleTextEnded, + handleToolCallStart, + handleToolResponse, +} from "./useChatContainer.handlers"; + +export function createStreamEventDispatcher( + deps: HandlerDependencies, +): (chunk: StreamChunk) => void { + return function dispatchStreamEvent(chunk: StreamChunk): void { + switch (chunk.type) { + case "text_chunk": + handleTextChunk(chunk, deps); + break; + + case "text_ended": + handleTextEnded(chunk, deps); + break; + + case "tool_call_start": + handleToolCallStart(chunk, deps); + break; + + case "tool_response": + handleToolResponse(chunk, deps); + break; + + case "login_needed": + case "need_login": + handleLoginNeeded(chunk, deps); + break; + + case "stream_end": + handleStreamEnd(chunk, deps); + break; + + case "error": + handleError(chunk, deps); + // Show toast at dispatcher level to avoid circular dependencies + toast.error("Chat Error", { + description: chunk.message || chunk.content || "An error occurred", + }); + break; + + case "usage": + // TODO: Handle usage for display + break; + + default: + console.warn("Unknown stream chunk type:", chunk); + } + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts new file mode 100644 index 0000000000..fdf1d9332d --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts @@ -0,0 +1,379 @@ +import type { ToolResult } from "@/types/chat"; +import type { ChatMessageData } from "../ChatMessage/useChatMessage"; + +export function removePageContext(content: string): string { + // Remove "Page URL: ..." pattern (case insensitive, handles various formats) + let cleaned = content.replace(/Page URL:\s*[^\n\r]*/gi, ""); + + // Find "User Message:" marker to preserve the actual user message + const userMessageMatch = cleaned.match(/User Message:\s*([\s\S]*)$/i); + if (userMessageMatch) { + // If we found "User Message:", extract everything after it + cleaned = userMessageMatch[1]; + } else { + // If no "User Message:" marker, remove "Page Content:" and everything after it + cleaned = cleaned.replace(/Page Content:[\s\S]*$/gi, ""); + } + + // Clean up extra whitespace and newlines + cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, "\n\n").trim(); + return cleaned; +} + +export function createUserMessage(content: string): ChatMessageData { + return { + type: "message", + role: "user", + content, + timestamp: new Date(), + }; +} + +export function filterAuthMessages( + messages: ChatMessageData[], +): ChatMessageData[] { + return messages.filter( + (msg) => msg.type !== "credentials_needed" && msg.type !== "login_needed", + ); +} + +export function isValidMessage(msg: unknown): msg is Record { + if (typeof msg !== "object" || msg === null) { + return false; + } + const m = msg as Record; + if (typeof m.role !== "string") { + return false; + } + if (m.content !== undefined && typeof m.content !== "string") { + return false; + } + return true; +} + +export function isToolCallArray(value: unknown): value is Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; +}> { + if (!Array.isArray(value)) { + return false; + } + return value.every( + (item) => + typeof item === "object" && + item !== null && + "id" in item && + typeof item.id === "string" && + "type" in item && + typeof item.type === "string" && + "function" in item && + typeof item.function === "object" && + item.function !== null && + "name" in item.function && + typeof item.function.name === "string" && + "arguments" in item.function && + typeof item.function.arguments === "string", + ); +} + +export function isAgentArray(value: unknown): value is Array<{ + id: string; + name: string; + description: string; + version?: number; + image_url?: string; +}> { + if (!Array.isArray(value)) { + return false; + } + return value.every( + (item) => + typeof item === "object" && + item !== null && + "id" in item && + typeof item.id === "string" && + "name" in item && + typeof item.name === "string" && + "description" in item && + typeof item.description === "string" && + (!("version" in item) || typeof item.version === "number") && + (!("image_url" in item) || typeof item.image_url === "string"), + ); +} + +export function extractJsonFromErrorMessage( + message: string, +): Record | null { + try { + const start = message.indexOf("{"); + if (start === -1) { + return null; + } + let depth = 0; + let end = -1; + for (let i = start; i < message.length; i++) { + const ch = message[i]; + if (ch === "{") { + depth++; + } else if (ch === "}") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) { + return null; + } + const jsonStr = message.slice(start, end + 1); + return JSON.parse(jsonStr) as Record; + } catch { + return null; + } +} + +export function parseToolResponse( + result: ToolResult, + toolId: string, + toolName: string, + timestamp?: Date, +): ChatMessageData | null { + let parsedResult: Record | null = null; + try { + parsedResult = + typeof result === "string" + ? JSON.parse(result) + : (result as Record); + } catch { + parsedResult = null; + } + if (parsedResult && typeof parsedResult === "object") { + const responseType = parsedResult.type as string | undefined; + if (responseType === "no_results") { + return { + type: "tool_response", + toolId, + toolName, + result: (parsedResult.message as string) || "No results found", + success: true, + timestamp: timestamp || new Date(), + }; + } + if (responseType === "agent_carousel") { + const agentsData = parsedResult.agents; + if (isAgentArray(agentsData)) { + return { + type: "agent_carousel", + toolName: "agent_carousel", + agents: agentsData, + totalCount: parsedResult.total_count as number | undefined, + timestamp: timestamp || new Date(), + }; + } else { + console.warn("Invalid agents array in agent_carousel response"); + } + } + if (responseType === "execution_started") { + return { + type: "execution_started", + toolName: "execution_started", + executionId: (parsedResult.execution_id as string) || "", + agentName: (parsedResult.graph_name as string) || undefined, + message: parsedResult.message as string | undefined, + libraryAgentLink: parsedResult.library_agent_link as string | undefined, + timestamp: timestamp || new Date(), + }; + } + if (responseType === "need_login") { + return { + type: "login_needed", + toolName: "login_needed", + message: + (parsedResult.message as string) || + "Please sign in to use chat and agent features", + sessionId: (parsedResult.session_id as string) || "", + agentInfo: parsedResult.agent_info as + | { + graph_id: string; + name: string; + trigger_type: string; + } + | undefined, + timestamp: timestamp || new Date(), + }; + } + if (responseType === "setup_requirements") { + return null; + } + } + return { + type: "tool_response", + toolId, + toolName, + result, + success: true, + timestamp: timestamp || new Date(), + }; +} + +export function isUserReadiness( + value: unknown, +): value is { missing_credentials?: Record } { + return ( + typeof value === "object" && + value !== null && + (!("missing_credentials" in value) || + typeof (value as any).missing_credentials === "object") + ); +} + +export function isMissingCredentials( + value: unknown, +): value is Record> { + if (typeof value !== "object" || value === null) { + return false; + } + return Object.values(value).every((v) => typeof v === "object" && v !== null); +} + +export function isSetupInfo(value: unknown): value is { + user_readiness?: Record; + agent_name?: string; +} { + return ( + typeof value === "object" && + value !== null && + (!("user_readiness" in value) || + typeof (value as any).user_readiness === "object") && + (!("agent_name" in value) || typeof (value as any).agent_name === "string") + ); +} + +export function extractCredentialsNeeded( + parsedResult: Record, + toolName: string = "run_agent", +): ChatMessageData | null { + try { + const setupInfo = parsedResult?.setup_info as + | Record + | undefined; + const userReadiness = setupInfo?.user_readiness as + | Record + | undefined; + const missingCreds = userReadiness?.missing_credentials as + | Record> + | undefined; + if (missingCreds && Object.keys(missingCreds).length > 0) { + const agentName = (setupInfo?.agent_name as string) || "this block"; + const credentials = Object.values(missingCreds).map((credInfo) => ({ + provider: (credInfo.provider as string) || "unknown", + providerName: + (credInfo.provider_name as string) || + (credInfo.provider as string) || + "Unknown Provider", + credentialType: + (credInfo.type as + | "api_key" + | "oauth2" + | "user_password" + | "host_scoped") || "api_key", + title: + (credInfo.title as string) || + `${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`, + scopes: credInfo.scopes as string[] | undefined, + })); + return { + type: "credentials_needed", + toolName, + credentials, + message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`, + agentName, + timestamp: new Date(), + }; + } + return null; + } catch (err) { + console.error("Failed to extract credentials from setup info:", err); + return null; + } +} + +export function extractInputsNeeded( + parsedResult: Record, + toolName: string = "run_agent", +): ChatMessageData | null { + try { + const setupInfo = parsedResult?.setup_info as + | Record + | undefined; + const requirements = setupInfo?.requirements as + | Record + | undefined; + const inputs = requirements?.inputs as + | Array> + | undefined; + const credentials = requirements?.credentials as + | Array> + | undefined; + + if (!inputs || inputs.length === 0) { + return null; + } + + const agentName = (setupInfo?.agent_name as string) || "this agent"; + const agentId = parsedResult?.graph_id as string | undefined; + const graphVersion = parsedResult?.graph_version as number | undefined; + + const inputSchema: Record = {}; + inputs.forEach((input) => { + const name = input.name as string; + if (name) { + inputSchema[name] = { + title: input.name as string, + description: (input.description as string) || "", + type: (input.type as string) || "string", + default: input.default, + required: (input.required as boolean) || false, + enum: input.options, + format: input.format, + }; + } + }); + + const credentialsSchema: Record = {}; + if (credentials && credentials.length > 0) { + credentials.forEach((cred) => { + const id = cred.id as string; + if (id) { + credentialsSchema[id] = { + type: "object", + properties: {}, + credentials_provider: [cred.provider as string], + credentials_types: [(cred.type as string) || "api_key"], + credentials_scopes: cred.scopes as string[] | undefined, + }; + } + }); + } + + return { + type: "inputs_needed", + toolName, + agentName, + agentId, + graphVersion, + inputSchema, + credentialsSchema: + Object.keys(credentialsSchema).length > 0 + ? credentialsSchema + : undefined, + message: `Please provide the required inputs to run ${agentName}.`, + timestamp: new Date(), + }; + } catch (err) { + console.error("Failed to extract inputs from setup info:", err); + return null; + } +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.handlers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.handlers.ts new file mode 100644 index 0000000000..260241a46d --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.handlers.ts @@ -0,0 +1,224 @@ +import type { StreamChunk } from "@/components/contextual/Chat/useChatStream"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import type { ChatMessageData } from "../ChatMessage/useChatMessage"; +import { + extractCredentialsNeeded, + extractInputsNeeded, + parseToolResponse, +} from "./helpers"; + +export interface HandlerDependencies { + setHasTextChunks: Dispatch>; + setStreamingChunks: Dispatch>; + streamingChunksRef: MutableRefObject; + setMessages: Dispatch>; + setIsStreamingInitiated: Dispatch>; + sessionId: string; +} + +export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) { + if (!chunk.content) return; + deps.setHasTextChunks(true); + deps.setStreamingChunks((prev) => { + const updated = [...prev, chunk.content!]; + deps.streamingChunksRef.current = updated; + return updated; + }); +} + +export function handleTextEnded( + _chunk: StreamChunk, + deps: HandlerDependencies, +) { + console.log("[Text Ended] Saving streamed text as assistant message"); + const completedText = deps.streamingChunksRef.current.join(""); + if (completedText.trim()) { + const assistantMessage: ChatMessageData = { + type: "message", + role: "assistant", + content: completedText, + timestamp: new Date(), + }; + deps.setMessages((prev) => [...prev, assistantMessage]); + } + deps.setStreamingChunks([]); + deps.streamingChunksRef.current = []; + deps.setHasTextChunks(false); + deps.setIsStreamingInitiated(false); +} + +export function handleToolCallStart( + chunk: StreamChunk, + deps: HandlerDependencies, +) { + const toolCallMessage: ChatMessageData = { + type: "tool_call", + toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`, + toolName: chunk.tool_name || "Executing...", + arguments: chunk.arguments || {}, + timestamp: new Date(), + }; + deps.setMessages((prev) => [...prev, toolCallMessage]); + console.log("[Tool Call Start]", { + toolId: toolCallMessage.toolId, + toolName: toolCallMessage.toolName, + timestamp: new Date().toISOString(), + }); +} + +export function handleToolResponse( + chunk: StreamChunk, + deps: HandlerDependencies, +) { + console.log("[Tool Response] Received:", { + toolId: chunk.tool_id, + toolName: chunk.tool_name, + timestamp: new Date().toISOString(), + }); + let toolName = chunk.tool_name || "unknown"; + if (!chunk.tool_name || chunk.tool_name === "unknown") { + deps.setMessages((prev) => { + const matchingToolCall = [...prev] + .reverse() + .find( + (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id, + ); + if (matchingToolCall && matchingToolCall.type === "tool_call") { + toolName = matchingToolCall.toolName; + } + return prev; + }); + } + const responseMessage = parseToolResponse( + chunk.result!, + chunk.tool_id!, + toolName, + new Date(), + ); + if (!responseMessage) { + let parsedResult: Record | null = null; + try { + parsedResult = + typeof chunk.result === "string" + ? JSON.parse(chunk.result) + : (chunk.result as Record); + } catch { + parsedResult = null; + } + if ( + (chunk.tool_name === "run_agent" || chunk.tool_name === "run_block") && + chunk.success && + parsedResult?.type === "setup_requirements" + ) { + const inputsMessage = extractInputsNeeded(parsedResult, chunk.tool_name); + if (inputsMessage) { + deps.setMessages((prev) => [...prev, inputsMessage]); + } + const credentialsMessage = extractCredentialsNeeded( + parsedResult, + chunk.tool_name, + ); + if (credentialsMessage) { + deps.setMessages((prev) => [...prev, credentialsMessage]); + } + } + return; + } + deps.setMessages((prev) => { + const toolCallIndex = prev.findIndex( + (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id, + ); + if (toolCallIndex !== -1) { + const newMessages = [...prev]; + newMessages[toolCallIndex] = responseMessage; + console.log( + "[Tool Response] Replaced tool_call with matching tool_id:", + chunk.tool_id, + "at index:", + toolCallIndex, + ); + return newMessages; + } + console.warn( + "[Tool Response] No tool_call found with tool_id:", + chunk.tool_id, + "appending instead", + ); + return [...prev, responseMessage]; + }); +} + +export function handleLoginNeeded( + chunk: StreamChunk, + deps: HandlerDependencies, +) { + const loginNeededMessage: ChatMessageData = { + type: "login_needed", + toolName: "login_needed", + message: chunk.message || "Please sign in to use chat and agent features", + sessionId: chunk.session_id || deps.sessionId, + agentInfo: chunk.agent_info, + timestamp: new Date(), + }; + deps.setMessages((prev) => [...prev, loginNeededMessage]); +} + +export function handleStreamEnd( + _chunk: StreamChunk, + deps: HandlerDependencies, +) { + const completedContent = deps.streamingChunksRef.current.join(""); + // Only save message if there are uncommitted chunks + // (text_ended already saved if there were tool calls) + if (completedContent.trim()) { + console.log( + "[Stream End] Saving remaining streamed text as assistant message", + ); + const assistantMessage: ChatMessageData = { + type: "message", + role: "assistant", + content: completedContent, + timestamp: new Date(), + }; + deps.setMessages((prev) => { + const updated = [...prev, assistantMessage]; + console.log("[Stream End] Final state:", { + localMessages: updated.map((m) => ({ + type: m.type, + ...(m.type === "message" && { + role: m.role, + contentLength: m.content.length, + }), + ...(m.type === "tool_call" && { + toolId: m.toolId, + toolName: m.toolName, + }), + ...(m.type === "tool_response" && { + toolId: m.toolId, + toolName: m.toolName, + success: m.success, + }), + })), + streamingChunks: deps.streamingChunksRef.current, + timestamp: new Date().toISOString(), + }); + return updated; + }); + } else { + console.log("[Stream End] No uncommitted chunks, message already saved"); + } + deps.setStreamingChunks([]); + deps.streamingChunksRef.current = []; + deps.setHasTextChunks(false); + deps.setIsStreamingInitiated(false); + console.log("[Stream End] Stream complete, messages in local state"); +} + +export function handleError(chunk: StreamChunk, deps: HandlerDependencies) { + const errorMessage = chunk.message || chunk.content || "An error occurred"; + console.error("Stream error:", errorMessage); + deps.setIsStreamingInitiated(false); + deps.setHasTextChunks(false); + deps.setStreamingChunks([]); + deps.streamingChunksRef.current = []; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts new file mode 100644 index 0000000000..5ce5aaf254 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts @@ -0,0 +1,210 @@ +import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; +import { useChatStream } from "@/components/contextual/Chat/useChatStream"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { ChatMessageData } from "../ChatMessage/useChatMessage"; +import { createStreamEventDispatcher } from "./createStreamEventDispatcher"; +import { + createUserMessage, + filterAuthMessages, + isToolCallArray, + isValidMessage, + parseToolResponse, + removePageContext, +} from "./helpers"; + +interface UseChatContainerArgs { + sessionId: string | null; + initialMessages: SessionDetailResponse["messages"]; + onRefreshSession: () => Promise; +} + +export function useChatContainer({ + sessionId, + initialMessages, +}: UseChatContainerArgs) { + const [messages, setMessages] = useState([]); + const [streamingChunks, setStreamingChunks] = useState([]); + const [hasTextChunks, setHasTextChunks] = useState(false); + const [isStreamingInitiated, setIsStreamingInitiated] = useState(false); + const streamingChunksRef = useRef([]); + const { error, sendMessage: sendStreamMessage } = useChatStream(); + const isStreaming = isStreamingInitiated || hasTextChunks; + + const allMessages = useMemo(() => { + const processedInitialMessages: ChatMessageData[] = []; + // Map to track tool calls by their ID so we can look up tool names for tool responses + const toolCallMap = new Map(); + + for (const msg of initialMessages) { + if (!isValidMessage(msg)) { + console.warn("Invalid message structure from backend:", msg); + continue; + } + + let content = String(msg.content || ""); + const role = String(msg.role || "assistant").toLowerCase(); + const toolCalls = msg.tool_calls; + const timestamp = msg.timestamp + ? new Date(msg.timestamp as string) + : undefined; + + // Remove page context from user messages when loading existing sessions + if (role === "user") { + content = removePageContext(content); + // Skip user messages that become empty after removing page context + if (!content.trim()) { + continue; + } + processedInitialMessages.push({ + type: "message", + role: "user", + content, + timestamp, + }); + continue; + } + + // Handle assistant messages first (before tool messages) to build tool call map + if (role === "assistant") { + // Strip tags from content + content = content + .replace(/[\s\S]*?<\/thinking>/gi, "") + .trim(); + + // If assistant has tool calls, create tool_call messages for each + if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) { + for (const toolCall of toolCalls) { + const toolName = toolCall.function.name; + const toolId = toolCall.id; + // Store tool name for later lookup + toolCallMap.set(toolId, toolName); + + try { + const args = JSON.parse(toolCall.function.arguments || "{}"); + processedInitialMessages.push({ + type: "tool_call", + toolId, + toolName, + arguments: args, + timestamp, + }); + } catch (err) { + console.warn("Failed to parse tool call arguments:", err); + processedInitialMessages.push({ + type: "tool_call", + toolId, + toolName, + arguments: {}, + timestamp, + }); + } + } + // Only add assistant message if there's content after stripping thinking tags + if (content.trim()) { + processedInitialMessages.push({ + type: "message", + role: "assistant", + content, + timestamp, + }); + } + } else if (content.trim()) { + // Assistant message without tool calls, but with content + processedInitialMessages.push({ + type: "message", + role: "assistant", + content, + timestamp, + }); + } + continue; + } + + // Handle tool messages - look up tool name from tool call map + if (role === "tool") { + const toolCallId = (msg.tool_call_id as string) || ""; + const toolName = toolCallMap.get(toolCallId) || "unknown"; + const toolResponse = parseToolResponse( + content, + toolCallId, + toolName, + timestamp, + ); + if (toolResponse) { + processedInitialMessages.push(toolResponse); + } + continue; + } + + // Handle other message types (system, etc.) + if (content.trim()) { + processedInitialMessages.push({ + type: "message", + role: role as "user" | "assistant" | "system", + content, + timestamp, + }); + } + } + + return [...processedInitialMessages, ...messages]; + }, [initialMessages, messages]); + + const sendMessage = useCallback( + async function sendMessage( + content: string, + isUserMessage: boolean = true, + context?: { url: string; content: string }, + ) { + if (!sessionId) { + console.error("Cannot send message: no session ID"); + return; + } + if (isUserMessage) { + const userMessage = createUserMessage(content); + setMessages((prev) => [...filterAuthMessages(prev), userMessage]); + } else { + setMessages((prev) => filterAuthMessages(prev)); + } + setStreamingChunks([]); + streamingChunksRef.current = []; + setHasTextChunks(false); + setIsStreamingInitiated(true); + const dispatcher = createStreamEventDispatcher({ + setHasTextChunks, + setStreamingChunks, + streamingChunksRef, + setMessages, + sessionId, + setIsStreamingInitiated, + }); + try { + await sendStreamMessage( + sessionId, + content, + dispatcher, + isUserMessage, + context, + ); + } catch (err) { + console.error("Failed to send message:", err); + setIsStreamingInitiated(false); + const errorMessage = + err instanceof Error ? err.message : "Failed to send message"; + toast.error("Failed to send message", { + description: errorMessage, + }); + } + }, + [sessionId, sendStreamMessage], + ); + + return { + messages: allMessages, + streamingChunks, + isStreaming, + error, + sendMessage, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx new file mode 100644 index 0000000000..0e677c06bc --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx @@ -0,0 +1,149 @@ +import { Text } from "@/components/atoms/Text/Text"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; +import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api"; +import { cn } from "@/lib/utils"; +import { CheckIcon, RobotIcon, WarningIcon } from "@phosphor-icons/react"; +import { useEffect, useRef } from "react"; +import { useChatCredentialsSetup } from "./useChatCredentialsSetup"; + +export interface CredentialInfo { + provider: string; + providerName: string; + credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped"; + title: string; + scopes?: string[]; +} + +interface Props { + credentials: CredentialInfo[]; + agentName?: string; + message: string; + onAllCredentialsComplete: () => void; + onCancel: () => void; + className?: string; +} + +function createSchemaFromCredentialInfo( + credential: CredentialInfo, +): BlockIOCredentialsSubSchema { + return { + type: "object", + properties: {}, + credentials_provider: [credential.provider], + credentials_types: [credential.credentialType], + credentials_scopes: credential.scopes, + discriminator: undefined, + discriminator_mapping: undefined, + discriminator_values: undefined, + }; +} + +export function ChatCredentialsSetup({ + credentials, + agentName: _agentName, + message, + onAllCredentialsComplete, + onCancel: _onCancel, +}: Props) { + const { selectedCredentials, isAllComplete, handleCredentialSelect } = + useChatCredentialsSetup(credentials); + + // Track if we've already called completion to prevent double calls + const hasCalledCompleteRef = useRef(false); + + // Reset the completion flag when credentials change (new credential setup flow) + useEffect( + function resetCompletionFlag() { + hasCalledCompleteRef.current = false; + }, + [credentials], + ); + + // Auto-call completion when all credentials are configured + useEffect( + function autoCompleteWhenReady() { + if (isAllComplete && !hasCalledCompleteRef.current) { + hasCalledCompleteRef.current = true; + onAllCredentialsComplete(); + } + }, + [isAllComplete, onAllCredentialsComplete], + ); + + return ( +
+
+
+
+ +
+
+ +
+
+
+
+
+ + Credentials Required + + + {message} + +
+ +
+ {credentials.map((cred, index) => { + const schema = createSchemaFromCredentialInfo(cred); + const isSelected = !!selectedCredentials[cred.provider]; + + return ( +
+
+ {isSelected ? ( + + ) : ( + + )} + + {cred.providerName} + +
+ + + handleCredentialSelect(cred.provider, credMeta) + } + /> +
+ ); + })} +
+
+
+
+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts new file mode 100644 index 0000000000..6b4b26e834 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts @@ -0,0 +1,36 @@ +import { useState, useMemo } from "react"; +import type { CredentialInfo } from "./ChatCredentialsSetup"; +import type { CredentialsMetaInput } from "@/lib/autogpt-server-api"; + +export function useChatCredentialsSetup(credentials: CredentialInfo[]) { + const [selectedCredentials, setSelectedCredentials] = useState< + Record + >({}); + + // Check if all credentials are configured + const isAllComplete = useMemo( + function checkAllComplete() { + if (credentials.length === 0) return false; + return credentials.every((cred) => selectedCredentials[cred.provider]); + }, + [credentials, selectedCredentials], + ); + + function handleCredentialSelect( + provider: string, + credential?: CredentialsMetaInput, + ) { + if (credential) { + setSelectedCredentials((prev) => ({ + ...prev, + [provider]: credential, + })); + } + } + + return { + selectedCredentials, + isAllComplete, + handleCredentialSelect, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatErrorState/ChatErrorState.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatErrorState/ChatErrorState.tsx new file mode 100644 index 0000000000..bac13d1b0c --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatErrorState/ChatErrorState.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; +import { cn } from "@/lib/utils"; + +export interface ChatErrorStateProps { + error: Error; + onRetry?: () => void; + className?: string; +} + +export function ChatErrorState({ + error, + onRetry, + className, +}: ChatErrorStateProps) { + return ( +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx new file mode 100644 index 0000000000..3101174a11 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx @@ -0,0 +1,64 @@ +import { Input } from "@/components/atoms/Input/Input"; +import { cn } from "@/lib/utils"; +import { ArrowUpIcon } from "@phosphor-icons/react"; +import { useChatInput } from "./useChatInput"; + +export interface ChatInputProps { + onSend: (message: string) => void; + disabled?: boolean; + placeholder?: string; + className?: string; +} + +export function ChatInput({ + onSend, + disabled = false, + placeholder = "Type your message...", + className, +}: ChatInputProps) { + const inputId = "chat-input"; + const { value, setValue, handleKeyDown, handleSend } = useChatInput({ + onSend, + disabled, + maxRows: 5, + inputId, + }); + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + rows={1} + wrapperClassName="mb-0 relative" + className="pr-12" + /> + + Press Enter to send, Shift+Enter for new line + + + +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts new file mode 100644 index 0000000000..08cf565daa --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts @@ -0,0 +1,60 @@ +import { KeyboardEvent, useCallback, useEffect, useState } from "react"; + +interface UseChatInputArgs { + onSend: (message: string) => void; + disabled?: boolean; + maxRows?: number; + inputId?: string; +} + +export function useChatInput({ + onSend, + disabled = false, + maxRows = 5, + inputId = "chat-input", +}: UseChatInputArgs) { + const [value, setValue] = useState(""); + + useEffect(() => { + const textarea = document.getElementById(inputId) as HTMLTextAreaElement; + if (!textarea) return; + textarea.style.height = "auto"; + const lineHeight = parseInt( + window.getComputedStyle(textarea).lineHeight, + 10, + ); + const maxHeight = lineHeight * maxRows; + const newHeight = Math.min(textarea.scrollHeight, maxHeight); + textarea.style.height = `${newHeight}px`; + textarea.style.overflowY = + textarea.scrollHeight > maxHeight ? "auto" : "hidden"; + }, [value, maxRows, inputId]); + + const handleSend = useCallback(() => { + if (disabled || !value.trim()) return; + onSend(value.trim()); + setValue(""); + const textarea = document.getElementById(inputId) as HTMLTextAreaElement; + if (textarea) { + textarea.style.height = "auto"; + } + }, [value, onSend, disabled, inputId]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSend(); + } + // Shift+Enter allows default behavior (new line) - no need to handle explicitly + }, + [handleSend], + ); + + return { + value, + setValue, + handleKeyDown, + handleSend, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoadingState/ChatLoadingState.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoadingState/ChatLoadingState.tsx new file mode 100644 index 0000000000..c0cdb33c50 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoadingState/ChatLoadingState.tsx @@ -0,0 +1,19 @@ +import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; +import { cn } from "@/lib/utils"; + +export interface ChatLoadingStateProps { + message?: string; + className?: string; +} + +export function ChatLoadingState({ className }: ChatLoadingStateProps) { + return ( +
+
+ +
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx new file mode 100644 index 0000000000..ef9542e449 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store"; +import Avatar, { + AvatarFallback, + AvatarImage, +} from "@/components/atoms/Avatar/Avatar"; +import { Button } from "@/components/atoms/Button/Button"; +import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { cn } from "@/lib/utils"; +import { + ArrowClockwise, + CheckCircleIcon, + CheckIcon, + CopyIcon, + RobotIcon, +} from "@phosphor-icons/react"; +import { useCallback, useState } from "react"; +import { getToolActionPhrase } from "../../helpers"; +import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget"; +import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup"; +import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; +import { MessageBubble } from "../MessageBubble/MessageBubble"; +import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage"; +import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage"; +import { useChatMessage, type ChatMessageData } from "./useChatMessage"; +export interface ChatMessageProps { + message: ChatMessageData; + className?: string; + onDismissLogin?: () => void; + onDismissCredentials?: () => void; + onSendMessage?: (content: string, isUserMessage?: boolean) => void; + agentOutput?: ChatMessageData; +} + +export function ChatMessage({ + message, + className, + onDismissCredentials, + onSendMessage, + agentOutput, +}: ChatMessageProps) { + const { user } = useSupabase(); + const [copied, setCopied] = useState(false); + const { + isUser, + isToolCall, + isToolResponse, + isLoginNeeded, + isCredentialsNeeded, + } = useChatMessage(message); + + const { data: profile } = useGetV2GetUserProfile({ + query: { + select: (res) => (res.status === 200 ? res.data : null), + enabled: isUser && !!user, + queryKey: ["/api/store/profile", user?.id], + }, + }); + + const handleAllCredentialsComplete = useCallback( + function handleAllCredentialsComplete() { + // Send a user message that explicitly asks to retry the setup + // This ensures the LLM calls get_required_setup_info again and proceeds with execution + if (onSendMessage) { + onSendMessage( + "I've configured the required credentials. Please check if everything is ready and proceed with setting up the agent.", + ); + } + // Optionally dismiss the credentials prompt + if (onDismissCredentials) { + onDismissCredentials(); + } + }, + [onSendMessage, onDismissCredentials], + ); + + function handleCancelCredentials() { + // Dismiss the credentials prompt + if (onDismissCredentials) { + onDismissCredentials(); + } + } + + const handleCopy = useCallback(async () => { + if (message.type !== "message") return; + + try { + await navigator.clipboard.writeText(message.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + } + }, [message]); + + const handleTryAgain = useCallback(() => { + if (message.type !== "message" || !onSendMessage) return; + onSendMessage(message.content, message.role === "user"); + }, [message, onSendMessage]); + + // Render credentials needed messages + if (isCredentialsNeeded && message.type === "credentials_needed") { + return ( + + ); + } + + // Render login needed messages + if (isLoginNeeded && message.type === "login_needed") { + // If user is already logged in, show success message instead of auth prompt + if (user) { + return ( +
+
+
+
+
+ +
+
+

+ Successfully Authenticated +

+

+ You're now signed in and ready to continue +

+
+
+
+
+
+ ); + } + + // Show auth prompt if not logged in + return ( +
+ +
+ ); + } + + // Render tool call messages + if (isToolCall && message.type === "tool_call") { + return ( +
+ +
+ ); + } + + // Render tool response messages (but skip agent_output if it's being rendered inside assistant message) + if ( + (isToolResponse && message.type === "tool_response") || + message.type === "no_results" || + message.type === "agent_carousel" || + message.type === "execution_started" + ) { + // Check if this is an agent_output that should be rendered inside assistant message + if (message.type === "tool_response" && message.result) { + let parsedResult: Record | null = null; + try { + parsedResult = + typeof message.result === "string" + ? JSON.parse(message.result) + : (message.result as Record); + } catch { + parsedResult = null; + } + if (parsedResult?.type === "agent_output") { + // Skip rendering - this will be rendered inside the assistant message + return null; + } + } + + return ( +
+ +
+ ); + } + + // Render regular chat messages + if (message.type === "message") { + return ( +
+
+ {!isUser && ( +
+
+ +
+
+ )} + +
+ + + {agentOutput && + agentOutput.type === "tool_response" && + !isUser && ( +
+ +
+ )} +
+
+ {isUser && onSendMessage && ( + + )} + +
+
+ + {isUser && ( +
+ + + + {profile?.username?.charAt(0)?.toUpperCase() || "U"} + + +
+ )} +
+
+ ); + } + + // Fallback for unknown message types + return null; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts new file mode 100644 index 0000000000..9a597d4b26 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts @@ -0,0 +1,113 @@ +import type { ToolArguments, ToolResult } from "@/types/chat"; +import { formatDistanceToNow } from "date-fns"; + +export type ChatMessageData = + | { + type: "message"; + role: "user" | "assistant" | "system"; + content: string; + timestamp?: string | Date; + } + | { + type: "tool_call"; + toolId: string; + toolName: string; + arguments?: ToolArguments; + timestamp?: string | Date; + } + | { + type: "tool_response"; + toolId: string; + toolName: string; + result: ToolResult; + success?: boolean; + timestamp?: string | Date; + } + | { + type: "login_needed"; + toolName: string; + message: string; + sessionId: string; + agentInfo?: { + graph_id: string; + name: string; + trigger_type: string; + }; + timestamp?: string | Date; + } + | { + type: "credentials_needed"; + toolName: string; + credentials: Array<{ + provider: string; + providerName: string; + credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped"; + title: string; + scopes?: string[]; + }>; + message: string; + agentName?: string; + timestamp?: string | Date; + } + | { + type: "no_results"; + toolName: string; + message: string; + suggestions?: string[]; + sessionId?: string; + timestamp?: string | Date; + } + | { + type: "agent_carousel"; + toolName: string; + agents: Array<{ + id: string; + name: string; + description: string; + version?: number; + image_url?: string; + }>; + totalCount?: number; + timestamp?: string | Date; + } + | { + type: "execution_started"; + toolName: string; + executionId: string; + agentName?: string; + message?: string; + libraryAgentLink?: string; + timestamp?: string | Date; + } + | { + type: "inputs_needed"; + toolName: string; + agentName?: string; + agentId?: string; + graphVersion?: number; + inputSchema: Record; + credentialsSchema?: Record; + message: string; + timestamp?: string | Date; + }; + +export function useChatMessage(message: ChatMessageData) { + const formattedTimestamp = message.timestamp + ? formatDistanceToNow(new Date(message.timestamp), { addSuffix: true }) + : "Just now"; + + return { + formattedTimestamp, + isUser: message.type === "message" && message.role === "user", + isAssistant: message.type === "message" && message.role === "assistant", + isSystem: message.type === "message" && message.role === "system", + isToolCall: message.type === "tool_call", + isToolResponse: message.type === "tool_response", + isLoginNeeded: message.type === "login_needed", + isCredentialsNeeded: message.type === "credentials_needed", + isNoResults: message.type === "no_results", + isAgentCarousel: message.type === "agent_carousel", + isExecutionStarted: message.type === "execution_started", + isInputsNeeded: message.type === "inputs_needed", + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx new file mode 100644 index 0000000000..1ac3b440e0 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx @@ -0,0 +1,90 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; +import { ArrowSquareOut, CheckCircle, Play } from "@phosphor-icons/react"; + +export interface ExecutionStartedMessageProps { + executionId: string; + agentName?: string; + message?: string; + onViewExecution?: () => void; + className?: string; +} + +export function ExecutionStartedMessage({ + executionId, + agentName, + message = "Agent execution started successfully", + onViewExecution, + className, +}: ExecutionStartedMessageProps) { + return ( +
+ {/* Icon & Header */} +
+
+ +
+
+ + Execution Started + + + {message} + +
+
+ + {/* Details */} +
+
+ {agentName && ( +
+ + Agent: + + + {agentName} + +
+ )} +
+ + Execution ID: + + + {executionId.slice(0, 16)}... + +
+
+
+ + {/* Action Buttons */} + {onViewExecution && ( +
+ +
+ )} + +
+ + + Your agent is now running. You can monitor its progress in the monitor + page. + +
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx new file mode 100644 index 0000000000..51a0794090 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface MarkdownContentProps { + content: string; + className?: string; +} + +interface CodeProps extends React.HTMLAttributes { + children?: React.ReactNode; + className?: string; +} + +interface ListProps extends React.HTMLAttributes { + children?: React.ReactNode; + className?: string; +} + +interface ListItemProps extends React.HTMLAttributes { + children?: React.ReactNode; + className?: string; +} + +interface InputProps extends React.InputHTMLAttributes { + type?: string; +} + +export function MarkdownContent({ content, className }: MarkdownContentProps) { + return ( +
+ { + const isInline = !className?.includes("language-"); + if (isInline) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children, ...props }) => ( +
+              {children}
+            
+ ), + a: ({ children, href, ...props }) => ( + + {children} + + ), + strong: ({ children, ...props }) => ( + + {children} + + ), + em: ({ children, ...props }) => ( + + {children} + + ), + del: ({ children, ...props }) => ( + + {children} + + ), + ul: ({ children, ...props }: ListProps) => ( +
    + {children} +
+ ), + ol: ({ children, ...props }) => ( +
    + {children} +
+ ), + li: ({ children, ...props }: ListItemProps) => ( +
  • + {children} +
  • + ), + input: ({ ...props }: InputProps) => { + if (props.type === "checkbox") { + return ( + + ); + } + return ; + }, + blockquote: ({ children, ...props }) => ( +
    + {children} +
    + ), + h1: ({ children, ...props }) => ( +

    + {children} +

    + ), + h2: ({ children, ...props }) => ( +

    + {children} +

    + ), + h3: ({ children, ...props }) => ( +

    + {children} +

    + ), + h4: ({ children, ...props }) => ( +

    + {children} +

    + ), + h5: ({ children, ...props }) => ( +
    + {children} +
    + ), + h6: ({ children, ...props }) => ( +
    + {children} +
    + ), + p: ({ children, ...props }) => ( +

    + {children} +

    + ), + hr: ({ ...props }) => ( +
    + ), + table: ({ children, ...props }) => ( +
    + + {children} +
    +
    + ), + th: ({ children, ...props }) => ( + + {children} + + ), + td: ({ children, ...props }) => ( + + {children} + + ), + }} + > + {content} +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageBubble/MessageBubble.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageBubble/MessageBubble.tsx new file mode 100644 index 0000000000..98b50f3d28 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageBubble/MessageBubble.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +export interface MessageBubbleProps { + children: ReactNode; + variant: "user" | "assistant"; + className?: string; +} + +export function MessageBubble({ + children, + variant, + className, +}: MessageBubbleProps) { + const userTheme = { + bg: "bg-slate-900", + border: "border-slate-800", + gradient: "from-slate-900/30 via-slate-800/20 to-transparent", + text: "text-slate-50", + }; + + const assistantTheme = { + bg: "bg-slate-50/20", + border: "border-slate-100", + gradient: "from-slate-200/20 via-slate-300/10 to-transparent", + text: "text-slate-900", + }; + + const theme = variant === "user" ? userTheme : assistantTheme; + + return ( +
    + {/* Gradient flare background */} +
    +
    + {children} +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx new file mode 100644 index 0000000000..b83e9f9c5e --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx @@ -0,0 +1,119 @@ +import { cn } from "@/lib/utils"; +import { ChatMessage } from "../ChatMessage/ChatMessage"; +import type { ChatMessageData } from "../ChatMessage/useChatMessage"; +import { StreamingMessage } from "../StreamingMessage/StreamingMessage"; +import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage"; +import { useMessageList } from "./useMessageList"; + +export interface MessageListProps { + messages: ChatMessageData[]; + streamingChunks?: string[]; + isStreaming?: boolean; + className?: string; + onStreamComplete?: () => void; + onSendMessage?: (content: string) => void; +} + +export function MessageList({ + messages, + streamingChunks = [], + isStreaming = false, + className, + onStreamComplete, + onSendMessage, +}: MessageListProps) { + const { messagesEndRef, messagesContainerRef } = useMessageList({ + messageCount: messages.length, + isStreaming, + }); + + return ( +
    +
    + {/* Render all persisted messages */} + {messages.map((message, index) => { + // Check if current message is an agent_output tool_response + // and if previous message is an assistant message + let agentOutput: ChatMessageData | undefined; + + if (message.type === "tool_response" && message.result) { + let parsedResult: Record | null = null; + try { + parsedResult = + typeof message.result === "string" + ? JSON.parse(message.result) + : (message.result as Record); + } catch { + parsedResult = null; + } + if (parsedResult?.type === "agent_output") { + const prevMessage = messages[index - 1]; + if ( + prevMessage && + prevMessage.type === "message" && + prevMessage.role === "assistant" + ) { + // This agent output will be rendered inside the previous assistant message + // Skip rendering this message separately + return null; + } + } + } + + // Check if next message is an agent_output tool_response to include in current assistant message + if (message.type === "message" && message.role === "assistant") { + const nextMessage = messages[index + 1]; + if ( + nextMessage && + nextMessage.type === "tool_response" && + nextMessage.result + ) { + let parsedResult: Record | null = null; + try { + parsedResult = + typeof nextMessage.result === "string" + ? JSON.parse(nextMessage.result) + : (nextMessage.result as Record); + } catch { + parsedResult = null; + } + if (parsedResult?.type === "agent_output") { + agentOutput = nextMessage; + } + } + } + + return ( + + ); + })} + + {/* Render thinking message when streaming but no chunks yet */} + {isStreaming && streamingChunks.length === 0 && } + + {/* Render streaming message if active */} + {isStreaming && streamingChunks.length > 0 && ( + + )} + + {/* Invisible div to scroll to */} +
    +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/useMessageList.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/useMessageList.ts new file mode 100644 index 0000000000..3dcc75df3c --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/useMessageList.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, useCallback } from "react"; + +interface UseMessageListArgs { + messageCount: number; + isStreaming: boolean; +} + +export function useMessageList({ + messageCount, + isStreaming, +}: UseMessageListArgs) { + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messageCount, isStreaming, scrollToBottom]); + + return { + messagesEndRef, + messagesContainerRef, + scrollToBottom, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/NoResultsMessage/NoResultsMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/NoResultsMessage/NoResultsMessage.tsx new file mode 100644 index 0000000000..b6adc8b93c --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/NoResultsMessage/NoResultsMessage.tsx @@ -0,0 +1,64 @@ +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; +import { MagnifyingGlass, X } from "@phosphor-icons/react"; + +export interface NoResultsMessageProps { + message: string; + suggestions?: string[]; + className?: string; +} + +export function NoResultsMessage({ + message, + suggestions = [], + className, +}: NoResultsMessageProps) { + return ( +
    + {/* Icon */} +
    +
    + +
    +
    + +
    +
    + + {/* Content */} +
    + + No Results Found + + + {message} + +
    + + {/* Suggestions */} + {suggestions.length > 0 && ( +
    + + Try these suggestions: + +
      + {suggestions.map((suggestion, index) => ( +
    • + + {suggestion} +
    • + ))} +
    +
    + )} +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx new file mode 100644 index 0000000000..464bc8f2dc --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx @@ -0,0 +1,92 @@ +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; + +export interface QuickActionsWelcomeProps { + title: string; + description: string; + actions: string[]; + onActionClick: (action: string) => void; + disabled?: boolean; + className?: string; +} + +export function QuickActionsWelcome({ + title, + description, + actions, + onActionClick, + disabled = false, + className, +}: QuickActionsWelcomeProps) { + return ( +
    +
    +
    + + {title} + + + {description} + +
    +
    + {actions.map((action) => { + // Use slate theme for all cards + const theme = { + bg: "bg-slate-50/10", + border: "border-slate-100", + hoverBg: "hover:bg-slate-50/20", + hoverBorder: "hover:border-slate-200", + gradient: "from-slate-200/20 via-slate-300/10 to-transparent", + text: "text-slate-900", + hoverText: "group-hover:text-slate-900", + }; + + return ( + + ); + })} +
    +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/SessionsDrawer/SessionsDrawer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/SessionsDrawer/SessionsDrawer.tsx new file mode 100644 index 0000000000..74aa709a46 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/SessionsDrawer/SessionsDrawer.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; +import { Text } from "@/components/atoms/Text/Text"; +import { scrollbarStyles } from "@/components/styles/scrollbars"; +import { cn } from "@/lib/utils"; +import { X } from "@phosphor-icons/react"; +import { formatDistanceToNow } from "date-fns"; +import { Drawer } from "vaul"; + +interface SessionsDrawerProps { + isOpen: boolean; + onClose: () => void; + onSelectSession: (sessionId: string) => void; + currentSessionId?: string | null; +} + +export function SessionsDrawer({ + isOpen, + onClose, + onSelectSession, + currentSessionId, +}: SessionsDrawerProps) { + const { data, isLoading } = useGetV2ListSessions( + { limit: 100 }, + { + query: { + enabled: isOpen, + }, + }, + ); + + const sessions = + data?.status === 200 + ? data.data.sessions.filter((session) => { + // Filter out sessions without messages (sessions that were never updated) + // If updated_at equals created_at, the session was created but never had messages + return session.updated_at !== session.created_at; + }) + : []; + + function handleSelectSession(sessionId: string) { + onSelectSession(sessionId); + onClose(); + } + + return ( + !open && onClose()} + direction="right" + > + + + +
    +
    + + Chat Sessions + + +
    +
    + +
    + {isLoading ? ( +
    + + Loading sessions... + +
    + ) : sessions.length === 0 ? ( +
    + + No sessions found + +
    + ) : ( +
    + {sessions.map((session) => { + const isActive = session.id === currentSessionId; + const updatedAt = session.updated_at + ? formatDistanceToNow(new Date(session.updated_at), { + addSuffix: true, + }) + : ""; + + return ( + + ); + })} +
    + )} +
    +
    +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/StreamingMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/StreamingMessage.tsx new file mode 100644 index 0000000000..2a6e3d5822 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/StreamingMessage.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; +import { RobotIcon } from "@phosphor-icons/react"; +import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; +import { MessageBubble } from "../MessageBubble/MessageBubble"; +import { useStreamingMessage } from "./useStreamingMessage"; + +export interface StreamingMessageProps { + chunks: string[]; + className?: string; + onComplete?: () => void; +} + +export function StreamingMessage({ + chunks, + className, + onComplete, +}: StreamingMessageProps) { + const { displayText } = useStreamingMessage({ chunks, onComplete }); + + return ( +
    +
    +
    +
    + +
    +
    + +
    + + + +
    +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/useStreamingMessage.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/useStreamingMessage.ts new file mode 100644 index 0000000000..5203762151 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/StreamingMessage/useStreamingMessage.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; + +interface UseStreamingMessageArgs { + chunks: string[]; + onComplete?: () => void; +} + +export function useStreamingMessage({ + chunks, + onComplete, +}: UseStreamingMessageArgs) { + const [isComplete, _setIsComplete] = useState(false); + const displayText = chunks.join(""); + + useEffect(() => { + if (isComplete && onComplete) { + onComplete(); + } + }, [isComplete, onComplete]); + + return { + displayText, + isComplete, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx new file mode 100644 index 0000000000..d8adddf416 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; +import { RobotIcon } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { MessageBubble } from "../MessageBubble/MessageBubble"; + +export interface ThinkingMessageProps { + className?: string; +} + +export function ThinkingMessage({ className }: ThinkingMessageProps) { + const [showSlowLoader, setShowSlowLoader] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + if (timerRef.current === null) { + timerRef.current = setTimeout(() => { + setShowSlowLoader(true); + }, 8000); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, []); + + return ( +
    +
    +
    +
    + +
    +
    + +
    + +
    + {showSlowLoader ? ( +
    +
    +

    + Taking a bit longer to think, wait a moment please +

    +
    + ) : ( + + Thinking... + + )} +
    + +
    +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolCallMessage/ToolCallMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolCallMessage/ToolCallMessage.tsx new file mode 100644 index 0000000000..97590ae0cf --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolCallMessage/ToolCallMessage.tsx @@ -0,0 +1,24 @@ +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; +import { WrenchIcon } from "@phosphor-icons/react"; +import { getToolActionPhrase } from "../../helpers"; + +export interface ToolCallMessageProps { + toolName: string; + className?: string; +} + +export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) { + return ( +
    + + + {getToolActionPhrase(toolName)}... + +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx new file mode 100644 index 0000000000..b84204c3ff --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx @@ -0,0 +1,260 @@ +import { Text } from "@/components/atoms/Text/Text"; +import "@/components/contextual/OutputRenderers"; +import { + globalRegistry, + OutputItem, +} from "@/components/contextual/OutputRenderers"; +import { cn } from "@/lib/utils"; +import type { ToolResult } from "@/types/chat"; +import { WrenchIcon } from "@phosphor-icons/react"; +import { getToolActionPhrase } from "../../helpers"; + +export interface ToolResponseMessageProps { + toolName: string; + result?: ToolResult; + success?: boolean; + className?: string; +} + +export function ToolResponseMessage({ + toolName, + result, + success: _success = true, + className, +}: ToolResponseMessageProps) { + if (!result) { + return ( +
    + + + {getToolActionPhrase(toolName)}... + +
    + ); + } + + let parsedResult: Record | null = null; + try { + parsedResult = + typeof result === "string" + ? JSON.parse(result) + : (result as Record); + } catch { + parsedResult = null; + } + + if (parsedResult && typeof parsedResult === "object") { + const responseType = parsedResult.type as string | undefined; + + if (responseType === "agent_output") { + const execution = parsedResult.execution as + | { + outputs?: Record; + } + | null + | undefined; + const outputs = execution?.outputs || {}; + const message = parsedResult.message as string | undefined; + + return ( +
    +
    + + + {getToolActionPhrase(toolName)} + +
    + {message && ( +
    + + {message} + +
    + )} + {Object.keys(outputs).length > 0 && ( +
    + {Object.entries(outputs).map(([outputName, values]) => + values.map((value, index) => { + const renderer = globalRegistry.getRenderer(value); + if (renderer) { + return ( + + ); + } + return ( +
    + + {outputName} + +
    +                        {JSON.stringify(value, null, 2)}
    +                      
    +
    + ); + }), + )} +
    + )} +
    + ); + } + + if (responseType === "block_output" && parsedResult.outputs) { + const outputs = parsedResult.outputs as Record; + + return ( +
    +
    + + + {getToolActionPhrase(toolName)} + +
    +
    + {Object.entries(outputs).map(([outputName, values]) => + values.map((value, index) => { + const renderer = globalRegistry.getRenderer(value); + if (renderer) { + return ( + + ); + } + return ( +
    + + {outputName} + +
    +                      {JSON.stringify(value, null, 2)}
    +                    
    +
    + ); + }), + )} +
    +
    + ); + } + + // Handle other response types with a message field (e.g., understanding_updated) + if (parsedResult.message && typeof parsedResult.message === "string") { + // Format tool name from snake_case to Title Case + const formattedToolName = toolName + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + // Clean up message - remove incomplete user_name references + let cleanedMessage = parsedResult.message; + // Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder + cleanedMessage = cleanedMessage.replace( + /Updated understanding with:\s*user_name\.?\s*/gi, + "", + ); + // Remove standalone user_name references + cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, ""); + cleanedMessage = cleanedMessage.trim(); + + // Only show message if it has content after cleaning + if (!cleanedMessage) { + return ( +
    + + + {formattedToolName} + +
    + ); + } + + return ( +
    +
    + + + {formattedToolName} + +
    +
    + + {cleanedMessage} + +
    +
    + ); + } + } + + const renderer = globalRegistry.getRenderer(result); + if (renderer) { + return ( +
    +
    + + + {getToolActionPhrase(toolName)} + +
    + +
    + ); + } + + return ( +
    + + + {getToolActionPhrase(toolName)}... + +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/helpers.ts new file mode 100644 index 0000000000..5a1e5eb93f --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/helpers.ts @@ -0,0 +1,73 @@ +/** + * Maps internal tool names to user-friendly display names with emojis. + * @deprecated Use getToolActionPhrase or getToolCompletionPhrase for status messages + * + * @param toolName - The internal tool name from the backend + * @returns A user-friendly display name with an emoji prefix + */ +export function getToolDisplayName(toolName: string): string { + const toolDisplayNames: Record = { + find_agent: "🔍 Search Marketplace", + get_agent_details: "📋 Get Agent Details", + check_credentials: "🔑 Check Credentials", + setup_agent: "⚙️ Setup Agent", + run_agent: "▶️ Run Agent", + get_required_setup_info: "📝 Get Setup Requirements", + }; + return toolDisplayNames[toolName] || toolName; +} + +/** + * Maps internal tool names to human-friendly action phrases (present continuous). + * Used for tool call messages to indicate what action is currently happening. + * + * @param toolName - The internal tool name from the backend + * @returns A human-friendly action phrase in present continuous tense + */ +export function getToolActionPhrase(toolName: string): string { + const toolActionPhrases: Record = { + find_agent: "Looking for agents in the marketplace", + agent_carousel: "Looking for agents in the marketplace", + get_agent_details: "Learning about the agent", + check_credentials: "Checking your credentials", + setup_agent: "Setting up the agent", + execution_started: "Running the agent", + run_agent: "Running the agent", + get_required_setup_info: "Getting setup requirements", + schedule_agent: "Scheduling the agent to run", + }; + + // Return mapped phrase or generate human-friendly fallback + return toolActionPhrases[toolName] || toolName; +} + +/** + * Maps internal tool names to human-friendly completion phrases (past tense). + * Used for tool response messages to indicate what action was completed. + * + * @param toolName - The internal tool name from the backend + * @returns A human-friendly completion phrase in past tense + */ +export function getToolCompletionPhrase(toolName: string): string { + const toolCompletionPhrases: Record = { + find_agent: "Finished searching the marketplace", + get_agent_details: "Got agent details", + check_credentials: "Checked credentials", + setup_agent: "Agent setup complete", + run_agent: "Agent execution started", + get_required_setup_info: "Got setup requirements", + }; + + // Return mapped phrase or generate human-friendly fallback + return ( + toolCompletionPhrases[toolName] || + `Finished ${toolName.replace(/_/g, " ").replace("...", "")}` + ); +} + +/** Validate UUID v4 format */ +export function isValidUUID(value: string): boolean { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(value); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts new file mode 100644 index 0000000000..f8aedd23b7 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts @@ -0,0 +1,119 @@ +"use client"; + +import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useChatSession } from "./useChatSession"; +import { useChatStream } from "./useChatStream"; + +export function useChat() { + const hasCreatedSessionRef = useRef(false); + const hasClaimedSessionRef = useRef(false); + const { user } = useSupabase(); + const { sendMessage: sendStreamMessage } = useChatStream(); + + const { + session, + sessionId: sessionIdFromHook, + messages, + isLoading, + isCreating, + error, + createSession, + refreshSession, + claimSession, + clearSession: clearSessionBase, + loadSession, + } = useChatSession({ + urlSessionId: null, + autoCreate: false, + }); + + useEffect( + function autoCreateSession() { + if (!hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) { + hasCreatedSessionRef.current = true; + createSession().catch((_err) => { + hasCreatedSessionRef.current = false; + }); + } + }, + [isCreating, sessionIdFromHook, createSession], + ); + + useEffect( + function autoClaimSession() { + if ( + session && + !session.user_id && + user && + !hasClaimedSessionRef.current && + !isLoading && + sessionIdFromHook + ) { + hasClaimedSessionRef.current = true; + claimSession(sessionIdFromHook) + .then(() => { + sendStreamMessage( + sessionIdFromHook, + "User has successfully logged in.", + () => {}, + false, + ).catch(() => {}); + }) + .catch(() => { + hasClaimedSessionRef.current = false; + }); + } + }, + [ + session, + user, + isLoading, + sessionIdFromHook, + claimSession, + sendStreamMessage, + ], + ); + + useEffect(function monitorNetworkStatus() { + function handleOnline() { + toast.success("Connection restored", { + description: "You're back online", + }); + } + + function handleOffline() { + toast.error("You're offline", { + description: "Check your internet connection", + }); + } + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + function clearSession() { + clearSessionBase(); + hasCreatedSessionRef.current = false; + hasClaimedSessionRef.current = false; + } + + return { + session, + messages, + isLoading, + isCreating, + error, + createSession, + refreshSession, + clearSession, + loadSession, + sessionId: sessionIdFromHook, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts new file mode 100644 index 0000000000..62e1a5a569 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts @@ -0,0 +1,17 @@ +"use client"; + +import { create } from "zustand"; + +interface ChatDrawerState { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +export const useChatDrawer = create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + toggle: () => set((state) => ({ isOpen: !state.isOpen })), +})); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts new file mode 100644 index 0000000000..4739c551e9 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts @@ -0,0 +1,271 @@ +import { + getGetV2GetSessionQueryKey, + getGetV2GetSessionQueryOptions, + postV2CreateSession, + useGetV2GetSession, + usePatchV2SessionAssignUser, + usePostV2CreateSession, +} from "@/app/api/__generated__/endpoints/chat/chat"; +import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; +import { okData } from "@/app/api/helpers"; +import { Key, storage } from "@/services/storage/local-storage"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { isValidUUID } from "./helpers"; + +interface UseChatSessionArgs { + urlSessionId?: string | null; + autoCreate?: boolean; +} + +export function useChatSession({ + urlSessionId, + autoCreate = false, +}: UseChatSessionArgs = {}) { + const queryClient = useQueryClient(); + const [sessionId, setSessionId] = useState(null); + const [error, setError] = useState(null); + const justCreatedSessionIdRef = useRef(null); + + useEffect(() => { + if (urlSessionId) { + if (!isValidUUID(urlSessionId)) { + console.error("Invalid session ID format:", urlSessionId); + toast.error("Invalid session ID", { + description: + "The session ID in the URL is not valid. Starting a new session...", + }); + setSessionId(null); + storage.clean(Key.CHAT_SESSION_ID); + return; + } + setSessionId(urlSessionId); + storage.set(Key.CHAT_SESSION_ID, urlSessionId); + } else { + const storedSessionId = storage.get(Key.CHAT_SESSION_ID); + if (storedSessionId) { + if (!isValidUUID(storedSessionId)) { + console.error("Invalid stored session ID:", storedSessionId); + storage.clean(Key.CHAT_SESSION_ID); + setSessionId(null); + } else { + setSessionId(storedSessionId); + } + } else if (autoCreate) { + setSessionId(null); + } + } + }, [urlSessionId, autoCreate]); + + const { + mutateAsync: createSessionMutation, + isPending: isCreating, + error: createError, + } = usePostV2CreateSession(); + + const { + data: sessionData, + isLoading: isLoadingSession, + error: loadError, + refetch, + } = useGetV2GetSession(sessionId || "", { + query: { + enabled: !!sessionId, + select: okData, + staleTime: Infinity, // Never mark as stale + refetchOnMount: false, // Don't refetch on component mount + refetchOnWindowFocus: false, // Don't refetch when window regains focus + refetchOnReconnect: false, // Don't refetch when network reconnects + retry: 1, + }, + }); + + const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser(); + + const session = useMemo(() => { + if (sessionData) return sessionData; + + if (sessionId && justCreatedSessionIdRef.current === sessionId) { + return { + id: sessionId, + user_id: null, + messages: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as SessionDetailResponse; + } + return null; + }, [sessionData, sessionId]); + + const messages = session?.messages || []; + const isLoading = isCreating || isLoadingSession; + + useEffect(() => { + if (createError) { + setError( + createError instanceof Error + ? createError + : new Error("Failed to create session"), + ); + } else if (loadError) { + setError( + loadError instanceof Error + ? loadError + : new Error("Failed to load session"), + ); + } else { + setError(null); + } + }, [createError, loadError]); + + const createSession = useCallback( + async function createSession() { + try { + setError(null); + const response = await postV2CreateSession({ + body: JSON.stringify({}), + }); + if (response.status !== 200) { + throw new Error("Failed to create session"); + } + const newSessionId = response.data.id; + setSessionId(newSessionId); + storage.set(Key.CHAT_SESSION_ID, newSessionId); + justCreatedSessionIdRef.current = newSessionId; + setTimeout(() => { + if (justCreatedSessionIdRef.current === newSessionId) { + justCreatedSessionIdRef.current = null; + } + }, 10000); + return newSessionId; + } catch (err) { + const error = + err instanceof Error ? err : new Error("Failed to create session"); + setError(error); + toast.error("Failed to create chat session", { + description: error.message, + }); + throw error; + } + }, + [createSessionMutation], + ); + + const loadSession = useCallback( + async function loadSession(id: string) { + try { + setError(null); + // Invalidate the query cache for this session to force a fresh fetch + await queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(id), + }); + // Set sessionId after invalidation to ensure the hook refetches + setSessionId(id); + storage.set(Key.CHAT_SESSION_ID, id); + // Force fetch with fresh data (bypass cache) + const queryOptions = getGetV2GetSessionQueryOptions(id, { + query: { + staleTime: 0, // Force fresh fetch + retry: 1, + }, + }); + const result = await queryClient.fetchQuery(queryOptions); + if (!result || ("status" in result && result.status !== 200)) { + console.warn("Session not found on server, clearing local state"); + storage.clean(Key.CHAT_SESSION_ID); + setSessionId(null); + throw new Error("Session not found"); + } + } catch (err) { + const error = + err instanceof Error ? err : new Error("Failed to load session"); + setError(error); + throw error; + } + }, + [queryClient], + ); + + const refreshSession = useCallback( + async function refreshSession() { + if (!sessionId) { + console.log("[refreshSession] Skipping - no session ID"); + return; + } + try { + setError(null); + await refetch(); + } catch (err) { + const error = + err instanceof Error ? err : new Error("Failed to refresh session"); + setError(error); + throw error; + } + }, + [sessionId, refetch], + ); + + const claimSession = useCallback( + async function claimSession(id: string) { + try { + setError(null); + await claimSessionMutation({ sessionId: id }); + if (justCreatedSessionIdRef.current === id) { + justCreatedSessionIdRef.current = null; + } + await queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(id), + }); + await refetch(); + toast.success("Session claimed successfully", { + description: "Your chat history has been saved to your account", + }); + } catch (err: unknown) { + const error = + err instanceof Error ? err : new Error("Failed to claim session"); + const is404 = + (typeof err === "object" && + err !== null && + "status" in err && + err.status === 404) || + (typeof err === "object" && + err !== null && + "response" in err && + typeof err.response === "object" && + err.response !== null && + "status" in err.response && + err.response.status === 404); + if (!is404) { + setError(error); + toast.error("Failed to claim session", { + description: error.message || "Unable to claim session", + }); + } + throw error; + } + }, + [claimSessionMutation, queryClient, refetch], + ); + + const clearSession = useCallback(function clearSession() { + setSessionId(null); + setError(null); + storage.clean(Key.CHAT_SESSION_ID); + justCreatedSessionIdRef.current = null; + }, []); + + return { + session, + sessionId, + messages, + isLoading, + isCreating, + error, + createSession, + loadSession, + refreshSession, + claimSession, + clearSession, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts new file mode 100644 index 0000000000..5f74ab1938 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts @@ -0,0 +1,248 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { toast } from "sonner"; +import type { ToolArguments, ToolResult } from "@/types/chat"; + +const MAX_RETRIES = 3; +const INITIAL_RETRY_DELAY = 1000; + +export interface StreamChunk { + type: + | "text_chunk" + | "text_ended" + | "tool_call" + | "tool_call_start" + | "tool_response" + | "login_needed" + | "need_login" + | "credentials_needed" + | "error" + | "usage" + | "stream_end"; + timestamp?: string; + content?: string; + message?: string; + tool_id?: string; + tool_name?: string; + arguments?: ToolArguments; + result?: ToolResult; + success?: boolean; + idx?: number; + session_id?: string; + agent_info?: { + graph_id: string; + name: string; + trigger_type: string; + }; + provider?: string; + provider_name?: string; + credential_type?: string; + scopes?: string[]; + title?: string; + [key: string]: unknown; +} + +export function useChatStream() { + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(null); + const retryCountRef = useRef(0); + const retryTimeoutRef = useRef(null); + const abortControllerRef = useRef(null); + + const stopStreaming = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + retryCountRef.current = 0; + setIsStreaming(false); + }, []); + + useEffect(() => { + return () => { + stopStreaming(); + }; + }, [stopStreaming]); + + const sendMessage = useCallback( + async ( + sessionId: string, + message: string, + onChunk: (chunk: StreamChunk) => void, + isUserMessage: boolean = true, + context?: { url: string; content: string }, + ) => { + stopStreaming(); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + if (abortController.signal.aborted) { + return Promise.reject(new Error("Request aborted")); + } + + retryCountRef.current = 0; + setIsStreaming(true); + setError(null); + + try { + const url = `/api/chat/sessions/${sessionId}/stream`; + const body = JSON.stringify({ + message, + is_user_message: isUserMessage, + context: context || null, + }); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body, + signal: abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `HTTP ${response.status}`); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + return new Promise((resolve, reject) => { + const cleanup = () => { + reader.cancel().catch(() => { + // Ignore cancel errors + }); + }; + + const readStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + cleanup(); + stopStreaming(); + resolve(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") { + cleanup(); + stopStreaming(); + resolve(); + return; + } + + try { + const chunk = JSON.parse(data) as StreamChunk; + + if (retryCountRef.current > 0) { + retryCountRef.current = 0; + } + + // Call the chunk handler + onChunk(chunk); + + // Handle stream lifecycle + if (chunk.type === "stream_end") { + cleanup(); + stopStreaming(); + resolve(); + return; + } else if (chunk.type === "error") { + cleanup(); + reject( + new Error( + chunk.message || chunk.content || "Stream error", + ), + ); + return; + } + } catch (err) { + // Skip invalid JSON lines + console.warn("Failed to parse SSE chunk:", err, data); + } + } + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + cleanup(); + return; + } + + const streamError = + err instanceof Error ? err : new Error("Failed to read stream"); + + if (retryCountRef.current < MAX_RETRIES) { + retryCountRef.current += 1; + const retryDelay = + INITIAL_RETRY_DELAY * Math.pow(2, retryCountRef.current - 1); + + toast.info("Connection interrupted", { + description: `Retrying in ${retryDelay / 1000} seconds...`, + }); + + retryTimeoutRef.current = setTimeout(() => { + sendMessage( + sessionId, + message, + onChunk, + isUserMessage, + context, + ).catch((_err) => { + // Retry failed + }); + }, retryDelay); + } else { + setError(streamError); + toast.error("Connection Failed", { + description: + "Unable to connect to chat service. Please try again.", + }); + cleanup(); + stopStreaming(); + reject(streamError); + } + } + }; + + readStream(); + }); + } catch (err) { + const streamError = + err instanceof Error ? err : new Error("Failed to start stream"); + setError(streamError); + setIsStreaming(false); + throw streamError; + } + }, + [stopStreaming], + ); + + return { + isStreaming, + error, + sendMessage, + stopStreaming, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/usePageContext.ts b/autogpt_platform/frontend/src/components/contextual/Chat/usePageContext.ts new file mode 100644 index 0000000000..39f36ac941 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/usePageContext.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; + +export interface PageContext { + url: string; + content: string; +} + +/** + * Hook to capture the current page context (URL + full page content) + */ +export function usePageContext() { + const capturePageContext = useCallback((): PageContext => { + if (typeof window === "undefined" || typeof document === "undefined") { + return { url: "", content: "" }; + } + + const url = window.location.href; + + // Capture full page text content + // Remove script and style elements, then get text + const clone = document.cloneNode(true) as Document; + const scripts = clone.querySelectorAll("script, style, noscript"); + scripts.forEach((el) => el.remove()); + + // Get text content from body + const body = clone.body; + const content = body?.textContent || body?.innerText || ""; + + // Clean up whitespace + const cleanedContent = content + .replace(/\s+/g, " ") + .replace(/\n\s*\n/g, "\n") + .trim(); + + return { + url, + content: cleanedContent, + }; + }, []); + + return { capturePageContext }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/CredentialsInputs.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/CredentialsInputs.tsx new file mode 100644 index 0000000000..60d61fab57 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/CredentialsInputs.tsx @@ -0,0 +1,228 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api/types"; +import { cn } from "@/lib/utils"; +import { toDisplayName } from "@/providers/agent-credentials/helper"; +import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal"; +import { CredentialRow } from "./components/CredentialRow/CredentialRow"; +import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect"; +import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal"; +import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal"; +import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal"; +import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal"; +import { getCredentialDisplayName } from "./helpers"; +import { + CredentialsInputState, + useCredentialsInput, +} from "./useCredentialsInput"; + +function isLoaded( + data: CredentialsInputState, +): data is Extract { + return data.isLoading === false; +} + +type Props = { + schema: BlockIOCredentialsSubSchema; + className?: string; + selectedCredentials?: CredentialsMetaInput; + siblingInputs?: Record; + onSelectCredentials: (newValue?: CredentialsMetaInput) => void; + onLoaded?: (loaded: boolean) => void; + readOnly?: boolean; + showTitle?: boolean; +}; + +export function CredentialsInput({ + schema, + className, + selectedCredentials: selectedCredential, + onSelectCredentials: onSelectCredential, + siblingInputs, + onLoaded, + readOnly = false, + showTitle = true, +}: Props) { + const hookData = useCredentialsInput({ + schema, + selectedCredential, + onSelectCredential, + siblingInputs, + onLoaded, + readOnly, + }); + + if (!isLoaded(hookData)) { + return null; + } + + const { + provider, + providerName, + supportsApiKey, + supportsOAuth2, + supportsUserPassword, + supportsHostScoped, + credentialsToShow, + oAuthError, + isAPICredentialsModalOpen, + isUserPasswordCredentialsModalOpen, + isHostScopedCredentialsModalOpen, + isOAuth2FlowInProgress, + oAuthPopupController, + credentialToDelete, + deleteCredentialsMutation, + actionButtonText, + setAPICredentialsModalOpen, + setUserPasswordCredentialsModalOpen, + setHostScopedCredentialsModalOpen, + setCredentialToDelete, + handleActionButtonClick, + handleCredentialSelect, + handleDeleteCredential, + handleDeleteConfirm, + } = hookData; + + const displayName = toDisplayName(provider); + const hasCredentialsToShow = credentialsToShow.length > 0; + + return ( +
    + {showTitle && ( +
    + {displayName} credentials + {schema.description && ( + + )} +
    + )} + + {hasCredentialsToShow ? ( + <> + {credentialsToShow.length > 1 && !readOnly ? ( + + ) : ( +
    + {credentialsToShow.map((credential) => { + return ( + handleCredentialSelect(credential.id)} + onDelete={() => + handleDeleteCredential({ + id: credential.id, + title: getCredentialDisplayName( + credential, + displayName, + ), + }) + } + readOnly={readOnly} + /> + ); + })} +
    + )} + {!readOnly && ( + + )} + + ) : ( + !readOnly && ( + + ) + )} + + {!readOnly && ( + <> + {supportsApiKey ? ( + setAPICredentialsModalOpen(false)} + onCredentialsCreate={(credsMeta) => { + onSelectCredential(credsMeta); + setAPICredentialsModalOpen(false); + }} + siblingInputs={siblingInputs} + /> + ) : null} + {supportsOAuth2 ? ( + oAuthPopupController?.abort("canceled")} + providerName={providerName} + /> + ) : null} + {supportsUserPassword ? ( + setUserPasswordCredentialsModalOpen(false)} + onCredentialsCreate={(creds) => { + onSelectCredential(creds); + setUserPasswordCredentialsModalOpen(false); + }} + siblingInputs={siblingInputs} + /> + ) : null} + {supportsHostScoped ? ( + setHostScopedCredentialsModalOpen(false)} + onCredentialsCreate={(creds) => { + onSelectCredential(creds); + setHostScopedCredentialsModalOpen(false); + }} + siblingInputs={siblingInputs} + /> + ) : null} + + {oAuthError ? ( + + Error: {oAuthError} + + ) : null} + + setCredentialToDelete(null)} + onConfirm={handleDeleteConfirm} + /> + + )} +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/APIKeyCredentialsModal.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/APIKeyCredentialsModal.tsx new file mode 100644 index 0000000000..0180c4ebf9 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/APIKeyCredentialsModal.tsx @@ -0,0 +1,129 @@ +import { Input } from "@/components/atoms/Input/Input"; +import { Button } from "@/components/atoms/Button/Button"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { + Form, + FormDescription, + FormField, +} from "@/components/__legacy__/ui/form"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api/types"; +import { useAPIKeyCredentialsModal } from "./useAPIKeyCredentialsModal"; + +type Props = { + schema: BlockIOCredentialsSubSchema; + open: boolean; + onClose: () => void; + onCredentialsCreate: (creds: CredentialsMetaInput) => void; + siblingInputs?: Record; +}; + +export function APIKeyCredentialsModal({ + schema, + open, + onClose, + onCredentialsCreate, + siblingInputs, +}: Props) { + const { + form, + isLoading, + supportsApiKey, + providerName, + schemaDescription, + onSubmit, + } = useAPIKeyCredentialsModal({ schema, siblingInputs, onCredentialsCreate }); + + if (isLoading || !supportsApiKey) { + return null; + } + + return ( + { + if (!isOpen) onClose(); + }, + }} + onClose={onClose} + styling={{ + maxWidth: "25rem", + }} + > + + {schemaDescription && ( +

    {schemaDescription}

    + )} + +
    + + ( + <> + + Required scope(s) for this block:{" "} + {schema.credentials_scopes?.map((s, i, a) => ( + + {s} + {i < a.length - 1 && ", "} + + ))} + + ) : null + } + {...field} + /> + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts new file mode 100644 index 0000000000..391633bed5 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/APIKeyCredentialsModal/useAPIKeyCredentialsModal.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { useForm, type UseFormReturn } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import useCredentials from "@/hooks/useCredentials"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api/types"; + +export type APIKeyFormValues = { + apiKey: string; + title: string; + expiresAt?: string; +}; + +type Args = { + schema: BlockIOCredentialsSubSchema; + siblingInputs?: Record; + onCredentialsCreate: (creds: CredentialsMetaInput) => void; +}; + +export function useAPIKeyCredentialsModal({ + schema, + siblingInputs, + onCredentialsCreate, +}: Args): { + form: UseFormReturn; + isLoading: boolean; + supportsApiKey: boolean; + provider?: string; + providerName?: string; + schemaDescription?: string; + onSubmit: (values: APIKeyFormValues) => Promise; +} { + const credentials = useCredentials(schema, siblingInputs); + + const formSchema = z.object({ + apiKey: z.string().min(1, "API Key is required"), + title: z.string().min(1, "Name is required"), + expiresAt: z.string().optional(), + }); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + apiKey: "", + title: "", + expiresAt: "", + }, + }); + + async function onSubmit(values: APIKeyFormValues) { + if (!credentials || credentials.isLoading) return; + const expiresAt = values.expiresAt + ? new Date(values.expiresAt).getTime() / 1000 + : undefined; + const newCredentials = await credentials.createAPIKeyCredentials({ + api_key: values.apiKey, + title: values.title, + expires_at: expiresAt, + }); + onCredentialsCreate({ + provider: credentials.provider, + id: newCredentials.id, + type: "api_key", + title: newCredentials.title, + }); + } + + return { + form, + isLoading: !credentials || credentials.isLoading, + supportsApiKey: !!credentials?.supportsApiKey, + provider: credentials?.provider, + providerName: + !credentials || credentials.isLoading + ? undefined + : credentials.providerName, + schemaDescription: schema.description, + onSubmit, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialRow/CredentialRow.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialRow/CredentialRow.tsx new file mode 100644 index 0000000000..21ec1200e4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialRow/CredentialRow.tsx @@ -0,0 +1,105 @@ +import { IconKey } from "@/components/__legacy__/ui/icons"; +import { Text } from "@/components/atoms/Text/Text"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/molecules/DropdownMenu/DropdownMenu"; +import { cn } from "@/lib/utils"; +import { CaretDown, DotsThreeVertical } from "@phosphor-icons/react"; +import { + fallbackIcon, + getCredentialDisplayName, + MASKED_KEY_LENGTH, + providerIcons, +} from "../../helpers"; + +type CredentialRowProps = { + credential: { + id: string; + title?: string; + username?: string; + type: string; + provider: string; + }; + provider: string; + displayName: string; + onSelect: () => void; + onDelete: () => void; + readOnly?: boolean; + showCaret?: boolean; + asSelectTrigger?: boolean; +}; + +export function CredentialRow({ + credential, + provider, + displayName, + onSelect, + onDelete, + readOnly = false, + showCaret = false, + asSelectTrigger = false, +}: CredentialRowProps) { + const ProviderIcon = providerIcons[provider] || fallbackIcon; + + return ( +
    +
    + +
    + +
    + + {getCredentialDisplayName(credential, displayName)} + + + {"*".repeat(MASKED_KEY_LENGTH)} + +
    + {showCaret && !asSelectTrigger && ( + + )} + {!readOnly && !showCaret && !asSelectTrigger && ( + + + + + + { + e.stopPropagation(); + onDelete(); + }} + > + Delete + + + + )} +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx new file mode 100644 index 0000000000..7adfa5772b --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx @@ -0,0 +1,86 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/__legacy__/ui/select"; +import { Text } from "@/components/atoms/Text/Text"; +import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types"; +import { useEffect } from "react"; +import { getCredentialDisplayName } from "../../helpers"; +import { CredentialRow } from "../CredentialRow/CredentialRow"; + +interface Props { + credentials: Array<{ + id: string; + title?: string; + username?: string; + type: string; + provider: string; + }>; + provider: string; + displayName: string; + selectedCredentials?: CredentialsMetaInput; + onSelectCredential: (credentialId: string) => void; + readOnly?: boolean; +} + +export function CredentialsSelect({ + credentials, + provider, + displayName, + selectedCredentials, + onSelectCredential, + readOnly = false, +}: Props) { + // Auto-select first credential if none is selected + useEffect(() => { + if (!selectedCredentials && credentials.length > 0) { + onSelectCredential(credentials[0].id); + } + }, [selectedCredentials, credentials, onSelectCredential]); + + return ( +
    + +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx new file mode 100644 index 0000000000..e3dd811ccc --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/DeleteConfirmationModal/DeleteConfirmationModal.tsx @@ -0,0 +1,49 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; + +interface Props { + credentialToDelete: { id: string; title: string } | null; + isDeleting: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteConfirmationModal({ + credentialToDelete, + isDeleting, + onClose, + onConfirm, +}: Props) { + return ( + { + if (!open) onClose(); + }, + }} + title="Delete credential" + styling={{ maxWidth: "32rem" }} + > + + + Are you sure you want to delete "{credentialToDelete?.title} + "? This action cannot be undone. + + + + + + + + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/HotScopedCredentialsModal/HotScopedCredentialsModal.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/HotScopedCredentialsModal/HotScopedCredentialsModal.tsx new file mode 100644 index 0000000000..547952841b --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/HotScopedCredentialsModal/HotScopedCredentialsModal.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/atoms/Input/Input"; +import { Button } from "@/components/atoms/Button/Button"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { + Form, + FormDescription, + FormField, + FormLabel, +} from "@/components/__legacy__/ui/form"; +import useCredentials from "@/hooks/useCredentials"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api/types"; +import { getHostFromUrl } from "@/lib/utils/url"; +import { PlusIcon, TrashIcon } from "@phosphor-icons/react"; + +type Props = { + schema: BlockIOCredentialsSubSchema; + open: boolean; + onClose: () => void; + onCredentialsCreate: (creds: CredentialsMetaInput) => void; + siblingInputs?: Record; +}; + +export function HostScopedCredentialsModal({ + schema, + open, + onClose, + onCredentialsCreate, + siblingInputs, +}: Props) { + const credentials = useCredentials(schema, siblingInputs); + + // Get current host from siblingInputs or discriminator_values + const currentUrl = credentials?.discriminatorValue; + const currentHost = currentUrl ? getHostFromUrl(currentUrl) : ""; + + const formSchema = z.object({ + host: z.string().min(1, "Host is required"), + title: z.string().optional(), + headers: z.record(z.string()).optional(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + host: currentHost || "", + title: currentHost || "Manual Entry", + headers: {}, + }, + }); + + const [headerPairs, setHeaderPairs] = useState< + Array<{ key: string; value: string }> + >([{ key: "", value: "" }]); + + // Update form values when siblingInputs change + useEffect(() => { + if (currentHost) { + form.setValue("host", currentHost); + form.setValue("title", currentHost); + } else { + // Reset to empty when no current host + form.setValue("host", ""); + form.setValue("title", "Manual Entry"); + } + }, [currentHost, form]); + + if ( + !credentials || + credentials.isLoading || + !credentials.supportsHostScoped + ) { + return null; + } + + const { provider, providerName, createHostScopedCredentials } = credentials; + + const addHeaderPair = () => { + setHeaderPairs([...headerPairs, { key: "", value: "" }]); + }; + + const removeHeaderPair = (index: number) => { + if (headerPairs.length > 1) { + setHeaderPairs(headerPairs.filter((_, i) => i !== index)); + } + }; + + const updateHeaderPair = ( + index: number, + field: "key" | "value", + value: string, + ) => { + const newPairs = [...headerPairs]; + newPairs[index][field] = value; + setHeaderPairs(newPairs); + }; + + async function onSubmit(values: z.infer) { + // Convert header pairs to object, filtering out empty pairs + const headers = headerPairs.reduce( + (acc, pair) => { + if (pair.key.trim() && pair.value.trim()) { + acc[pair.key.trim()] = pair.value.trim(); + } + return acc; + }, + {} as Record, + ); + + const newCredentials = await createHostScopedCredentials({ + host: values.host, + title: currentHost || values.host, + headers, + }); + + onCredentialsCreate({ + provider, + id: newCredentials.id, + type: "host_scoped", + title: newCredentials.title, + }); + } + + return ( + { + if (!isOpen) onClose(); + }, + }} + onClose={onClose} + styling={{ + maxWidth: "25rem", + }} + > + + {schema.description && ( +

    {schema.description}

    + )} + +
    + + ( + + )} + /> + +
    + Headers + + Add sensitive headers (like Authorization, X-API-Key) that + should be automatically included in requests to the specified + host. + + + {headerPairs.map((pair, index) => ( +
    + + updateHeaderPair(index, "key", e.target.value) + } + /> + + + updateHeaderPair(index, "value", e.target.value) + } + /> + +
    + ))} + + +
    + +
    + +
    + + +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/OAuthWaitingModal/OAuthWaitingModal.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/OAuthWaitingModal/OAuthWaitingModal.tsx new file mode 100644 index 0000000000..10fb5e00d7 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/OAuthWaitingModal/OAuthWaitingModal.tsx @@ -0,0 +1,30 @@ +import { Dialog } from "@/components/molecules/Dialog/Dialog"; + +type Props = { + open: boolean; + onClose: () => void; + providerName: string; +}; + +export function OAuthFlowWaitingModal({ open, onClose, providerName }: Props) { + return ( + { + if (!isOpen) onClose(); + }, + }} + onClose={onClose} + > + +

    + Complete the sign-in process in the pop-up window. +
    + Closing this dialog will cancel the sign-in process. +

    +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/PasswordCredentialsModal/PasswordCredentialsModal.tsx b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/PasswordCredentialsModal/PasswordCredentialsModal.tsx new file mode 100644 index 0000000000..c75b7be988 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/components/PasswordCredentialsModal/PasswordCredentialsModal.tsx @@ -0,0 +1,138 @@ +import { Form, FormField } from "@/components/__legacy__/ui/form"; +import { Button } from "@/components/atoms/Button/Button"; +import { Input } from "@/components/atoms/Input/Input"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import useCredentials from "@/hooks/useCredentials"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +type Props = { + schema: BlockIOCredentialsSubSchema; + open: boolean; + onClose: () => void; + onCredentialsCreate: (creds: CredentialsMetaInput) => void; + siblingInputs?: Record; +}; + +export function PasswordCredentialsModal({ + schema, + open, + onClose, + onCredentialsCreate, + siblingInputs, +}: Props) { + const credentials = useCredentials(schema, siblingInputs); + + const formSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), + title: z.string().min(1, "Name is required"), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: "", + password: "", + title: "", + }, + }); + + if ( + !credentials || + credentials.isLoading || + !credentials.supportsUserPassword + ) { + return null; + } + + const { provider, providerName, createUserPasswordCredentials } = credentials; + + async function onSubmit(values: z.infer) { + const newCredentials = await createUserPasswordCredentials({ + username: values.username, + password: values.password, + title: values.title, + }); + onCredentialsCreate({ + provider, + id: newCredentials.id, + type: "user_password", + title: newCredentials.title, + }); + } + + return ( + { + if (!isOpen) onClose(); + }, + }} + onClose={onClose} + styling={{ + maxWidth: "25rem", + }} + > + +
    + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + +
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/helpers.ts b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/helpers.ts new file mode 100644 index 0000000000..4cca825747 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/helpers.ts @@ -0,0 +1,102 @@ +import { KeyIcon } from "@phosphor-icons/react"; +import { NotionLogoIcon } from "@radix-ui/react-icons"; +import { + FaDiscord, + FaGithub, + FaGoogle, + FaHubspot, + FaMedium, + FaTwitter, +} from "react-icons/fa"; + +export const fallbackIcon = KeyIcon; + +export const providerIcons: Partial< + Record> +> = { + aiml_api: fallbackIcon, + anthropic: fallbackIcon, + apollo: fallbackIcon, + e2b: fallbackIcon, + github: FaGithub, + google: FaGoogle, + groq: fallbackIcon, + http: fallbackIcon, + notion: NotionLogoIcon, + nvidia: fallbackIcon, + discord: FaDiscord, + d_id: fallbackIcon, + google_maps: FaGoogle, + jina: fallbackIcon, + ideogram: fallbackIcon, + linear: fallbackIcon, + medium: FaMedium, + mem0: fallbackIcon, + ollama: fallbackIcon, + openai: fallbackIcon, + openweathermap: fallbackIcon, + open_router: fallbackIcon, + llama_api: fallbackIcon, + pinecone: fallbackIcon, + enrichlayer: fallbackIcon, + slant3d: fallbackIcon, + screenshotone: fallbackIcon, + smtp: fallbackIcon, + replicate: fallbackIcon, + reddit: fallbackIcon, + fal: fallbackIcon, + revid: fallbackIcon, + twitter: FaTwitter, + unreal_speech: fallbackIcon, + exa: fallbackIcon, + hubspot: FaHubspot, + smartlead: fallbackIcon, + todoist: fallbackIcon, + zerobounce: fallbackIcon, +}; + +export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & ( + | { + success: true; + code: string; + state: string; + } + | { + success: false; + message: string; + } +); + +export function getActionButtonText( + supportsOAuth2: boolean, + supportsApiKey: boolean, + supportsUserPassword: boolean, + supportsHostScoped: boolean, + hasExistingCredentials: boolean, +): string { + if (hasExistingCredentials) { + if (supportsOAuth2) return "Connect another account"; + if (supportsApiKey) return "Use a new API key"; + if (supportsUserPassword) return "Add a new username and password"; + if (supportsHostScoped) return "Add new headers"; + return "Add new credentials"; + } else { + if (supportsOAuth2) return "Add account"; + if (supportsApiKey) return "Add API key"; + if (supportsUserPassword) return "Add username and password"; + if (supportsHostScoped) return "Add headers"; + return "Add credentials"; + } +} + +export function getCredentialDisplayName( + credential: { title?: string; username?: string }, + displayName: string, +): string { + return ( + credential.title || credential.username || `Your ${displayName} account` + ); +} + +export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +export const MASKED_KEY_LENGTH = 30; diff --git a/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/useCredentialsInput.ts b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/useCredentialsInput.ts new file mode 100644 index 0000000000..6f5ca48126 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/CredentialsInputs/useCredentialsInput.ts @@ -0,0 +1,315 @@ +import { useDeleteV1DeleteCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations"; +import useCredentials from "@/hooks/useCredentials"; +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { + BlockIOCredentialsSubSchema, + CredentialsMetaInput, +} from "@/lib/autogpt-server-api/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { + getActionButtonText, + OAUTH_TIMEOUT_MS, + OAuthPopupResultMessage, +} from "./helpers"; + +export type CredentialsInputState = ReturnType; + +type Params = { + schema: BlockIOCredentialsSubSchema; + selectedCredential?: CredentialsMetaInput; + onSelectCredential: (newValue?: CredentialsMetaInput) => void; + siblingInputs?: Record; + onLoaded?: (loaded: boolean) => void; + readOnly?: boolean; +}; + +export function useCredentialsInput({ + schema, + selectedCredential, + onSelectCredential, + siblingInputs, + onLoaded, + readOnly = false, +}: Params) { + const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] = + useState(false); + const [ + isUserPasswordCredentialsModalOpen, + setUserPasswordCredentialsModalOpen, + ] = useState(false); + const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] = + useState(false); + const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false); + const [oAuthPopupController, setOAuthPopupController] = + useState(null); + const [oAuthError, setOAuthError] = useState(null); + const [credentialToDelete, setCredentialToDelete] = useState<{ + id: string; + title: string; + } | null>(null); + + const api = useBackendAPI(); + const queryClient = useQueryClient(); + const credentials = useCredentials(schema, siblingInputs); + + const deleteCredentialsMutation = useDeleteV1DeleteCredentials({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["/api/integrations/credentials"], + }); + queryClient.invalidateQueries({ + queryKey: [`/api/integrations/${credentials?.provider}/credentials`], + }); + setCredentialToDelete(null); + if (selectedCredential?.id === credentialToDelete?.id) { + onSelectCredential(undefined); + } + }, + }, + }); + + useEffect(() => { + if (onLoaded) { + onLoaded(Boolean(credentials && credentials.isLoading === false)); + } + }, [credentials, onLoaded]); + + // Unselect credential if not available + useEffect(() => { + if (readOnly) return; + if (!credentials || !("savedCredentials" in credentials)) return; + if ( + selectedCredential && + !credentials.savedCredentials.some((c) => c.id === selectedCredential.id) + ) { + onSelectCredential(undefined); + } + }, [credentials, selectedCredential, onSelectCredential, readOnly]); + + // The available credential, if there is only one + const singleCredential = useMemo(() => { + if (!credentials || !("savedCredentials" in credentials)) { + return null; + } + + return credentials.savedCredentials.length === 1 + ? credentials.savedCredentials[0] + : null; + }, [credentials]); + + // Auto-select the one available credential + useEffect(() => { + if (readOnly) return; + if (singleCredential && !selectedCredential) { + onSelectCredential(singleCredential); + } + }, [singleCredential, selectedCredential, onSelectCredential, readOnly]); + + if ( + !credentials || + credentials.isLoading || + !("savedCredentials" in credentials) + ) { + return { + isLoading: true, + }; + } + + const { + provider, + providerName, + supportsApiKey, + supportsOAuth2, + supportsUserPassword, + supportsHostScoped, + savedCredentials, + oAuthCallback, + } = credentials; + + async function handleOAuthLogin() { + setOAuthError(null); + const { login_url, state_token } = await api.oAuthLogin( + provider, + schema.credentials_scopes, + ); + setOAuth2FlowInProgress(true); + const popup = window.open(login_url, "_blank", "popup=true"); + + if (!popup) { + throw new Error( + "Failed to open popup window. Please allow popups for this site.", + ); + } + + const controller = new AbortController(); + setOAuthPopupController(controller); + controller.signal.onabort = () => { + console.debug("OAuth flow aborted"); + setOAuth2FlowInProgress(false); + popup.close(); + }; + + const handleMessage = async (e: MessageEvent) => { + console.debug("Message received:", e.data); + if ( + typeof e.data != "object" || + !("message_type" in e.data) || + e.data.message_type !== "oauth_popup_result" + ) { + console.debug("Ignoring irrelevant message"); + return; + } + + if (!e.data.success) { + console.error("OAuth flow failed:", e.data.message); + setOAuthError(`OAuth flow failed: ${e.data.message}`); + setOAuth2FlowInProgress(false); + return; + } + + if (e.data.state !== state_token) { + console.error("Invalid state token received"); + setOAuthError("Invalid state token received"); + setOAuth2FlowInProgress(false); + return; + } + + try { + console.debug("Processing OAuth callback"); + const credentials = await oAuthCallback(e.data.code, e.data.state); + console.debug("OAuth callback processed successfully"); + + // Check if the credential's scopes match the required scopes + const requiredScopes = schema.credentials_scopes; + if (requiredScopes && requiredScopes.length > 0) { + const grantedScopes = new Set(credentials.scopes || []); + const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf( + grantedScopes, + ); + + if (!hasAllRequiredScopes) { + console.error( + `Newly created OAuth credential for ${providerName} has insufficient scopes. Required:`, + requiredScopes, + "Granted:", + credentials.scopes, + ); + setOAuthError( + "Connection failed: the granted permissions don't match what's required. " + + "Please contact the application administrator.", + ); + return; + } + } + + onSelectCredential({ + id: credentials.id, + type: "oauth2", + title: credentials.title, + provider, + }); + } catch (error) { + console.error("Error in OAuth callback:", error); + setOAuthError( + `Error in OAuth callback: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + console.debug("Finalizing OAuth flow"); + setOAuth2FlowInProgress(false); + controller.abort("success"); + } + }; + + console.debug("Adding message event listener"); + window.addEventListener("message", handleMessage, { + signal: controller.signal, + }); + + setTimeout(() => { + console.debug("OAuth flow timed out"); + controller.abort("timeout"); + setOAuth2FlowInProgress(false); + setOAuthError("OAuth flow timed out"); + }, OAUTH_TIMEOUT_MS); + } + + function handleActionButtonClick() { + if (supportsOAuth2) { + handleOAuthLogin(); + } else if (supportsApiKey) { + setAPICredentialsModalOpen(true); + } else if (supportsUserPassword) { + setUserPasswordCredentialsModalOpen(true); + } else if (supportsHostScoped) { + setHostScopedCredentialsModalOpen(true); + } + } + + function handleCredentialSelect(credentialId: string) { + const selectedCreds = savedCredentials.find((c) => c.id === credentialId); + if (selectedCreds) { + onSelectCredential({ + id: selectedCreds.id, + type: selectedCreds.type, + provider: provider, + title: (selectedCreds as any).title, + }); + } + } + + function handleDeleteCredential(credential: { id: string; title: string }) { + setCredentialToDelete(credential); + } + + function handleDeleteConfirm() { + if (credentialToDelete && credentials) { + deleteCredentialsMutation.mutate({ + provider: credentials.provider, + credId: credentialToDelete.id, + }); + } + } + + return { + isLoading: false as const, + provider, + providerName, + supportsApiKey, + supportsOAuth2, + supportsUserPassword, + supportsHostScoped, + credentialsToShow: savedCredentials, + selectedCredential, + oAuthError, + isAPICredentialsModalOpen, + isUserPasswordCredentialsModalOpen, + isHostScopedCredentialsModalOpen, + isOAuth2FlowInProgress, + oAuthPopupController, + credentialToDelete, + deleteCredentialsMutation, + actionButtonText: getActionButtonText( + supportsOAuth2, + supportsApiKey, + supportsUserPassword, + supportsHostScoped, + savedCredentials.length > 0, + ), + setAPICredentialsModalOpen, + setUserPasswordCredentialsModalOpen, + setHostScopedCredentialsModalOpen, + setCredentialToDelete, + handleActionButtonClick, + handleCredentialSelect, + handleDeleteCredential, + handleDeleteConfirm, + handleOAuthLogin, + onSelectCredential, + schema, + siblingInputs, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx index e0a43b8c77..cf138a9747 100644 --- a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx +++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx @@ -1,7 +1,7 @@ "use client"; -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs"; import { Button } from "@/components/atoms/Button/Button"; +import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs"; import { CircleNotchIcon, FolderOpenIcon } from "@phosphor-icons/react"; import { Props as BaseProps, diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputActions.tsx b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputActions.tsx new file mode 100644 index 0000000000..cfe69e8709 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputActions.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React, { useState } from "react"; +import { CheckIcon, CopyIcon, DownloadIcon } from "@phosphor-icons/react"; +import { Button } from "@/components/atoms/Button/Button"; +import { OutputRenderer, OutputMetadata } from "../types"; +import { downloadOutputs } from "../utils/download"; +import { cn } from "@/lib/utils"; + +interface OutputActionsProps { + items: Array<{ + value: unknown; + metadata?: OutputMetadata; + renderer: OutputRenderer; + }>; + isPrimary?: boolean; + className?: string; +} + +export function OutputActions({ + items, + isPrimary = false, +}: OutputActionsProps) { + const [copied, setCopied] = useState(false); + + const handleCopyAll = async () => { + const textContents: string[] = []; + + for (const item of items) { + const copyContent = item.renderer.getCopyContent( + item.value, + item.metadata, + ); + if ( + copyContent && + item.renderer.isConcatenable(item.value, item.metadata) + ) { + // For concatenable items, extract the text + let text: string; + if (typeof copyContent.data === "string") { + text = copyContent.data; + } else if (copyContent.fallbackText) { + text = copyContent.fallbackText; + } else { + continue; + } + textContents.push(text); + } + } + + if (textContents.length > 0) { + const combinedText = textContents.join("\n\n"); + try { + await navigator.clipboard.writeText(combinedText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + } + } + }; + + const handleDownloadAll = () => { + downloadOutputs(items); + }; + + return ( +
    + + + +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputItem.tsx b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputItem.tsx new file mode 100644 index 0000000000..c5c91d5d48 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/components/OutputItem.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Text } from "@/components/atoms/Text/Text"; +import { OutputMetadata, OutputRenderer } from "../types"; + +interface OutputItemProps { + value: any; + metadata?: OutputMetadata; + renderer: OutputRenderer; + label?: string; +} + +export function OutputItem({ + value, + metadata, + renderer, + label, +}: OutputItemProps) { + return ( +
    + {label && ( + + {label} + + )} + +
    {renderer.render(value, metadata)}
    +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/index.ts b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/index.ts new file mode 100644 index 0000000000..ba26054eb2 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/index.ts @@ -0,0 +1,20 @@ +import { globalRegistry } from "./types"; +import { textRenderer } from "./renderers/TextRenderer"; +import { codeRenderer } from "./renderers/CodeRenderer"; +import { imageRenderer } from "./renderers/ImageRenderer"; +import { videoRenderer } from "./renderers/VideoRenderer"; +import { jsonRenderer } from "./renderers/JSONRenderer"; +import { markdownRenderer } from "./renderers/MarkdownRenderer"; + +// Register all renderers in priority order +globalRegistry.register(videoRenderer); +globalRegistry.register(imageRenderer); +globalRegistry.register(codeRenderer); +globalRegistry.register(markdownRenderer); +globalRegistry.register(jsonRenderer); +globalRegistry.register(textRenderer); + +export { globalRegistry }; +export type { OutputRenderer, OutputMetadata, DownloadContent } from "./types"; +export { OutputItem } from "./components/OutputItem"; +export { OutputActions } from "./components/OutputActions"; diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/CodeRenderer.tsx b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/CodeRenderer.tsx new file mode 100644 index 0000000000..93df7d8ddd --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/CodeRenderer.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { + OutputRenderer, + OutputMetadata, + DownloadContent, + CopyContent, +} from "../types"; + +function getFileExtension(language: string): string { + const extensionMap: Record = { + javascript: "js", + typescript: "ts", + python: "py", + java: "java", + csharp: "cs", + cpp: "cpp", + c: "c", + html: "html", + css: "css", + json: "json", + xml: "xml", + yaml: "yaml", + markdown: "md", + sql: "sql", + bash: "sh", + shell: "sh", + plaintext: "txt", + }; + + return extensionMap[language.toLowerCase()] || "txt"; +} + +function canRenderCode(value: unknown, metadata?: OutputMetadata): boolean { + if (metadata?.type === "code" || metadata?.language) { + return typeof value === "string"; + } + + if (typeof value !== "string") return false; + + const markdownIndicators = [ + /^#{1,6}\s+/m, + /\*\*[^*]+\*\*/, + /\[([^\]]+)\]\(([^)]+)\)/, + /^>\s+/m, + /^\s*[-*+]\s+\w+/m, + /!\[([^\]]*)\]\(([^)]+)\)/, + ]; + + let markdownMatches = 0; + for (const pattern of markdownIndicators) { + if (pattern.test(value)) { + markdownMatches++; + if (markdownMatches >= 2) { + return false; + } + } + } + + const codeIndicators = [ + /^(function|const|let|var|class|import|export|if|for|while)\s/m, + /^def\s+\w+\s*\(/m, + /^import\s+/m, + /^from\s+\w+\s+import/m, + /^\s*<[^>]+>/, + /[{}[\]();]/, + ]; + + return codeIndicators.some((pattern) => pattern.test(value)); +} + +function renderCode( + value: unknown, + metadata?: OutputMetadata, +): React.ReactNode { + const codeValue = String(value); + const language = metadata?.language || "plaintext"; + + return ( +
    + {metadata?.language && ( +
    + {language} +
    + )} +
    +        {codeValue}
    +      
    +
    + ); +} + +function getCopyContentCode( + value: unknown, + _metadata?: OutputMetadata, +): CopyContent | null { + const codeValue = String(value); + return { + mimeType: "text/plain", + data: codeValue, + fallbackText: codeValue, + }; +} + +function getDownloadContentCode( + value: unknown, + metadata?: OutputMetadata, +): DownloadContent | null { + const codeValue = String(value); + const language = metadata?.language || "txt"; + const extension = getFileExtension(language); + const blob = new Blob([codeValue], { type: "text/plain" }); + + return { + data: blob, + filename: metadata?.filename || `code.${extension}`, + mimeType: "text/plain", + }; +} + +function isConcatenableCode( + _value: unknown, + _metadata?: OutputMetadata, +): boolean { + return true; +} + +export const codeRenderer: OutputRenderer = { + name: "CodeRenderer", + priority: 30, + canRender: canRenderCode, + render: renderCode, + getCopyContent: getCopyContentCode, + getDownloadContent: getDownloadContentCode, + isConcatenable: isConcatenableCode, +}; diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/ImageRenderer.tsx b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/ImageRenderer.tsx new file mode 100644 index 0000000000..f2a7636c6e --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/ImageRenderer.tsx @@ -0,0 +1,209 @@ +import React from "react"; +import { + OutputRenderer, + OutputMetadata, + DownloadContent, + CopyContent, +} from "../types"; + +const imageExtensions = [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ".svg", + ".webp", + ".ico", +]; + +const imageMimeTypes = [ + "image/jpeg", + "image/png", + "image/gif", + "image/bmp", + "image/svg+xml", + "image/webp", + "image/x-icon", +]; + +function guessMimeType(url: string): string | null { + const extension = url.split(".").pop()?.toLowerCase(); + const mimeMap: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + bmp: "image/bmp", + svg: "image/svg+xml", + webp: "image/webp", + ico: "image/x-icon", + }; + return extension ? mimeMap[extension] || null : null; +} + +function canRenderImage(value: unknown, metadata?: OutputMetadata): boolean { + if ( + metadata?.type === "image" || + (metadata?.mimeType && imageMimeTypes.includes(metadata.mimeType)) + ) { + return true; + } + + if (typeof value === "object" && value !== null) { + const obj = value as any; + if (obj.url || obj.data || obj.path) { + const urlOrData = obj.url || obj.data || obj.path; + + if (typeof urlOrData === "string") { + if (urlOrData.startsWith("data:image/")) { + return true; + } + + if ( + urlOrData.startsWith("http://") || + urlOrData.startsWith("https://") + ) { + const hasImageExt = imageExtensions.some((ext) => + urlOrData.toLowerCase().includes(ext), + ); + return hasImageExt; + } + } + } + + if (obj.filename) { + const hasImageExt = imageExtensions.some((ext) => + obj.filename.toLowerCase().endsWith(ext), + ); + return hasImageExt; + } + } + + if (typeof value === "string") { + if (value.startsWith("data:image/")) { + return true; + } + + if (value.startsWith("http://") || value.startsWith("https://")) { + const hasImageExt = imageExtensions.some((ext) => + value.toLowerCase().includes(ext), + ); + return hasImageExt; + } + + if (metadata?.filename) { + const hasImageExt = imageExtensions.some((ext) => + metadata.filename!.toLowerCase().endsWith(ext), + ); + return hasImageExt; + } + } + + return false; +} + +function renderImage( + value: unknown, + metadata?: OutputMetadata, +): React.ReactNode { + const imageUrl = String(value); + const altText = metadata?.filename || "Output image"; + + return ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {altText} +
    + ); +} + +function getCopyContentImage( + value: unknown, + metadata?: OutputMetadata, +): CopyContent | null { + const imageUrl = String(value); + + if (imageUrl.startsWith("data:")) { + const mimeMatch = imageUrl.match(/data:([^;]+)/); + const mimeType = mimeMatch?.[1] || "image/png"; + + return { + mimeType: mimeType, + data: async () => { + const response = await fetch(imageUrl); + return await response.blob(); + }, + alternativeMimeTypes: ["image/png", "text/plain"], + fallbackText: imageUrl, + }; + } + + const mimeType = metadata?.mimeType || guessMimeType(imageUrl) || "image/png"; + + return { + mimeType: mimeType, + data: async () => { + const response = await fetch(imageUrl); + return await response.blob(); + }, + alternativeMimeTypes: ["image/png", "text/plain"], + fallbackText: imageUrl, + }; +} + +function getDownloadContentImage( + value: unknown, + metadata?: OutputMetadata, +): DownloadContent | null { + const imageUrl = String(value); + + if (imageUrl.startsWith("data:")) { + const [mimeInfo, base64Data] = imageUrl.split(","); + const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/png"; + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: mimeType }); + + const extension = mimeType.split("/")[1] || "png"; + return { + data: blob, + filename: metadata?.filename || `image.${extension}`, + mimeType, + }; + } + + return { + data: imageUrl, + filename: metadata?.filename || "image.png", + mimeType: metadata?.mimeType || "image/png", + }; +} + +function isConcatenableImage( + _value: unknown, + _metadata?: OutputMetadata, +): boolean { + return false; +} + +export const imageRenderer: OutputRenderer = { + name: "ImageRenderer", + priority: 40, + canRender: canRenderImage, + render: renderImage, + getCopyContent: getCopyContentImage, + getDownloadContent: getDownloadContentImage, + isConcatenable: isConcatenableImage, +}; diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/JSONRenderer.tsx b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/JSONRenderer.tsx new file mode 100644 index 0000000000..668f9179c3 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/JSONRenderer.tsx @@ -0,0 +1,204 @@ +"use client"; + +import React, { useState } from "react"; +import { CaretDown, CaretRight } from "@phosphor-icons/react"; +import { + OutputRenderer, + OutputMetadata, + DownloadContent, + CopyContent, +} from "../types"; + +function canRenderJSON(value: unknown, _metadata?: OutputMetadata): boolean { + if (_metadata?.type === "json") { + return true; + } + + if (typeof value === "object" && value !== null) { + return true; + } + + if (typeof value === "string") { + try { + JSON.parse(value); + return true; + } catch { + return false; + } + } + + return false; +} + +function renderJSON( + value: unknown, + _metadata?: OutputMetadata, +): React.ReactNode { + let jsonData = value; + + if (typeof value === "string") { + try { + jsonData = JSON.parse(value); + } catch { + return null; + } + } + + return ; +} + +function getCopyContentJSON( + value: unknown, + _metadata?: OutputMetadata, +): CopyContent | null { + const jsonString = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + + return { + mimeType: "application/json", + data: jsonString, + alternativeMimeTypes: ["text/plain"], + fallbackText: jsonString, + }; +} + +function getDownloadContentJSON( + value: unknown, + _metadata?: OutputMetadata, +): DownloadContent | null { + const jsonString = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + const blob = new Blob([jsonString], { type: "application/json" }); + + return { + data: blob, + filename: _metadata?.filename || "output.json", + mimeType: "application/json", + }; +} + +function isConcatenableJSON( + _value: unknown, + _metadata?: OutputMetadata, +): boolean { + return true; +} + +export const jsonRenderer: OutputRenderer = { + name: "JSONRenderer", + priority: 20, + canRender: canRenderJSON, + render: renderJSON, + getCopyContent: getCopyContentJSON, + getDownloadContent: getDownloadContentJSON, + isConcatenable: isConcatenableJSON, +}; + +function JSONViewer({ data }: { data: any }) { + const [collapsed, setCollapsed] = useState>({}); + + const toggleCollapse = (key: string) => { + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const renderValue = (value: any, key: string = ""): React.ReactNode => { + if (value === null) + return null; + if (value === undefined) + return undefined; + + if (typeof value === "boolean") { + return {value.toString()}; + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "string") { + return "{value}"; + } + + if (Array.isArray(value)) { + const isCollapsed = collapsed[key]; + const itemCount = value.length; + + if (itemCount === 0) { + return []; + } + + return ( +
    + + {!isCollapsed && ( +
    + {value.map((item, index) => ( +
    + {index}: + {renderValue(item, `${key}[${index}]`)} +
    + ))} +
    + )} +
    + ); + } + + if (typeof value === "object") { + const isCollapsed = collapsed[key]; + const keys = Object.keys(value); + + if (keys.length === 0) { + return {"{}"}; + } + + return ( +
    + + {!isCollapsed && ( +
    + {keys.map((objKey) => ( +
    + + "{objKey}": + + {renderValue(value[objKey], `${key}.${objKey}`)} +
    + ))} +
    + )} +
    + ); + } + + return {String(value)}; + }; + + return ( +
    + {renderValue(data, "root")} +
    + ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/MarkdownRenderer.tsx b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/MarkdownRenderer.tsx new file mode 100644 index 0000000000..d94966c6c8 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/MarkdownRenderer.tsx @@ -0,0 +1,456 @@ +"use client"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import rehypeHighlight from "rehype-highlight"; +import rehypeSlug from "rehype-slug"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import { + OutputRenderer, + OutputMetadata, + DownloadContent, + CopyContent, +} from "../types"; +import "highlight.js/styles/github-dark.css"; +import "katex/dist/katex.min.css"; + +const markdownPatterns = [ + /```[\s\S]*?```/u, // Fenced code blocks (check first) + /^#{1,6}\s+\S+/gmu, // ATX headers (require content) + /\*\*[^*\n]+?\*\*/u, // **bold** + /__(?!_)[^_\n]+?__(?!_)/u, // __bold__ (avoid ___/snake_case_) + /(?\s+\S.*/gm, // Blockquotes + /^\|[^|\n]+(\|[^|\n]+)+\|\s*$/gm, // Table row (at least two cells) + /^\s*\|(?:\s*:?[-=]{3,}\s*\|)+\s*$/gm, // Table separator row + /\$\$[\s\S]+?\$\$/u, // Display math + /(? url.toLowerCase().includes(ext)); +} + +function getVideoEmbedUrl(url: string): string | null { + const youtubeMatch = url.match( + /(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\s]+)/, + ); + if (youtubeMatch) { + return `https://www.youtube.com/embed/${youtubeMatch[1]}`; + } + + const vimeoMatch = url.match(/vimeo\.com\/(\d+)/); + if (vimeoMatch) { + return `https://player.vimeo.com/video/${vimeoMatch[1]}`; + } + + if (videoExtensions.some((ext) => url.toLowerCase().includes(ext))) { + return url; + } + + return null; +} + +function renderVideoEmbed(url: string): React.ReactNode { + const embedUrl = getVideoEmbedUrl(url); + + if (!embedUrl) { + return ( + + {url} + + ); + } + + if (videoExtensions.some((ext) => embedUrl.toLowerCase().includes(ext))) { + return ( +
    + +
    + ); + } + + return ( +
    +
    +