Compare commits

...

9 Commits

Author SHA1 Message Date
Zamil Majdy
f482eb668b hotfix(backend): resolve tool pin name mismatch in SmartDecisionMakerBlock (#11749)
## Root Cause

Execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2 and all similar
executions have been failing since Nov 12, 2025 when tool pin routing
was refactored to use node IDs. The SmartDecisionMakerBlock was
double-sanitizing field names when emitting tool call outputs:

```python
# Original field name from link: "Max Keyword Difficulty"
original_field_name = field_mapping.get(clean_arg_name)  #  Retrieved correctly
sanitized_arg_name = self.cleanup(original_field_name)   #  Sanitized AGAIN!
emit_key = f"tools_^_{node_id}_~_{sanitized_arg_name}"   # Emits "max_keyword_difficulty"
```

But the parser expected original names from graph links:
```python
# Parser expects: "Max Keyword Difficulty" (from link.sink_name)
# Emit provides: "max_keyword_difficulty" (sanitized)
# Result: Mismatch → Tool never executes
```

### Changes 🏗️

**1. Fixed Emit Logic** (`smart_decision_maker.py` line 1135)
- Removed double sanitization: `sanitized_arg_name =
self.cleanup(original_field_name)`
- Now emits with original field names: `emit_key =
f"tools_^_{node_id}_~_{original_field_name}"`

**2. Made Agent Nodes Consistent** (`smart_decision_maker.py` lines
497-530)
- Added `field_mapping` to agent function signatures (was missing)
- Agent signatures now sanitize property keys for Anthropic API (like
block signatures)
- Stores field_mapping for use during emit

### Impact

**Fixes:**
-  All graphs with multi-word field names (e.g., "Max Keyword
Difficulty", "Minimum Volume")
-  All graphs with special characters in field names (e.g., "API-Key")
-  Both block nodes AND agent nodes now work consistently

**Unaffected:**
- Single-word lowercase field names (e.g., "keyword", "url") - these
were already working

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified parse_execution_output handles exact match correctly
  - [x] Verified emit uses original field names
  - [x] Verified field_mapping works for both block and agent nodes
- [x] Re-run execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2 after
deployment to verify fix

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
(no changes)
- [x] `docker-compose.yml` is updated or already compatible with my
changes (no changes)
- [x] No configuration changes in this PR

### Test Plan

1. **Unit test validation** (completed):
- Field name cleanup: "Max Keyword Difficulty" →
"max_keyword_difficulty" 
   - Parse with exact match: Success 
   - Parse with mismatch: Returns None 

2. **Production validation** (to be done after deployment):
   - Re-run execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2
- Verify AgentExecutor (node 767682f5-694f-4b2a-bf52-fbdcad6a4a4f)
executes successfully
   - Verify execution completes with high correctness score (not 0.20)
   - Monitor for any regressions in existing graphs

### Files Changed

- `backend/blocks/smart_decision_maker.py`: Remove double sanitization,
add agent field_mapping

### Related Issues

- Resolves execution failure a40bdb4a-964d-4684-94e8-b148eb6bcfc2
- Fixes bug introduced in commit 536e2a5ec (Nov 12, 2025)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved field name mapping consistency in the SmartDecisionMaker
block to ensure proper handling of field names throughout function
signatures and tool execution workflows.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 02:08:12 +07:00
Bentlybro
fc8434fb30 Merge branch 'master' into dev 2026-01-07 12:02:15 +00:00
Ubbe
3ae08cd48e feat(frontend): use Google Drive Picker on new builder (#11702)
## Changes 🏗️

<img width="600" height="960" alt="Screenshot 2026-01-06 at 17 40 23"
src="https://github.com/user-attachments/assets/61085ec5-a367-45c7-acaa-e3fc0f0af647"
/>

- So when using Google Blocks on the new builder, it shows Google Drive
Picket 🏁

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
  - [x] Run app locally and test the above


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a Google Drive picker field and widget for forms with an
always-visible remove button and improved single/multi selection
handling.

* **Bug Fixes**
* Better validation and normalization of selected files and consolidated
error messaging.
* Adjusted layout spacing around the picker and selected files for
clearer display.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 17:07:09 +07:00
Swifty
4db13837b9 Revert "extracted frontend changes out of the hackathon/copilot branch"
This reverts commit df87867625.
2026-01-07 09:27:25 +01:00
Swifty
df87867625 extracted frontend changes out of the hackathon/copilot branch 2026-01-07 09:25:10 +01:00
Abhimanyu Yadav
e503126170 feat(frontend): upgrade RJSF to v6 and implement new FormRenderer system
(#11677)

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 05:06:34 +00:00
Zamil Majdy
7ee28197a3 docs(gitbook): sync documentation updates with dev branch (#11709)
## Summary

Sync GitBook documentation changes from the gitbook branch to dev. This
PR contains comprehensive documentation updates including new assets,
content restructuring, and infrastructure improvements.

## Changes 🏗️

### Documentation Updates
- **New GitBook Assets**: Added 9 new documentation images and
screenshots
  - Platform overview images (AGPT_Platform.png, Banner_image.png)
- Feature illustrations (Contribute.png, Integrations.png, hosted.jpg,
no-code.jpg, api-reference.jpg)
  - Screenshots and examples for better user guidance
- **Content Updates**: Enhanced README.md and SUMMARY.md with improved
structure and navigation
- **Visual Documentation**: Added comprehensive visual guides for
platform features

### Infrastructure 
- **Cloudflare Worker**: Added redirect handler for docs.agpt.co →
agpt.co/docs migration
  - Complete URL mapping for 71+ redirect patterns
  - Handles platform blocks restructuring and edge cases
  - Ready for deployment to Cloudflare Workers

### Merge Conflict Resolution
- **Clean merge from dev**: Successfully merged dev's major backend
restructuring (server/ → api/)
- **File resurrection fix**: Removed files that were accidentally
resurrected during merge conflict resolution
  - Cleaned up BuilderActionButton.tsx (deleted in dev)
  - Cleaned up old PreviewBanner.tsx location (moved in dev)
  - Synced pnpm-lock.yaml and layout.tsx with dev's current state

## Technical Details

This PR represents a careful synchronization that:
1. **Preserves all GitBook documentation work** while staying current
with dev
2. **Maintains clean diff**: Only documentation-related changes remain
after merge cleanup
3. **Resolves merge conflicts**: Handled major backend API restructuring
without breaking docs
4. **Infrastructure ready**: Cloudflare Worker ready for docs migration
deployment

## Files Changed
- `docs/`: GitBook documentation assets and content
- `autogpt_platform/cloudflare_worker.js`: Docs infrastructure for URL
redirects

## Validation
-  All TypeScript compilation errors resolved
-  Pre-commit hooks passing (Prettier, TypeCheck)
-  Only documentation changes remain in diff vs dev
-  Cloudflare Worker tested with comprehensive URL mapping
-  No non-documentation code changes after cleanup

## Deployment Notes
The Cloudflare Worker can be deployed via:
```bash
# Cloudflare Dashboard → Workers → Create → Paste code → Add route docs.agpt.co/*
```

This completes the GitBook synchronization and prepares for docs site
migration.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: bobby.gaffin <bobby.gaffin@agpt.co>
Co-authored-by: Bently <Github@bentlybro.com>
Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
Co-authored-by: Swifty <craigswift13@gmail.com>
Co-authored-by: Ubbe <hi@ubbe.dev>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lluis Agusti <hi@llu.lu>
2026-01-07 02:11:11 +00:00
Nicholas Tindle
818de26d24 fix(platform/blocks): XMLParserBlock list object error (#11517)
<!-- Clearly explain the need for these changes: -->

### Need for these changes 💡

The `XMLParserBlock` was susceptible to crashing with an
`AttributeError: 'List' object has no attribute 'add_text'` when
processing malformed XML inputs, such as documents with multiple root
elements or stray text outside the root. This PR introduces robust
validation to prevent these crashes and provide clear, actionable error
messages to users.

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->

- Added a `_validate_tokens` static method to `XMLParserBlock` to
perform pre-parsing validation on the token stream. This method ensures
the XML input has a single root element and no text content outside of
it.
- Modified the `XMLParserBlock.run` method to call `_validate_tokens`
immediately after tokenization and before passing the tokens to
`gravitasml.Parser`.
- Introduced a new test case, `test_rejects_text_outside_root`, in
`test_blocks_dos_vulnerability.py` to verify that the `XMLParserBlock`
correctly raises a `ValueError` when encountering XML with text outside
the root element.
- Imported `Token` for type hinting in `xml_parser.py`.

### 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:
  <!-- Put your test plan here: -->
- [x] Confirm that the `test_rejects_text_outside_root` test passes,
asserting that `ValueError` is raised for invalid XML.
  - [x] Confirm that other relevant XML parsing tests continue to pass.


---
Linear Issue:
[OPEN-2835](https://linear.app/autogpt/issue/OPEN-2835/blockunknownerror-raised-by-xmlparserblock-with-message-list-object)

<a
href="https://cursor.com/background-agent?bcId=bc-4495ea93-6836-412c-b2e3-0adb31113169"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-cursor-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in
Cursor"
src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a
href="https://cursor.com/agents?id=bc-4495ea93-6836-412c-b2e3-0adb31113169"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/open-in-web-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web"
src="https://cursor.com/open-in-web.svg"></picture></a>


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Strengthens XML parsing robustness and error clarity.
> 
> - Adds `_validate_tokens` in `XMLParserBlock` to ensure a single root
element, balanced tags, and no text outside the root before parsing
> - Updates `run` to `list(tokenize(...))` and validate tokens prior to
`Parser.parse()`; maintains 10MB input size guard
> - Introduces `test_rejects_text_outside_root` asserting a readable
`ValueError` for trailing text
> - Bumps `gravitasml` to `0.1.4` in `pyproject.toml` and lockfile
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
22cc5149c5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved XML parsing validation with stricter enforcement of
single-root elements and prevention of trailing text, providing clearer
error messages for invalid XML input.

* **Tests**
* Added test coverage for XML parser validation of invalid root text
scenarios.

* **Chores**
  * Updated GravitasML dependency to latest compatible version.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <ntindle@users.noreply.github.com>
2026-01-06 20:02:53 +00:00
Ubbe
4a7bc006a8 hotfix(frontend): chat should be disabled by default (#11639)
### 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
2025-12-18 19:04:13 +01:00
135 changed files with 4756 additions and 5642 deletions

View File

@@ -495,8 +495,14 @@ class SmartDecisionMakerBlock(Block):
}
properties = {}
field_mapping = {}
for link in links:
field_name = link.sink_name
clean_field_name = SmartDecisionMakerBlock.cleanup(field_name)
field_mapping[clean_field_name] = field_name
sink_block_input_schema = sink_node.input_default["input_schema"]
sink_block_properties = sink_block_input_schema.get("properties", {}).get(
link.sink_name, {}
@@ -506,7 +512,7 @@ class SmartDecisionMakerBlock(Block):
if "description" in sink_block_properties
else f"The {link.sink_name} of the tool"
)
properties[link.sink_name] = {
properties[clean_field_name] = {
"type": "string",
"description": description,
"default": json.dumps(sink_block_properties.get("default", None)),
@@ -519,7 +525,7 @@ class SmartDecisionMakerBlock(Block):
"strict": True,
}
# Store node info for later use in output processing
tool_function["_field_mapping"] = field_mapping
tool_function["_sink_node_id"] = sink_node.id
return {"type": "function", "function": tool_function}
@@ -1129,8 +1135,9 @@ class SmartDecisionMakerBlock(Block):
original_field_name = field_mapping.get(clean_arg_name, clean_arg_name)
arg_value = tool_args.get(clean_arg_name)
sanitized_arg_name = self.cleanup(original_field_name)
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
# Use original_field_name directly (not sanitized) to match link sink_name
# The field_mapping already translates from LLM's cleaned names to original names
emit_key = f"tools_^_{sink_node_id}_~_{original_field_name}"
logger.debug(
"[SmartDecisionMakerBlock|geid:%s|neid:%s] emit %s",

View File

@@ -196,6 +196,15 @@ class TestXMLParserBlockSecurity:
async for _ in block.run(XMLParserBlock.Input(input_xml=large_xml)):
pass
async def test_rejects_text_outside_root(self):
"""Ensure parser surfaces readable errors for invalid root text."""
block = XMLParserBlock()
invalid_xml = "<root><child>value</child></root> trailing"
with pytest.raises(ValueError, match="text outside the root element"):
async for _ in block.run(XMLParserBlock.Input(input_xml=invalid_xml)):
pass
class TestStoreMediaFileSecurity:
"""Test file storage security limits."""

View File

@@ -1,5 +1,5 @@
from gravitasml.parser import Parser
from gravitasml.token import tokenize
from gravitasml.token import Token, tokenize
from backend.data.block import Block, BlockOutput, BlockSchemaInput, BlockSchemaOutput
from backend.data.model import SchemaField
@@ -25,6 +25,38 @@ class XMLParserBlock(Block):
],
)
@staticmethod
def _validate_tokens(tokens: list[Token]) -> None:
"""Ensure the XML has a single root element and no stray text."""
if not tokens:
raise ValueError("XML input is empty.")
depth = 0
root_seen = False
for token in tokens:
if token.type == "TAG_OPEN":
if depth == 0 and root_seen:
raise ValueError("XML must have a single root element.")
depth += 1
if depth == 1:
root_seen = True
elif token.type == "TAG_CLOSE":
depth -= 1
if depth < 0:
raise SyntaxError("Unexpected closing tag in XML input.")
elif token.type in {"TEXT", "ESCAPE"}:
if depth == 0 and token.value:
raise ValueError(
"XML contains text outside the root element; "
"wrap content in a single root tag."
)
if depth != 0:
raise SyntaxError("Unclosed tag detected in XML input.")
if not root_seen:
raise ValueError("XML must include a root element.")
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
# Security fix: Add size limits to prevent XML bomb attacks
MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB limit for XML input
@@ -35,7 +67,9 @@ class XMLParserBlock(Block):
)
try:
tokens = tokenize(input_data.input_xml)
tokens = list(tokenize(input_data.input_xml))
self._validate_tokens(tokens)
parser = Parser(tokens)
parsed_result = parser.parse()
yield "parsed_xml", parsed_result

View File

@@ -1924,14 +1924,14 @@ google = ["google-api-python-client (>=2.0.0)", "google-auth (>=2.0.0)"]
[[package]]
name = "gravitasml"
version = "0.1.3"
version = "0.1.4"
description = ""
optional = false
python-versions = "<4.0,>=3.10"
groups = ["main"]
files = [
{file = "gravitasml-0.1.3-py3-none-any.whl", hash = "sha256:51ff98b4564b7a61f7796f18d5f2558b919d30b3722579296089645b7bc18b85"},
{file = "gravitasml-0.1.3.tar.gz", hash = "sha256:04d240b9fa35878252d57a36032130b6516487468847fcdced1022c032a20f57"},
{file = "gravitasml-0.1.4-py3-none-any.whl", hash = "sha256:671a18b11d3d8a0e270c6a80c72cd058458b18d5ef7560d00010e962ab1bca74"},
{file = "gravitasml-0.1.4.tar.gz", hash = "sha256:35d0d9fec7431817482d53d9c976e375557c3e041d1eb6928e809324a8c866e3"},
]
[package.dependencies]
@@ -7295,4 +7295,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.14"
content-hash = "b762806d5d58fcf811220890c4705a16dc62b33387af43e3a29399c62a641098"
content-hash = "a93ba0cea3b465cb6ec3e3f258b383b09f84ea352ccfdbfa112902cde5653fc6"

View File

@@ -27,7 +27,7 @@ google-api-python-client = "^2.177.0"
google-auth-oauthlib = "^1.2.2"
google-cloud-storage = "^3.2.0"
googlemaps = "^4.10.0"
gravitasml = "^0.1.3"
gravitasml = "^0.1.4"
groq = "^0.30.0"
html2text = "^2024.2.26"
jinja2 = "^3.1.6"

View File

@@ -0,0 +1,146 @@
/**
* Cloudflare Workers Script for docs.agpt.co → agpt.co/docs migration
*
* Deploy this script to handle all redirects with a single JavaScript file.
* No rule limits, easy to maintain, handles all edge cases.
*/
// URL mapping for special cases that don't follow patterns
const SPECIAL_MAPPINGS = {
// Root page
'/': '/docs/platform',
// Special cases that don't follow standard patterns
'/platform/d_id/': '/docs/integrations/block-integrations/d-id',
'/platform/blocks/blocks/': '/docs/integrations',
'/platform/blocks/decoder_block/': '/docs/integrations/block-integrations/text-decoder',
'/platform/blocks/http': '/docs/integrations/block-integrations/send-web-request',
'/platform/blocks/llm/': '/docs/integrations/block-integrations/ai-and-llm',
'/platform/blocks/time_blocks': '/docs/integrations/block-integrations/time-and-date',
'/platform/blocks/text_to_speech_block': '/docs/integrations/block-integrations/text-to-speech',
'/platform/blocks/ai_shortform_video_block': '/docs/integrations/block-integrations/ai-shortform-video',
'/platform/blocks/replicate_flux_advanced': '/docs/integrations/block-integrations/replicate-flux-advanced',
'/platform/blocks/flux_kontext': '/docs/integrations/block-integrations/flux-kontext',
'/platform/blocks/ai_condition/': '/docs/integrations/block-integrations/ai-condition',
'/platform/blocks/email_block': '/docs/integrations/block-integrations/email',
'/platform/blocks/google_maps': '/docs/integrations/block-integrations/google-maps',
'/platform/blocks/google/gmail': '/docs/integrations/block-integrations/gmail',
'/platform/blocks/github/issues/': '/docs/integrations/block-integrations/github-issues',
'/platform/blocks/github/repo/': '/docs/integrations/block-integrations/github-repo',
'/platform/blocks/github/pull_requests': '/docs/integrations/block-integrations/github-pull-requests',
'/platform/blocks/twitter/twitter': '/docs/integrations/block-integrations/twitter',
'/classic/setup/': '/docs/classic/setup/setting-up-autogpt-classic',
'/code-of-conduct/': '/docs/classic/help-us-improve-autogpt/code-of-conduct',
'/contributing/': '/docs/classic/contributing',
'/contribute/': '/docs/contribute',
'/forge/components/introduction/': '/docs/classic/forge/introduction'
};
/**
* Transform path by replacing underscores with hyphens and removing trailing slashes
*/
function transformPath(path) {
return path.replace(/_/g, '-').replace(/\/$/, '');
}
/**
* Handle docs.agpt.co redirects
*/
function handleDocsRedirect(url) {
const pathname = url.pathname;
// Check special mappings first
if (SPECIAL_MAPPINGS[pathname]) {
return `https://agpt.co${SPECIAL_MAPPINGS[pathname]}`;
}
// Pattern-based redirects
// Platform blocks: /platform/blocks/* → /docs/integrations/block-integrations/*
if (pathname.startsWith('/platform/blocks/')) {
const blockName = pathname.substring('/platform/blocks/'.length);
const transformedName = transformPath(blockName);
return `https://agpt.co/docs/integrations/block-integrations/${transformedName}`;
}
// Platform contributing: /platform/contributing/* → /docs/platform/contributing/*
if (pathname.startsWith('/platform/contributing/')) {
const subPath = pathname.substring('/platform/contributing/'.length);
return `https://agpt.co/docs/platform/contributing/${subPath}`;
}
// Platform general: /platform/* → /docs/platform/* (with underscore→hyphen)
if (pathname.startsWith('/platform/')) {
const subPath = pathname.substring('/platform/'.length);
const transformedPath = transformPath(subPath);
return `https://agpt.co/docs/platform/${transformedPath}`;
}
// Forge components: /forge/components/* → /docs/classic/forge/introduction/*
if (pathname.startsWith('/forge/components/')) {
const subPath = pathname.substring('/forge/components/'.length);
return `https://agpt.co/docs/classic/forge/introduction/${subPath}`;
}
// Forge general: /forge/* → /docs/classic/forge/*
if (pathname.startsWith('/forge/')) {
const subPath = pathname.substring('/forge/'.length);
return `https://agpt.co/docs/classic/forge/${subPath}`;
}
// Classic: /classic/* → /docs/classic/*
if (pathname.startsWith('/classic/')) {
const subPath = pathname.substring('/classic/'.length);
return `https://agpt.co/docs/classic/${subPath}`;
}
// Default fallback
return 'https://agpt.co/docs/';
}
/**
* Main Worker function
*/
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Only handle docs.agpt.co requests
if (url.hostname === 'docs.agpt.co') {
const redirectUrl = handleDocsRedirect(url);
return new Response(null, {
status: 301,
headers: {
'Location': redirectUrl,
'Cache-Control': 'max-age=300' // Cache redirects for 5 minutes
}
});
}
// For non-docs requests, pass through or return 404
return new Response('Not Found', { status: 404 });
}
};
// Test function for local development
export function testRedirects() {
const testCases = [
'https://docs.agpt.co/',
'https://docs.agpt.co/platform/getting-started/',
'https://docs.agpt.co/platform/advanced_setup/',
'https://docs.agpt.co/platform/blocks/basic/',
'https://docs.agpt.co/platform/blocks/ai_condition/',
'https://docs.agpt.co/classic/setup/',
'https://docs.agpt.co/forge/components/agents/',
'https://docs.agpt.co/contributing/',
'https://docs.agpt.co/unknown-page'
];
console.log('Testing redirects:');
testCases.forEach(testUrl => {
const url = new URL(testUrl);
const result = handleDocsRedirect(url);
console.log(`${testUrl}${result}`);
});
}

View File

@@ -46,14 +46,15 @@
"@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/validator-ajv8": "5.24.13",
"@rjsf/core": "6.1.2",
"@rjsf/utils": "6.1.2",
"@rjsf/validator-ajv8": "6.1.2",
"@sentry/nextjs": "10.27.0",
"@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.78.0",

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -0,0 +1,11 @@
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);

View File

@@ -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";

View File

@@ -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,

View File

@@ -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 (
<AnimatePresence>
@@ -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())}
</Text>
</div>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<DraftRecoveryState>({
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,
};

View File

@@ -121,6 +121,14 @@ export const useFlow = () => {
if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]);
addNodes(customNodes);
// Sync hardcoded values with handle IDs.
// If a keyvalue 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]);

View File

@@ -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<CustomEdge>[]) => {
setEdges(applyEdgeChanges(changes, edges));
},
[removeEdge],
[edges, setEdges],
);
return { edges, onConnect, onEdgesChange };

View File

@@ -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 (
<Handle
type={side === "left" ? "target" : "source"}
position={side === "left" ? Position.Left : Position.Right}
id={handleId}
className={side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2"}
type={"target"}
position={Position.Left}
id={cleanedHandleId}
className={"-ml-6 mr-2"}
>
<div className="pointer-events-none">
<CircleIcon
size={16}
weight={isConnected ? "fill" : "duotone"}
weight={isInputConnected ? "fill" : "duotone"}
className={"text-gray-400 opacity-100"}
/>
</div>
@@ -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 (
<Handle
type={"source"}
position={Position.Right}
id={field_name}
className={"-mr-2 ml-2"}
>
<div className="pointer-events-none">
<CircleIcon
size={16}
weight={"duotone"}
color={isOutputConnected ? hexColor : "gray"}
className={cn("text-gray-400 opacity-100")}
/>
</div>
</Handle>
);
};
export { InputNodeHandle, OutputNodeHandle };

View File

@@ -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;
};

View File

@@ -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<NodeProps<CustomNode>> = 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}

View File

@@ -8,7 +8,7 @@ export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => {
);
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
return (
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white px-5 py-3.5">
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white px-5 py-3.5">
<Text variant="body" className="font-medium text-slate-700">
Advanced
</Text>

View File

@@ -22,7 +22,7 @@ export const NodeContainer = ({
return (
<div
className={cn(
"z-12 max-w-[370px] rounded-xlarge ring-1 ring-slate-200/60",
"z-12 w-[350px] rounded-xlarge ring-1 ring-slate-200/60",
selected && "shadow-lg ring-2 ring-slate-200",
status && nodeStyleBasedOnStatus[status],
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",

View File

@@ -23,7 +23,9 @@ export const NodeHeader = ({
const updateNodeData = useNodeStore((state) => 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 (
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-zinc-200 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
{/* Title row with context menu */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
@@ -68,12 +70,12 @@ export const NodeHeader = ({
<TooltipTrigger asChild>
<div>
<Text variant="large-semibold" className="line-clamp-1">
{beautifyString(title)}
{beautifyString(title).replace("Block", "").trim()}
</Text>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{beautifyString(title)}</p>
<p>{beautifyString(title).replace("Block", "").trim()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -23,7 +23,7 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
}
return (
<div className="flex flex-col gap-3 rounded-b-xl border-t border-slate-200/50 px-4 py-4">
<div className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4">
<div className="flex items-center justify-between">
<Text variant="body-medium" className="!font-semibold text-slate-700">
Node Output

View File

@@ -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";

View File

@@ -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(
({

View File

@@ -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 ? (
<div key={fullKey} className="flex flex-col items-end gap-2">
<div className="relative flex items-center gap-2">
{fieldSchema?.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
style={{ marginLeft: 6, cursor: "pointer" }}
aria-label="info"
tabIndex={0}
>
<InfoIcon />
</span>
</TooltipTrigger>
<TooltipContent>{fieldSchema?.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Text variant="body" className="text-slate-700">
{fieldTitle}
</Text>
<Text variant="small" as="span" className={colorClass}>
({displayType})
</Text>
{showHandles && (
<OutputNodeHandle
field_name={fullKey}
nodeId={nodeId}
hexColor={hexColor}
/>
)}
</div>
{/* Recursively render nested properties */}
{fieldSchema?.properties &&
renderOutputHandles(
fieldSchema.properties,
fullKey,
`${fieldTitle}.`,
)}
</div>
) : null;
},
);
};
return (
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white py-3.5">
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white py-3.5">
<Button
variant="ghost"
className="mr-4 h-fit min-w-0 p-0 hover:border-transparent hover:bg-transparent"
@@ -49,50 +113,9 @@ export const OutputHandler = ({
</Text>
</Button>
{
<div className="flex flex-col items-end gap-2">
{Object.entries(properties).map(([key, property]: [string, any]) => {
const isConnected = isOutputConnected(nodeId, key);
const shouldShow = isConnected || isOutputVisible;
const { displayType, colorClass } = getTypeDisplayInfo(property);
return shouldShow ? (
<div key={key} className="relative flex items-center gap-2">
{property?.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
style={{ marginLeft: 6, cursor: "pointer" }}
aria-label="info"
tabIndex={0}
>
<InfoIcon />
</span>
</TooltipTrigger>
<TooltipContent>{property?.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Text variant="body" className="text-slate-700">
{property?.title || key}{" "}
</Text>
<Text variant="small" as="span" className={colorClass}>
({displayType})
</Text>
<NodeHandle
handleId={
uiType === BlockUIType.AGENT ? key : generateHandleId(key)
}
isConnected={isConnected}
side="right"
/>
</div>
) : null;
})}
</div>
}
<div className="flex flex-col items-end gap-2">
{renderOutputHandles(properties)}
</div>
</div>
);
};

View File

@@ -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, string> = {
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,
};
};

View File

@@ -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<EdgeStore>((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<EdgeStore>((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<string, NodeExecutionResult["status"]>();
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<EdgeStore>((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<EdgeStore>((set, get) => ({
beadData,
},
};
}),
}));
});
return hasChanges ? { edges: newEdges } : state;
});
},
resetEdgeBeads: () => {

View File

@@ -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<NodeStore>((set, get) => ({
@@ -305,4 +311,35 @@ export const useNodeStore = create<NodeStore>((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,
),
}));
}
},
}));

View File

@@ -143,6 +143,7 @@ export function CredentialsInput({
size="small"
onClick={handleActionButtonClick}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
@@ -155,6 +156,7 @@ export function CredentialsInput({
size="small"
onClick={handleActionButtonClick}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>

View File

@@ -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";

View File

@@ -49,6 +49,7 @@ export function GoogleDrivePicker(props: Props) {
)}
<Button
size="small"
type="button"
onClick={handleOpenPicker}
disabled={props.disabled || isLoading || isAuthInProgress}
>

View File

@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
import React, { useCallback } from "react";
import { GoogleDrivePicker } from "./GoogleDrivePicker";
import { isValidFile } from "./helpers";
export interface Props {
config: GoogleDrivePickerConfig;
@@ -27,13 +28,15 @@ export function GoogleDrivePickerInput({
const hasAutoCredentials = !!config.auto_credentials;
// Strip _credentials_id from value for display purposes
const currentFiles = isMultiSelect
? Array.isArray(value)
? value
: []
: value
? [value]
: [];
// Only show files section when there are valid file objects
const currentFiles = React.useMemo(() => {
if (isMultiSelect) {
if (!Array.isArray(value)) return [];
return value.filter(isValidFile);
}
if (!value || !isValidFile(value)) return [];
return [value];
}, [value, isMultiSelect]);
const handlePicked = useCallback(
(files: any[], credentialId?: string) => {
@@ -85,23 +88,27 @@ export function GoogleDrivePickerInput({
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Picker Button */}
<GoogleDrivePicker
multiselect={config.multiselect || false}
views={config.allowed_views || ["DOCS"]}
scopes={config.scopes || ["https://www.googleapis.com/auth/drive.file"]}
disabled={false}
requirePlatformCredentials={hasAutoCredentials}
onPicked={handlePicked}
onCanceled={() => {
// User canceled - no action needed
}}
onError={handleError}
/>
<div className="mb-4">
{/* Picker Button */}
<GoogleDrivePicker
multiselect={config.multiselect || false}
views={config.allowed_views || ["DOCS"]}
scopes={
config.scopes || ["https://www.googleapis.com/auth/drive.file"]
}
disabled={false}
requirePlatformCredentials={hasAutoCredentials}
onPicked={handlePicked}
onCanceled={() => {
// User canceled - no action needed
}}
onError={handleError}
/>
</div>
{/* Display Selected Files */}
{currentFiles.length > 0 && (
<div className="space-y-1">
<div className="mb-8 space-y-1">
{currentFiles.map((file: any, idx: number) => (
<div
key={file.id || idx}

View File

@@ -119,3 +119,14 @@ export function getCredentialsSchema(scopes: string[]) {
secret: true,
} satisfies BlockIOCredentialsSubSchema;
}
export function isValidFile(
file: unknown,
): file is { id?: string; name?: string } {
return (
typeof file === "object" &&
file !== null &&
(typeof (file as { id?: unknown }).id === "string" ||
typeof (file as { name?: unknown }).name === "string")
);
}

View File

@@ -1,26 +1,16 @@
import { BlockUIType } from "@/app/(platform)/build/components/types";
import Form from "@rjsf/core";
import { RJSFSchema } from "@rjsf/utils";
import { fields } from "./fields";
import { templates } from "./templates";
import { widgets } from "./widgets";
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
import { useMemo } from "react";
import { customValidator } from "./utils/custom-validator";
type FormContextType = {
nodeId?: string;
uiType?: BlockUIType;
showHandles?: boolean;
size?: "small" | "medium" | "large";
};
import Form from "./registry";
import { ExtendedFormContextType } from "./types";
type FormRendererProps = {
jsonSchema: RJSFSchema;
handleChange: (formData: any) => void;
uiSchema: any;
initialValues: any;
formContext: FormContextType;
formContext: ExtendedFormContextType;
};
export const FormRenderer = ({
@@ -33,19 +23,18 @@ export const FormRenderer = ({
const preprocessedSchema = useMemo(() => {
return preprocessInputSchema(jsonSchema);
}, [jsonSchema]);
return (
<div className={"mt-4"}>
<div className={"mb-6 mt-4"}>
<Form
formContext={formContext}
idPrefix="agpt"
idSeparator="_%_"
schema={preprocessedSchema}
validator={customValidator}
fields={fields}
templates={templates}
widgets={widgets}
formContext={formContext}
onChange={handleChange}
uiSchema={uiSchema}
formData={initialValues}
noValidate={true}
liveValidate={false}
/>
</div>

View File

@@ -0,0 +1,86 @@
import { FieldProps, getUiOptions, getWidget } from "@rjsf/utils";
import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle";
import { isEmpty } from "lodash";
import { useAnyOfField } from "./useAnyOfField";
import { getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants";
export const AnyOfField = (props: FieldProps) => {
const { registry, schema } = props;
const { fields } = registry;
const { SchemaField: _SchemaField } = fields;
const { nodeId } = registry.formContext;
const { isInputConnected } = useEdgeStore();
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
const {
handleOptionChange,
enumOptions,
selectedOption,
optionSchema,
field_id,
} = useAnyOfField(props);
const handleId = getHandleId({
uiOptions,
id: field_id + ANY_OF_FLAG,
schema: schema,
});
const updatedUiSchema = updateUiOption(props.uiSchema, {
handleId: handleId,
label: false,
fromAnyOf: true,
});
const isHandleConnected = isInputConnected(nodeId, handleId);
const optionsSchemaField =
(optionSchema && optionSchema.type !== "null" && (
<_SchemaField
{...props}
schema={optionSchema}
uiSchema={updatedUiSchema}
/>
)) ||
null;
const selector = (
<Widget
id={field_id}
name={`${props.name}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`}
schema={{ type: "number", default: 0 }}
onChange={handleOptionChange}
onBlur={props.onBlur}
onFocus={props.onFocus}
disabled={props.disabled || isEmpty(enumOptions)}
multiple={false}
value={selectedOption >= 0 ? selectedOption : undefined}
options={{ enumOptions }}
registry={registry}
placeholder={props.placeholder}
autocomplete={props.autocomplete}
className="-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium"
autofocus={props.autofocus}
label=""
hideLabel={true}
readonly={props.readonly}
/>
);
return (
<div>
<AnyOfFieldTitle
{...props}
selector={selector}
uiSchema={updatedUiSchema}
/>
{!isHandleConnected && optionsSchemaField}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import {
descriptionId,
FieldProps,
getTemplate,
getUiOptions,
titleId,
} from "@rjsf/utils";
import { shouldShowTypeSelector } from "../helpers";
import { useIsArrayItem } from "../../array/context/array-item-context";
import { cleanUpHandleId } from "../../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { Text } from "@/components/atoms/Text/Text";
import { isOptionalType } from "../../../utils/schema-utils";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { cn } from "@/lib/utils";
interface customFieldProps extends FieldProps {
selector: JSX.Element;
}
export const AnyOfFieldTitle = (props: customFieldProps) => {
const { uiSchema, schema, required, name, registry, fieldPathId, selector } =
props;
const { isInputConnected } = useEdgeStore();
const { nodeId } = registry.formContext;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
uiOptions,
);
const title_id = titleId(fieldPathId ?? "");
const description_id = descriptionId(fieldPathId ?? "");
const isArrayItem = useIsArrayItem();
const handleId = cleanUpHandleId(uiOptions.handleId);
const isHandleConnected = isInputConnected(nodeId, handleId);
const { isOptional, type } = isOptionalType(schema); // If we have something like int | null = we will treat it as optional int
const { displayType, colorClass } = getTypeDisplayInfo(type);
const shouldShowSelector =
shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected;
const shoudlShowType = isHandleConnected || (isOptional && type);
return (
<div className="flex items-center gap-2">
<TitleFieldTemplate
id={title_id}
title={schema.title || name || ""}
required={required}
schema={schema}
registry={registry}
uiSchema={uiSchema}
/>
{shoudlShowType && (
<Text variant="small" className={cn("text-zinc-700", colorClass)}>
{isOptional ? `(${displayType})` : "(any)"}
</Text>
)}
{shouldShowSelector && selector}
<DescriptionFieldTemplate
id={description_id}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import { RJSFSchema, StrictRJSFSchema } from "@rjsf/utils";
const TYPE_PRIORITY = [
"string",
"number",
"integer",
"boolean",
"array",
"object",
] as const;
export function getDefaultTypeIndex(options: StrictRJSFSchema[]): number {
for (const preferredType of TYPE_PRIORITY) {
const index = options.findIndex((opt) => opt.type === preferredType);
if (index >= 0) return index;
}
const nonNullIndex = options.findIndex((opt) => opt.type !== "null");
return nonNullIndex >= 0 ? nonNullIndex : 0;
}
/**
* Determines if a type selector should be shown for an anyOf schema
* Returns false for simple optional types (type | null)
* Returns true for complex anyOf (3+ types or multiple non-null types)
*/
export function shouldShowTypeSelector(
schema: RJSFSchema | undefined,
): boolean {
const anyOf = schema?.anyOf;
if (!anyOf || !Array.isArray(anyOf) || anyOf.length === 0) {
return false;
}
if (anyOf.length === 2 && anyOf.some((opt: any) => opt.type === "null")) {
return false;
}
return anyOf.length >= 3;
}
export function isSimpleOptional(schema: RJSFSchema | undefined): boolean {
const anyOf = schema?.anyOf;
return (
Array.isArray(anyOf) &&
anyOf.length === 2 &&
anyOf.some((opt: any) => opt.type === "null")
);
}
export function getOptionalType(
schema: RJSFSchema | undefined,
): string | undefined {
if (!isSimpleOptional(schema)) {
return undefined;
}
const anyOf = schema?.anyOf;
const nonNullOption = anyOf?.find((opt: any) => opt.type !== "null");
return nonNullOption ? (nonNullOption as any).type : undefined;
}

View File

@@ -0,0 +1,96 @@
import { FieldProps, getFirstMatchingOption, mergeSchemas } from "@rjsf/utils";
import { useRef, useState } from "react";
import validator from "@rjsf/validator-ajv8";
import { getDefaultTypeIndex } from "./helpers";
import { cleanUpHandleId } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
export const useAnyOfField = (props: FieldProps) => {
const { registry, schema, options, onChange, formData } = props;
const { schemaUtils } = registry;
const getInitialOption = () => {
if (formData !== undefined && formData !== null) {
const option = getFirstMatchingOption(
validator,
formData,
options,
schema,
);
return option !== undefined ? option : getDefaultTypeIndex(options);
}
return getDefaultTypeIndex(options);
};
const [selectedOption, setSelectedOption] =
useState<number>(getInitialOption());
const retrievedOptions = useRef<any[]>(
options.map((opt: any) => schemaUtils.retrieveSchema(opt, formData)),
);
const option =
selectedOption >= 0
? retrievedOptions.current[selectedOption] || null
: null;
let optionSchema: any | undefined | null;
// adding top level required to each option schema
if (option) {
const { required } = schema;
optionSchema = required
? (mergeSchemas({ required }, option) as any)
: option;
}
const field_id = props.fieldPathId.$id;
const handleOptionChange = (option?: string) => {
const intOption = option !== undefined ? parseInt(option, 10) : -1;
if (intOption === selectedOption) return;
const newOption =
intOption >= 0 ? retrievedOptions.current[intOption] : undefined;
const oldOption =
selectedOption >= 0
? retrievedOptions.current[selectedOption]
: undefined;
// When we change the option, we need to clean the form data
let newFormData = schemaUtils.sanitizeDataForNewSchema(
newOption,
oldOption,
formData,
);
const handlePrefix = cleanUpHandleId(field_id);
console.log("handlePrefix", handlePrefix);
useEdgeStore
.getState()
.removeEdgesByHandlePrefix(registry.formContext.nodeId, handlePrefix);
// We have cleaned the form data, now we need to get the default form state of new selected option
if (newOption) {
newFormData = schemaUtils.getDefaultFormState(
newOption,
newFormData,
"excludeObjectChildren",
) as any;
}
setSelectedOption(intOption);
onChange(newFormData, props.fieldPathId.path, undefined, field_id);
};
const enumOptions = retrievedOptions.current.map((option, index) => ({
value: index,
label: option.type,
}));
return {
handleOptionChange,
enumOptions,
selectedOption,
optionSchema,
field_id,
};
};

View File

@@ -0,0 +1,34 @@
import {
ArrayFieldItemTemplateProps,
getTemplate,
getUiOptions,
} from "@rjsf/utils";
export default function ArrayFieldItemTemplate(
props: ArrayFieldItemTemplateProps,
) {
const { children, buttonsProps, hasToolbar, uiSchema, registry } = props;
const uiOptions = getUiOptions(uiSchema);
const ArrayFieldItemButtonsTemplate = getTemplate(
"ArrayFieldItemButtonsTemplate",
registry,
uiOptions,
);
return (
<div>
<div className="mb-2 flex flex-row flex-wrap items-center">
<div className="shrink grow">
<div className="shrink grow">{children}</div>
</div>
<div className="flex items-end justify-end">
{hasToolbar && (
<div className="-mt-4 mb-2 flex gap-2">
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import {
ArrayFieldTemplateProps,
buttonId,
getTemplate,
getUiOptions,
} from "@rjsf/utils";
import { getHandleId, updateUiOption } from "../../helpers";
export default function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const {
canAdd,
disabled,
fieldPathId,
uiSchema,
items,
optionalDataControl,
onAddClick,
readonly,
registry,
required,
schema,
title,
} = props;
const uiOptions = getUiOptions(uiSchema);
const ArrayFieldDescriptionTemplate = getTemplate(
"ArrayFieldDescriptionTemplate",
registry,
uiOptions,
);
const ArrayFieldTitleTemplate = getTemplate(
"ArrayFieldTitleTemplate",
registry,
uiOptions,
);
const showOptionalDataControlInTitle = !readonly && !disabled;
const {
ButtonTemplates: { AddButton },
} = registry.templates;
const { fromAnyOf } = uiOptions;
const handleId = getHandleId({
uiOptions,
id: fieldPathId.$id,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
return (
<div>
<div className="m-0 flex p-0">
<div className="m-0 w-full space-y-4 p-0">
{!fromAnyOf && (
<div className="flex items-center">
<ArrayFieldTitleTemplate
fieldPathId={fieldPathId}
title={uiOptions.title || title}
schema={schema}
uiSchema={updatedUiSchema}
required={required}
registry={registry}
optionalDataControl={
showOptionalDataControlInTitle
? optionalDataControl
: undefined
}
/>
<ArrayFieldDescriptionTemplate
fieldPathId={fieldPathId}
description={uiOptions.description || schema.description}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
</div>
)}
<div
key={`array-item-list-${fieldPathId.$id}`}
className="m-0 mb-2 w-full p-0"
>
{!showOptionalDataControlInTitle ? optionalDataControl : undefined}
{items}
{canAdd && (
<div className="mt-4 flex justify-end">
<AddButton
id={buttonId(fieldPathId, "add")}
className="rjsf-array-item-add"
onClick={onAddClick}
disabled={disabled || readonly}
uiSchema={updatedUiSchema}
registry={registry}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { FieldProps, getUiOptions } from "@rjsf/utils";
import { getHandleId, updateUiOption } from "../../helpers";
import { ARRAY_ITEM_FLAG } from "../../constants";
const ArraySchemaField = (props: FieldProps) => {
const { index, registry, fieldPathId } = props;
const { SchemaField } = registry.fields;
const uiOptions = getUiOptions(props.uiSchema);
const handleId = getHandleId({
uiOptions,
id: fieldPathId.$id,
schema: props.schema,
});
const updatedUiSchema = updateUiOption(props.uiSchema, {
handleId: handleId + ARRAY_ITEM_FLAG,
});
return (
<SchemaField
{...props}
uiSchema={updatedUiSchema}
title={"_item-" + index.toString()}
/>
);
};
export default ArraySchemaField;

View File

@@ -0,0 +1,33 @@
import React, { createContext, useContext } from "react";
interface ArrayItemContextValue {
isArrayItem: boolean;
arrayItemHandleId: string;
}
const ArrayItemContext = createContext<ArrayItemContextValue>({
isArrayItem: false,
arrayItemHandleId: "",
});
export const ArrayItemProvider: React.FC<{
children: React.ReactNode;
arrayItemHandleId: string;
}> = ({ children, arrayItemHandleId }) => {
return (
<ArrayItemContext.Provider value={{ isArrayItem: true, arrayItemHandleId }}>
{children}
</ArrayItemContext.Provider>
);
};
export const useIsArrayItem = (): boolean => {
// here this will be true if field is inside an array
const context = useContext(ArrayItemContext);
return context.isArrayItem;
};
export const useArrayItemHandleId = (): string => {
const context = useContext(ArrayItemContext);
return context.arrayItemHandleId;
};

View File

@@ -0,0 +1,3 @@
export const generateArrayItemHandleId = (id: string) => {
return `array-item-${id}`;
};

View File

@@ -0,0 +1,7 @@
export { default as ArrayFieldTemplate } from "./ArrayFieldTemplate";
export { default as ArrayFieldItemTemplate } from "./ArrayFieldItemTemplate";
export { default as ArraySchemaField } from "./ArraySchemaField";
export {
ArrayItemProvider,
useIsArrayItem,
} from "./context/array-item-context";

View File

@@ -0,0 +1,71 @@
import {
RegistryFieldsType,
RegistryWidgetsType,
TemplatesType,
} from "@rjsf/utils";
import { AnyOfField } from "./anyof/AnyOfField";
import {
ArrayFieldItemTemplate,
ArrayFieldTemplate,
ArraySchemaField,
} from "./array";
import {
ObjectFieldTemplate,
OptionalDataControlsTemplate,
WrapIfAdditionalTemplate,
} from "./object";
import { DescriptionField, FieldTemplate, TitleField } from "./standard";
import { AddButton, CopyButton, RemoveButton } from "./standard/buttons";
import {
CheckboxWidget,
DateTimeWidget,
DateWidget,
FileWidget,
GoogleDrivePickerWidget,
SelectWidget,
TextWidget,
TimeWidget,
} from "./standard/widgets";
const NoButton = () => null;
export function generateBaseFields(): RegistryFieldsType {
return {
AnyOfField,
ArraySchemaField,
};
}
export function generateBaseTemplates(): Partial<TemplatesType> {
return {
ArrayFieldItemTemplate,
ArrayFieldTemplate,
ButtonTemplates: {
AddButton,
CopyButton,
MoveDownButton: NoButton,
MoveUpButton: NoButton,
RemoveButton,
SubmitButton: NoButton,
},
DescriptionFieldTemplate: DescriptionField,
FieldTemplate,
ObjectFieldTemplate,
OptionalDataControlsTemplate,
TitleFieldTemplate: TitleField,
WrapIfAdditionalTemplate,
};
}
export function generateBaseWidgets(): RegistryWidgetsType {
return {
TextWidget,
SelectWidget,
CheckboxWidget,
FileWidget,
DateWidget,
TimeWidget,
DateTimeWidget,
GoogleDrivePickerWidget,
};
}

View File

@@ -0,0 +1,5 @@
export * from "./array";
export * from "./object";
export * from "./standard";
export * from "./standard/widgets";
export * from "./standard/buttons";

View File

@@ -0,0 +1,122 @@
import {
ADDITIONAL_PROPERTY_FLAG,
buttonId,
canExpand,
descriptionId,
getTemplate,
getUiOptions,
ObjectFieldTemplateProps,
titleId,
} from "@rjsf/utils";
import { getHandleId, updateUiOption } from "../../helpers";
import React from "react";
export default function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const {
description,
title,
properties,
required,
uiSchema,
fieldPathId,
schema,
formData,
optionalDataControl,
onAddProperty,
disabled,
readonly,
registry,
} = props;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
uiOptions,
);
const showOptionalDataControlInTitle = !readonly && !disabled;
const {
ButtonTemplates: { AddButton },
} = registry.templates;
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const handleId = getHandleId({
uiOptions,
id: fieldPathId.$id,
schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
return (
<>
<div className="flex items-center gap-2">
{title && !additional && (
<TitleFieldTemplate
id={titleId(fieldPathId)}
title={title}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
optionalDataControl={true ? optionalDataControl : undefined}
/>
)}
{description && (
<DescriptionFieldTemplate
id={descriptionId(fieldPathId)}
description={description}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
)}
</div>
<div className="flex flex-col">
{!showOptionalDataControlInTitle ? optionalDataControl : undefined}
{/* I have cloned it - so i could pass updated uiSchema to the nested children */}
{properties.map((element: any, index: number) => {
const clonedContent = React.cloneElement(element.content, {
...element.content.props,
uiSchema: updateUiOption(element.content.props.uiSchema, {
handleId: handleId,
}),
});
return (
<div
key={index}
className={`${element.hidden ? "hidden" : ""} flex`}
>
<div className="w-full">{clonedContent}</div>
</div>
);
})}
{canExpand(schema, uiSchema, formData) ? (
<div className="mt-2 flex justify-end">
<AddButton
id={buttonId(fieldPathId, "add")}
onClick={onAddProperty}
disabled={disabled || readonly}
className="rjsf-object-property-expand"
uiSchema={updatedUiSchema}
registry={registry}
/>
</div>
) : null}
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { OptionalDataControlsTemplateProps } from "@rjsf/utils";
import { PlusCircle } from "lucide-react";
import { IconButton, RemoveButton } from "../standard/buttons";
export default function OptionalDataControlsTemplate(
props: OptionalDataControlsTemplateProps,
) {
const { id, registry, label, onAddClick, onRemoveClick } = props;
if (onAddClick) {
return (
<IconButton
id={id}
registry={registry}
className="rjsf-add-optional-data"
onClick={onAddClick}
title={label}
icon={<PlusCircle />}
size="small"
/>
);
} else if (onRemoveClick) {
return (
<RemoveButton
id={id}
registry={registry}
className="rjsf-remove-optional-data"
onClick={onRemoveClick}
title={label}
size="small"
/>
);
}
return <em id={id}>{label}</em>;
}

View File

@@ -0,0 +1,114 @@
import {
ADDITIONAL_PROPERTY_FLAG,
buttonId,
getTemplate,
getUiOptions,
titleId,
WrapIfAdditionalTemplateProps,
} from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
export default function WrapIfAdditionalTemplate(
props: WrapIfAdditionalTemplateProps,
) {
const {
classNames,
style,
children,
disabled,
id,
label,
onRemoveProperty,
onKeyRenameBlur,
readonly,
required,
schema,
uiSchema,
registry,
} = props;
const { templates, formContext } = registry;
const uiOptions = getUiOptions(uiSchema);
// Button templates are not overridden in the uiSchema
const { RemoveButton } = templates.ButtonTemplates;
const { isInputConnected } = useEdgeStore();
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const { nodeId } = formContext;
const handleId = uiOptions.handleId;
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
if (!additional) {
return (
<div className={classNames} style={style}>
{children}
</div>
);
}
const keyId = `${id}-key`;
const generateObjectPropertyTitleId = (id: string, label: string) => {
return id.replace(`_${label}`, `_#_${label}`);
};
const title_id = generateObjectPropertyTitleId(id, label);
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (e.target.value == "") {
onRemoveProperty();
} else {
onKeyRenameBlur(e);
}
};
const isHandleConnected = isInputConnected(nodeId, handleId);
return (
<>
<div className={`mb-4 flex flex-col gap-1`} style={style}>
<TitleFieldTemplate
id={titleId(title_id)}
title={`#${label}`}
required={required}
schema={schema}
registry={registry}
uiSchema={uiSchema}
/>
{!isHandleConnected && (
<div className="flex flex-1 items-center gap-2">
<Input
label={""}
hideLabel={true}
required={required}
defaultValue={label}
disabled={disabled || readonly}
id={keyId}
wrapperClassName="mb-2 w-30"
name={keyId}
onBlur={!readonly ? handleBlur : undefined}
type="text"
size="small"
/>
<div className="mt-2"> {children}</div>
</div>
)}
{!isHandleConnected && (
<div className="-mt-4">
<RemoveButton
id={buttonId(id, "remove")}
disabled={disabled || readonly}
onClick={onRemoveProperty}
uiSchema={uiSchema}
registry={registry}
/>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,3 @@
export { default as ObjectFieldTemplate } from "./ObjectFieldTemplate";
export { default as WrapIfAdditionalTemplate } from "./WrapIfAdditionalTemplate";
export { default as OptionalDataControlsTemplate } from "./OptionalDataControlsTemplate";

View File

@@ -0,0 +1,32 @@
import { DescriptionFieldProps } from "@rjsf/utils";
import { RichDescription } from "@rjsf/core";
import { InfoIcon } from "@phosphor-icons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
export default function DescriptionField(props: DescriptionFieldProps) {
const { id, description, registry, uiSchema } = props;
if (!description) {
return null;
}
return (
<div id={id} className="0 inline w-fit">
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon size={16} className="cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<RichDescription
description={description}
registry={registry}
uiSchema={uiSchema}
/>
</TooltipContent>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { Text } from "@/components/atoms/Text/Text";
export const FieldError = ({
nodeId,
fieldId,
}: {
nodeId: string;
fieldId: string;
}) => {
const nodeErrors = useNodeStore((state) => {
const node = state.nodes.find((n) => n.id === nodeId);
return node?.data?.errors;
});
const fieldError =
nodeErrors?.[fieldId] || nodeErrors?.[fieldId.replace(/_%_/g, ".")] || null;
return (
<div>
{fieldError && (
<Text variant="small" className="mt-1 pl-4 !text-red-600">
{fieldError}
</Text>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import {
ADDITIONAL_PROPERTY_FLAG,
FieldTemplateProps,
getTemplate,
getUiOptions,
titleId,
} from "@rjsf/utils";
import { isAnyOfChild, isAnyOfSchema } from "../../utils/schema-utils";
import {
cleanUpHandleId,
getHandleId,
isPartOfAnyOf,
updateUiOption,
} from "../../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { FieldError } from "./FieldError";
export default function FieldTemplate(props: FieldTemplateProps) {
const {
id,
children,
displayLabel,
description,
rawDescription,
label,
hidden,
required,
schema,
uiSchema,
registry,
classNames,
style,
disabled,
onKeyRename,
onKeyRenameBlur,
onRemoveProperty,
readonly,
} = props;
const { nodeId } = registry.formContext;
const { isInputConnected } = useEdgeStore();
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[registry.formContext.nodeId ?? ""],
);
if (hidden) {
return <div className="hidden">{children}</div>;
}
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate",
registry,
uiOptions,
);
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const handleId = getHandleId({
uiOptions,
id: id,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
const isHandleConnected = isInputConnected(nodeId, cleanUpHandleId(handleId));
const shouldDisplayLabel =
displayLabel ||
(schema.type === "boolean" && !isAnyOfChild(uiSchema as any));
const shouldShowTitleSection = !isAnyOfSchema(schema) && !additional;
const shouldShowChildren = isAnyOfSchema(schema) || !isHandleConnected;
const isAdvancedField = (schema as any).advanced === true;
if (!showAdvanced && isAdvancedField && !isHandleConnected) {
return null;
}
const marginBottom =
isPartOfAnyOf({ uiOptions }) || isAnyOfSchema(schema) ? 0 : 16;
return (
<WrapIfAdditionalTemplate
classNames={classNames}
style={style}
disabled={disabled}
id={id}
label={label}
displayLabel={displayLabel}
onKeyRename={onKeyRename}
onKeyRenameBlur={onKeyRenameBlur}
onRemoveProperty={onRemoveProperty}
rawDescription={rawDescription}
readonly={readonly}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
>
<div className="flex flex-col gap-2" style={{ marginBottom }}>
{shouldShowTitleSection && (
<div className="flex items-center gap-2">
{shouldDisplayLabel && (
<TitleFieldTemplate
id={titleId(id)}
title={label}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
)}
{shouldDisplayLabel && rawDescription && <span>{description}</span>}
</div>
)}
{shouldShowChildren && children}
<FieldError nodeId={nodeId} fieldId={cleanUpHandleId(id)} />
</div>
</WrapIfAdditionalTemplate>
);
}

View File

@@ -0,0 +1,55 @@
import {
ADDITIONAL_PROPERTY_FLAG,
descriptionId,
getUiOptions,
TitleFieldProps,
} from "@rjsf/utils";
import { Text } from "@/components/atoms/Text/Text";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { isAnyOfSchema } from "../../utils/schema-utils";
import { cn } from "@/lib/utils";
import { isArrayItem } from "../../helpers";
import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
export default function TitleField(props: TitleFieldProps) {
const { id, title, required, schema, registry, uiSchema } = props;
const { nodeId, showHandles } = registry.formContext;
const uiOptions = getUiOptions(uiSchema);
const isAnyOf = isAnyOfSchema(schema);
const { displayType, colorClass } = getTypeDisplayInfo(schema);
const description_id = descriptionId(id);
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const isArrayItemFlag = isArrayItem({ uiOptions });
const smallText = isArrayItemFlag || additional;
const showHandle = uiOptions.showHandles ?? showHandles;
return (
<div className="flex items-center">
{showHandle !== false && (
<InputNodeHandle handleId={uiOptions.handleId} nodeId={nodeId} />
)}
<Text
variant={isArrayItemFlag ? "small" : "body"}
id={id}
className={cn("line-clamp-1", smallText && "text-sm text-zinc-700")}
>
{title}
</Text>
<Text variant="small" className={"mr-1 text-red-500"}>
{required ? "*" : null}
</Text>
{!isAnyOf && (
<Text
variant="small"
className={cn("ml-2", colorClass)}
id={description_id}
>
({displayType})
</Text>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { IconButtonProps, TranslatableString } from "@rjsf/utils";
import { cn } from "@/lib/utils";
import { Button } from "@/components/atoms/Button/Button";
import { PlusIcon } from "@phosphor-icons/react";
export default function AddButton({
registry,
className,
uiSchema: _uiSchema,
...props
}: IconButtonProps) {
const { translateString } = registry;
return (
<div className="m-0 w-full p-0">
<Button
{...props}
size="small"
className={cn("w-full gap-4", className)}
variant="secondary"
type="button"
>
<PlusIcon size={16} weight="bold" />
{translateString(TranslatableString.AddItemButton)}
</Button>
</div>
);
}

View File

@@ -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<T, S, F> & VariantProps<typeof extendedButtonVariants>;
export default function IconButton(props: AutogptIconButtonProps) {
const {
icon,
className,
uiSchema: _uiSchema,
registry: _registry,
iconType: _iconType,
...otherProps
} = props;
return (
<Button
size="icon"
variant="secondary"
className={cn(className, "w-fit border border-zinc-200 p-1.5 px-4")}
{...otherProps}
type="button"
>
{icon}
<Text variant="body" className="ml-2">
{" "}
Remove Item{" "}
</Text>
</Button>
);
}
export function CopyButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.CopyButton)}
{...props}
icon={<Copy className="h-4 w-4" />}
/>
);
}
export function MoveDownButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.MoveDownButton)}
{...props}
icon={<ChevronDown className="h-4 w-4" />}
/>
);
}
export function MoveUpButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.MoveUpButton)}
{...props}
icon={<ChevronUp className="h-4 w-4" />}
/>
);
}
export function RemoveButton(props: AutogptIconButtonProps) {
const {
registry: { translateString },
} = props;
return (
<IconButton
title={translateString(TranslatableString.RemoveButton)}
{...props}
className={"border-destructive"}
icon={<TrashIcon size={16} className="!text-zinc-800" />}
/>
);
}

View File

@@ -0,0 +1,8 @@
export { default as AddButton } from "./AddButton";
export {
default as IconButton,
CopyButton,
RemoveButton,
MoveUpButton,
MoveDownButton,
} from "./IconButton";

View File

@@ -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 (
<Alert variant="error" className="mb-2">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{translateString(TranslatableString.ErrorsLabel)}</AlertTitle>
<AlertDescription className="flex flex-col gap-1">
{errors.map((error, i: number) => {
return <span key={i}>&#x2022; {error.stack}</span>;
})}
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1 @@
export { default as ErrorList } from "./ErrorList";

View File

@@ -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);
}

View File

@@ -0,0 +1,3 @@
export { default as FieldTemplate } from "./FieldTemplate";
export { default as TitleField } from "./TitleField";
export { default as DescriptionField } from "./DescriptionField";

View File

@@ -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 (
<Switch
id={id}

View File

@@ -0,0 +1 @@
export { CheckboxWidget } from "./CheckBoxWidget";

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { WidgetProps } from "@rjsf/utils";
import { DateInput } from "@/components/atoms/DateInput/DateInput";
export const DateInputWidget = (props: WidgetProps) => {
export const DateWidget = (props: WidgetProps) => {
const {
value,
onChange,

View File

@@ -0,0 +1 @@
export { DateWidget } from "./DateWidget";

View File

@@ -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,

View File

@@ -0,0 +1 @@
export { DateTimeWidget } from "./DateTimeWidget";

View File

@@ -0,0 +1 @@
export { FileWidget } from "./FileWidget";

View File

@@ -0,0 +1,55 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import { getFieldErrorKey } from "@/components/renderers/InputRenderer/utils/helpers";
import type { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { WidgetProps } from "@rjsf/utils";
function hasGoogleDrivePickerConfig(
schema: unknown,
): schema is { google_drive_picker_config?: GoogleDrivePickerConfig } {
return (
typeof schema === "object" &&
schema !== null &&
"google_drive_picker_config" in schema
);
}
export function GoogleDrivePickerWidget(props: WidgetProps) {
const { onChange, disabled, readonly, value, schema, id, formContext } =
props;
const { nodeId } = formContext || {};
const nodeErrors = useNodeStore((state) => {
const node = state.nodes.find((n) => n.id === nodeId);
return node?.data?.errors;
});
const fieldErrorKey = getFieldErrorKey(id ?? "");
const fieldError =
nodeErrors?.[fieldErrorKey] ||
nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] ||
nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] ||
undefined;
const config: GoogleDrivePickerConfig = hasGoogleDrivePickerConfig(schema)
? schema.google_drive_picker_config || {}
: {};
function handleChange(newValue: unknown) {
onChange(newValue);
}
return (
<GoogleDrivePickerInput
config={config}
value={value}
onChange={handleChange}
error={fieldError}
className={cn(
disabled || readonly ? "pointer-events-none opacity-50" : undefined,
)}
showRemoveButton={true}
/>
);
}

View File

@@ -0,0 +1 @@
export { GoogleDrivePickerWidget } from "./GoogleDrivePicketWidget";

View File

@@ -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) => {
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{enumOptions?.map((option) => (
{enumOptions?.map((option: any) => (
<MultiSelectorItem key={option.value} value={option.value}>
{option.label}
</MultiSelectorItem>
@@ -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}
/>
);
};

View File

@@ -0,0 +1 @@
export { SelectWidget } from "./SelectWidget";

View File

@@ -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}
/>
</>
);
};
}

View File

@@ -0,0 +1,2 @@
export { default } from "./TextWidget";
export { InputExpanderModal } from "./TextInputExpanderModal";

View File

@@ -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 || {};

View File

@@ -0,0 +1 @@
export { TimeWidget } from "./TimeWidget";

View File

@@ -0,0 +1,8 @@
export { CheckboxWidget } from "./CheckboxInput";
export { DateWidget } from "./DateInput";
export { DateTimeWidget } from "./DateTimeInput";
export { FileWidget } from "./FileInput";
export { GoogleDrivePickerWidget } from "./GoogleDrivePicker";
export { SelectWidget } from "./SelectInput";
export { default as TextWidget } from "./TextInput";
export { TimeWidget } from "./TimeInput";

View File

@@ -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_%_";

View File

@@ -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 (
<div className="flex flex-col gap-2">
<CredentialFieldTitle
fieldPathId={fieldPathId}
registry={registry}
uiOptions={uiOptions}
schema={schema}
/>
<CredentialsInput
schema={schema as BlockIOCredentialsSubSchema}
selectedCredentials={selectedCredentials}
onSelectCredentials={handleSelectCredentials}
siblingInputs={hardcodedValues}
showTitle={false}
readOnly={formContext?.readOnly}
/>
</div>
);
};

View File

@@ -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 (
<div className="flex items-center gap-2">
<TitleFieldTemplate
id={titleId(fieldPathId ?? "")}
title={credentialProvider ?? ""}
required={true}
schema={schema}
registry={registry}
uiSchema={updatedUiSchema}
/>
<DescriptionFieldTemplate
id={descriptionId(fieldPathId ?? "")}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
</div>
);
};

View File

@@ -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 (
<div>
<GoogleDrivePickerInput
config={config}
value={formData}
onChange={(value) => onChange(value, fieldPathId.path)}
className={uiOptions.className}
showRemoveButton={true}
/>
</div>
);
};

View File

@@ -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<any, RJSFSchema, any>) => 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<string, any>,
);
}

View File

@@ -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
```

View File

@@ -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<T extends Record<string, any>>(
uiSchema: T | undefined,
options: Record<string, any>,
): T & { "ui:options": Record<string, any> } {
return {
...(uiSchema || {}),
"ui:options": {
...uiSchema?.["ui:options"],
...options,
},
} as T & { "ui:options": Record<string, any> };
}
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<T, S, F>;
}) => {
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<T, S, F>;
}) => {
return uiOptions.handleId === undefined;
};
export const isPartOfAnyOf = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
uiOptions,
}: {
uiOptions: UIOptionsType<T, S, F>;
}) => {
return uiOptions.handleId?.endsWith(ANY_OF_FLAG);
};
export const isObjectProperty = <
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({
uiOptions,
schema,
}: {
uiOptions: UIOptionsType<T, S, F>;
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<T, S, F>;
}) => {
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<string, any>,
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;
}

View File

@@ -0,0 +1,3 @@
export { FormRenderer } from "./FormRenderer";
export { default as Form } from "./registry";
export type { ExtendedFormContextType } from "./types";

View File

@@ -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<FormProps> {
const theme: ThemeProps = {
templates: generateBaseTemplates(),
widgets: generateBaseWidgets(),
fields: {
...generateBaseFields(),
...generateCustomFields(),
},
};
return withTheme(theme);
}
export default generateForm();

View File

@@ -0,0 +1,10 @@
export { default, generateForm } from "./Form";
export {
generateBaseFields,
generateBaseTemplates,
generateBaseWidgets,
} from "../base/base-registry";
export {
generateCustomFields,
findCustomFieldId,
} from "../custom/custom-registry";

View File

@@ -0,0 +1,7 @@
import { BlockUIType } from "@/app/(platform)/build/components/types";
export type ExtraContext = {
nodeId?: string;
uiType?: BlockUIType;
size?: "small" | "medium" | "large";
};

View File

@@ -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;
};

View File

@@ -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" },
];
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -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<any, RJSFSchema, any> | 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");
}

Some files were not shown because too many files have changed in this diff Show More