Compare commits

...

13 Commits

Author SHA1 Message Date
Lluis Agusti
383e22da19 chore: wip 2026-01-13 20:04:51 +07:00
Lluis Agusti
8957ecb099 Merge remote-tracking branch 'origin/dev' into fix/run-modal-layout-fixes 2026-01-13 15:37:53 +07:00
Lluis Agusti
d2305d047d chore: wip 2026-01-13 15:37:38 +07:00
Toran Bruce Richards
db8b43bb3d feat(blocks): Add WordPress Get All Posts block and Publish Post draft toggle (#11003)
**Implements issue #11002**

This PR adds WordPress post management functionality and improves error
handling in DataForSEO blocks.

### Changes 🏗️

1. **New WordPress Blocks:**
- Added `WordPressGetAllPostsBlock` - Fetches posts from WordPress sites
with filtering and pagination support
- Enhanced `WordPressCreatePostBlock` with `publish_as_draft` toggle to
control post publication status

2. **WordPress API Enhancements:**
- Added `get_posts()` function in `_api.py` to retrieve posts with
filtering by status
- Added `PostsResponse` model for handling WordPress posts list API
responses
- Support for pagination with `number` and `offset` parameters (max 100
posts per request)

### 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:
  
  **Test Plan:**
- [x] Test `WordPressGetAllPostsBlock` with valid WordPress credentials
  - [x] Verify filtering posts by status (publish, draft, pending, etc.)
  - [x] Test pagination with different number and offset values
- [x] Test `WordPressCreatePostBlock` with publish_as_draft=True to
create draft posts
- [x] Test `WordPressCreatePostBlock` with publish_as_draft=False to
publish posts publicly

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

**Note:** No configuration changes were required for this PR.

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

## Summary by CodeRabbit

* **New Features**
* Added a WordPress “Get All Posts” block to fetch posts with optional
status filtering and pagination; returns total found and post details.
* **Enhancements**
* WordPress “Create Post” block now supports a “Publish as draft”
option, allowing posts to be created as drafts or published immediately.
* WordPress blocks are now surfaced consistently in the block catalog
for easier use.
* **Error Handling**
* Clearer error messages when fetching posts fails, aiding
troubleshooting.

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


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces WordPress post listing and improves post creation and API
robustness.
> 
> - Adds `WordPressGetAllPostsBlock` to fetch posts with optional
`status` filter and pagination (`number`, `offset`); outputs `found`,
`posts`, and streams each `post`
> - Enhances `WordPressCreatePostBlock` with `publish_as_draft` input
and adds `site` to outputs; sets `status` accordingly
> - WordPress API updates in `_api.py`: new `get_posts`, `Post`,
`PostsResponse`, and `normalize_site`; apply
`Requests(raise_for_status=False)` across OAuth/token/info and post
creation; better error propagation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
10be1c4709. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Toran Bruce Richards <Torantulino@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <ntindle@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:57:47 +00:00
Abhimanyu Yadav
923d8baedc feat(frontend): add JsonTextField component for complex nested form data (#11752)
### Changes 🏗️

- Added a new `JsonTextField` component to handle complex nested JSON
types (objects/arrays inside other objects/arrays)
- Created helper functions for JSON parsing, validation, and formatting
- Implemented `useJsonTextField` hook to manage state and validation
- Enhanced `generateUiSchemaForCustomFields` to detect nested complex
types and render them as JSON text fields
- Updated `TextInputExpanderModal` to support JSON-specific styling
- Added `JSON_TEXT_FIELD_ID` constant to custom registry for field
identification

This change improves the user experience by preventing deeply nested
form UIs. Instead, complex nested structures are presented as editable
JSON text fields with proper validation and formatting.

### Before

![Screenshot 2026-01-12 at
1.07.54 PM.png](https://app.graphite.com/user-attachments/assets/dc2b96cc-562a-4e6b-8278-76de941e3bd9.png)

### After

![Screenshot 2026-01-12 at
12.35.19 PM.png](https://app.graphite.com/user-attachments/assets/ea0028a5-c119-43c3-8100-b103484e0b54.png)

### 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 with simple JSON objects in forms
  - [x] Test with nested arrays and objects
  - [x] Test with anyOf/oneOf schemas containing complex types
  - [x] Test the expander modal with JSON content

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

* **New Features**
* New JSON text field with expandable modal editor, inline validation,
and helpful placeholders.
* Complex nested objects/arrays now render as JSON fields to simplify
editing.
* Modal editor uses monospace, smaller text when editing JSON for
improved readability.

* **Chores**
* Added a non-functional runtime debug log (no user-facing behavior
changes).

<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 12:22:41 +00:00
Abhimanyu Yadav
a55b2e02dc feat(frontend): enhance CredentialsInput and CredentialRow components with variant support (#11753)
### Changes 🏗️

- Added a new `variant` prop to `CredentialsInput` component with
options "default" or "node"
- Implemented compact styling for the "node" variant in `CredentialRow`
component
- Modified layout and overflow handling for credential display in node
context
- Added conditional rendering of masked key display based on variant
- Passed the variant prop through the component hierarchy
- Applied the "node" variant to the `CredentialsField` component with
appropriate styling

Before

![Screenshot 2026-01-12 at
4.39.35 PM.png](https://app.graphite.com/user-attachments/assets/2b605b2d-7abf-4e8a-adc5-6a6e8b712ef7.png)

After

![Screenshot 2026-01-12 at
4.55.39 PM.png](https://app.graphite.com/user-attachments/assets/20bb1452-870a-4111-a246-c4e3a3b456ea.png)

### 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 credential selection works correctly in node context
  - [x] Confirmed compact styling is applied properly in node variant
  - [x] Tested overflow handling for long credential names
  - [x] Verified both default and node variants display correctly

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

## Summary by CodeRabbit

* **New Features**
* Credential input and selection components now support multiple
configurable visual variants, enabling better text display handling,
optimized layouts, and improved visual consistency across different
application contexts and specific use cases.

* **Style**
* Credential field displays now feature enhanced text truncation and
overflow management for a more polished and consistent user interface
experience.

<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 12:22:20 +00:00
Abhimanyu Yadav
6b6648b290 feat(frontend): add Table component with TableField renderer for tabular data input (#11751)
### Changes 🏗️

- Added a new `Table` component for handling tabular data input
- Created supporting hooks and helper functions for the Table component
- Added Storybook stories to showcase different Table configurations
- Implemented a custom `TableField` renderer for JSON Schema forms
- Updated type display info to support the new "table" format
- Added schema matcher to detect and render table fields appropriately

![Screenshot 2026-01-12 at
11.29.04 AM.png](https://app.graphite.com/user-attachments/assets/71469d59-469f-4cb0-882b-a49791fe948d.png)

![Screenshot 2026-01-12 at
11.28.54 AM.png](https://app.graphite.com/user-attachments/assets/81193f32-0e16-435e-bb66-5d2aea98266a.png)

### 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 Table component renders correctly with various
configurations
  - [x] Tested adding and removing rows in the Table
- [x] Confirmed data changes are properly tracked and reported via
onChange
  - [x] Verified TableField renderer works with JSON Schema forms
  - [x] Checked that table format is properly detected in the schema

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added a Table component for displaying and editing tabular data with
support for adding/deleting rows, read-only mode, and customizable
labels.
* Added support for rendering array fields as tables in form inputs with
configurable columns and values.

* **Tests**
* Added comprehensive Storybook stories demonstrating various Table
configurations and behaviors.

<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 10:32:14 +00:00
Abhimanyu Yadav
c0a9c0410b feat(frontend): add MultiSelectField component and improve node title cursor styling (#11744)
## Changes 🏗️

- Added a new `MultiSelectField` component for handling multiple boolean
selections in a dropdown format
- Implemented `useMultiSelectField` hook to manage the state and logic
of the multi-select component
- Added support for custom fields in `AnyOfField` by checking if the
option schema matches a custom field
- Added `isMultiSelectSchema` utility function to detect schemas
suitable for the multi-select component
- Added hover cursor styling to node headers to indicate text
editability

![Screenshot 2026-01-10 at
11.15.12 AM.png](https://app.graphite.com/user-attachments/assets/8254497b-604f-4ccc-a40b-eb8994c073b4.png)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified that multi-select fields render correctly in the UI
  - [x] Confirmed that selecting multiple options works as expected
  - [x] Tested that the node header shows the text cursor on hover
- [x] Verified that AnyOf fields correctly use custom field renderers
when applicable

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

* **New Features**
* Added a multi-select field allowing selection of multiple options with
improved selection UI.
* AnyOf options can now resolve and render custom field types, improving
form composition when schemas map to custom controls.

* **Style**
  * Tooltip header cursor updated for clearer hover feedback.

<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 09:48:58 +00:00
Abhimanyu Yadav
17a77b02c7 fix(frontend): exclude schemas with enum from anyOf detection (#11743)
### Changes 🏗️

Fixed the `isAnyOfSchema` function in schema-utils.ts to exclude schemas
that have an `enum` property. This prevents incorrect schema processing
for enums that also have anyOf definitions. Added a console.log
statement in FormRenderer.tsx to help debug schema preprocessing.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified that forms with enum values render correctly
- [x] Confirmed that anyOf schemas are properly identified and processed
- [x] Tested with various schema combinations to ensure the fix doesn't
break existing functionality

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

## Summary by CodeRabbit

## Bug Fixes
* Improved validation logic for form field schemas to correctly handle
edge cases when multiple constraint types are defined.

<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 09:48:47 +00:00
Zamil Majdy
701fce83ca fix(backend): add missing metadata attribute to mock nodes in SmartDecisionMaker tests (#11750)
This PR fixes failing SmartDecisionMaker tests by adding missing
`metadata` attribute to mock nodes.

### Changes 🏗️

Mock nodes in SmartDecisionMaker tests were missing the `metadata = {}`
attribute, which was introduced in commit 4a52b7eca for the
customized_name feature. This caused tests to fail with:

```
TypeError: expected string or bytes-like object, got 'Mock'
```

**Files fixed**:
- `backend/blocks/test/test_smart_decision_maker_dict.py`: Added
`metadata = {}` to mock nodes in all 3 tests
- `backend/blocks/test/test_smart_decision_maker_dynamic_fields.py`:
Added `metadata = {}` to mock nodes in all 8 tests

**Root cause**: The `_create_block_function_signature` method calls
`sink_node.metadata.get("customized_name")`, but mock nodes in tests
didn't have the metadata attribute initialized.

### 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 `poetry run pytest
backend/blocks/test/test_smart_decision_maker_dict.py -xvs` - 3 passed
- [x] Run `poetry run pytest
backend/blocks/test/test_smart_decision_maker_dynamic_fields.py -xvs` -
8 passed
  - [x] All tests pass successfully

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

## Summary by CodeRabbit

## Release Notes

* **Tests**
* Updated test infrastructure to enhance mock object configuration for
improved test reliability and consistency across test suites.

<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-11 17:00:36 -06:00
Zamil Majdy
78d89d0faf Merge branch 'master' of github.com:Significant-Gravitas/AutoGPT into dev 2026-01-11 13:09:23 -06:00
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
Nicholas Tindle
4a52b7eca0 fix(backend): use customized block names in smart decision maker
The SmartDecisionMakerBlock now respects the customized_name field from
node metadata when generating tool function signatures for the LLM.

Previously, the block always used the static block.name from the block
class definition, ignoring any custom names users set in the builder UI.

Changes:
- _create_block_function_signature: Check sink_node.metadata for
  customized_name before falling back to block.name
- _create_agent_function_signature: Check sink_node.metadata for
  customized_name before falling back to sink_graph_meta.name
- Added 4 unit tests for the customized_name feature

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:51:39 -07:00
67 changed files with 3250 additions and 355 deletions

View File

@@ -108,6 +108,9 @@ class CredentialsMetaResponse(BaseModel):
host: str | None = Field(
default=None, description="Host pattern for host-scoped credentials"
)
is_system: bool = Field(
default=False, description="Whether this is a system-managed credential"
)
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
@@ -175,6 +178,8 @@ async def callback(
f"Successfully processed OAuth callback for user {user_id} "
f"and provider {provider.value}"
)
from backend.integrations.credentials_store import is_system_credential
return CredentialsMetaResponse(
id=credentials.id,
provider=credentials.provider,
@@ -185,6 +190,7 @@ async def callback(
host=(
credentials.host if isinstance(credentials, HostScopedCredentials) else None
),
is_system=is_system_credential(credentials.id),
)
@@ -192,6 +198,8 @@ async def callback(
async def list_credentials(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_all_creds(user_id)
return [
CredentialsMetaResponse(
@@ -202,6 +210,7 @@ async def list_credentials(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]
@@ -214,6 +223,8 @@ async def list_credentials_by_provider(
],
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
return [
CredentialsMetaResponse(
@@ -224,6 +235,7 @@ async def list_credentials_by_provider(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]

View File

@@ -391,8 +391,12 @@ class SmartDecisionMakerBlock(Block):
"""
block = sink_node.block
# Use custom name from node metadata if set, otherwise fall back to block.name
custom_name = sink_node.metadata.get("customized_name")
tool_name = custom_name if custom_name else block.name
tool_function: dict[str, Any] = {
"name": SmartDecisionMakerBlock.cleanup(block.name),
"name": SmartDecisionMakerBlock.cleanup(tool_name),
"description": block.description,
}
sink_block_input_schema = block.input_schema
@@ -489,14 +493,24 @@ class SmartDecisionMakerBlock(Block):
f"Sink graph metadata not found: {graph_id} {graph_version}"
)
# Use custom name from node metadata if set, otherwise fall back to graph name
custom_name = sink_node.metadata.get("customized_name")
tool_name = custom_name if custom_name else sink_graph_meta.name
tool_function: dict[str, Any] = {
"name": SmartDecisionMakerBlock.cleanup(sink_graph_meta.name),
"name": SmartDecisionMakerBlock.cleanup(tool_name),
"description": sink_graph_meta.description,
}
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 +520,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 +533,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}
@@ -1147,8 +1161,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

@@ -1057,3 +1057,153 @@ async def test_smart_decision_maker_traditional_mode_default():
) # Should yield individual tool parameters
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
assert "conversations" in outputs
@pytest.mark.asyncio
async def test_smart_decision_maker_uses_customized_name_for_blocks():
"""Test that SmartDecisionMakerBlock uses customized_name from node metadata for tool names."""
from unittest.mock import MagicMock
from backend.blocks.basic import StoreValueBlock
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node with customized_name in metadata
mock_node = MagicMock(spec=Node)
mock_node.id = "test-node-id"
mock_node.block_id = StoreValueBlock().id
mock_node.metadata = {"customized_name": "My Custom Tool Name"}
mock_node.block = StoreValueBlock()
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "input"
# Call the function directly
result = await SmartDecisionMakerBlock._create_block_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the customized name (cleaned up)
assert result["type"] == "function"
assert result["function"]["name"] == "my_custom_tool_name" # Cleaned version
assert result["function"]["_sink_node_id"] == "test-node-id"
@pytest.mark.asyncio
async def test_smart_decision_maker_falls_back_to_block_name():
"""Test that SmartDecisionMakerBlock falls back to block.name when no customized_name."""
from unittest.mock import MagicMock
from backend.blocks.basic import StoreValueBlock
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node without customized_name
mock_node = MagicMock(spec=Node)
mock_node.id = "test-node-id"
mock_node.block_id = StoreValueBlock().id
mock_node.metadata = {} # No customized_name
mock_node.block = StoreValueBlock()
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "input"
# Call the function directly
result = await SmartDecisionMakerBlock._create_block_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the block's default name
assert result["type"] == "function"
assert result["function"]["name"] == "storevalueblock" # Default block name cleaned
assert result["function"]["_sink_node_id"] == "test-node-id"
@pytest.mark.asyncio
async def test_smart_decision_maker_uses_customized_name_for_agents():
"""Test that SmartDecisionMakerBlock uses customized_name from metadata for agent nodes."""
from unittest.mock import AsyncMock, MagicMock, patch
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node with customized_name in metadata
mock_node = MagicMock(spec=Node)
mock_node.id = "test-agent-node-id"
mock_node.metadata = {"customized_name": "My Custom Agent"}
mock_node.input_default = {
"graph_id": "test-graph-id",
"graph_version": 1,
"input_schema": {"properties": {"test_input": {"description": "Test input"}}},
}
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "test_input"
# Mock the database client
mock_graph_meta = MagicMock()
mock_graph_meta.name = "Original Agent Name"
mock_graph_meta.description = "Agent description"
mock_db_client = AsyncMock()
mock_db_client.get_graph_metadata.return_value = mock_graph_meta
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
return_value=mock_db_client,
):
result = await SmartDecisionMakerBlock._create_agent_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the customized name (cleaned up)
assert result["type"] == "function"
assert result["function"]["name"] == "my_custom_agent" # Cleaned version
assert result["function"]["_sink_node_id"] == "test-agent-node-id"
@pytest.mark.asyncio
async def test_smart_decision_maker_agent_falls_back_to_graph_name():
"""Test that agent node falls back to graph name when no customized_name."""
from unittest.mock import AsyncMock, MagicMock, patch
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node without customized_name
mock_node = MagicMock(spec=Node)
mock_node.id = "test-agent-node-id"
mock_node.metadata = {} # No customized_name
mock_node.input_default = {
"graph_id": "test-graph-id",
"graph_version": 1,
"input_schema": {"properties": {"test_input": {"description": "Test input"}}},
}
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "test_input"
# Mock the database client
mock_graph_meta = MagicMock()
mock_graph_meta.name = "Original Agent Name"
mock_graph_meta.description = "Agent description"
mock_db_client = AsyncMock()
mock_db_client.get_graph_metadata.return_value = mock_graph_meta
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
return_value=mock_db_client,
):
result = await SmartDecisionMakerBlock._create_agent_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the graph's default name
assert result["type"] == "function"
assert result["function"]["name"] == "original_agent_name" # Graph name cleaned
assert result["function"]["_sink_node_id"] == "test-agent-node-id"

View File

@@ -15,6 +15,7 @@ async def test_smart_decision_maker_handles_dynamic_dict_fields():
mock_node.block = CreateDictionaryBlock()
mock_node.block_id = CreateDictionaryBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic dictionary fields
mock_links = [
@@ -77,6 +78,7 @@ async def test_smart_decision_maker_handles_dynamic_list_fields():
mock_node.block = AddToListBlock()
mock_node.block_id = AddToListBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic list fields
mock_links = [

View File

@@ -44,6 +44,7 @@ async def test_create_block_function_signature_with_dict_fields():
mock_node.block = CreateDictionaryBlock()
mock_node.block_id = CreateDictionaryBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic dictionary fields (source sanitized, sink original)
mock_links = [
@@ -106,6 +107,7 @@ async def test_create_block_function_signature_with_list_fields():
mock_node.block = AddToListBlock()
mock_node.block_id = AddToListBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic list fields
mock_links = [
@@ -159,6 +161,7 @@ async def test_create_block_function_signature_with_object_fields():
mock_node.block = MatchTextPatternBlock()
mock_node.block_id = MatchTextPatternBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic object fields
mock_links = [
@@ -208,11 +211,13 @@ async def test_create_tool_node_signatures():
mock_dict_node.block = CreateDictionaryBlock()
mock_dict_node.block_id = CreateDictionaryBlock().id
mock_dict_node.input_default = {}
mock_dict_node.metadata = {}
mock_list_node = Mock()
mock_list_node.block = AddToListBlock()
mock_list_node.block_id = AddToListBlock().id
mock_list_node.input_default = {}
mock_list_node.metadata = {}
# Mock links with dynamic fields
dict_link1 = Mock(
@@ -423,6 +428,7 @@ async def test_mixed_regular_and_dynamic_fields():
mock_node.block.name = "TestBlock"
mock_node.block.description = "A test block"
mock_node.block.input_schema = Mock()
mock_node.metadata = {}
# Mock the get_field_schema to return a proper schema for regular fields
def get_field_schema(field_name):

View File

@@ -1,3 +1,3 @@
from .blog import WordPressCreatePostBlock
from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock
__all__ = ["WordPressCreatePostBlock"]
__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"]

View File

@@ -161,7 +161,7 @@ async def oauth_exchange_code_for_tokens(
grant_type="authorization_code",
).model_dump(exclude_none=True)
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL}oauth2/token",
headers=headers,
data=data,
@@ -205,7 +205,7 @@ async def oauth_refresh_tokens(
grant_type="refresh_token",
).model_dump(exclude_none=True)
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL}oauth2/token",
headers=headers,
data=data,
@@ -252,7 +252,7 @@ async def validate_token(
"token": token,
}
response = await Requests().get(
response = await Requests(raise_for_status=False).get(
f"{WORDPRESS_BASE_URL}oauth2/token-info",
params=params,
)
@@ -296,7 +296,7 @@ async def make_api_request(
url = f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}"
request_method = getattr(Requests(), method.lower())
request_method = getattr(Requests(raise_for_status=False), method.lower())
response = await request_method(
url,
headers=headers,
@@ -476,6 +476,7 @@ async def create_post(
data["tags"] = ",".join(str(t) for t in data["tags"])
# Make the API request
site = normalize_site(site)
endpoint = f"/rest/v1.1/sites/{site}/posts/new"
headers = {
@@ -483,7 +484,7 @@ async def create_post(
"Content-Type": "application/x-www-form-urlencoded",
}
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
headers=headers,
data=data,
@@ -499,3 +500,132 @@ async def create_post(
)
error_message = error_data.get("message", response.text)
raise ValueError(f"Failed to create post: {response.status} - {error_message}")
class Post(BaseModel):
"""Response model for individual posts in a posts list response.
This is a simplified version compared to PostResponse, as the list endpoint
returns less detailed information than the create/get single post endpoints.
"""
ID: int
site_ID: int
author: PostAuthor
date: datetime
modified: datetime
title: str
URL: str
short_URL: str
content: str | None = None
excerpt: str | None = None
slug: str
guid: str
status: str
sticky: bool
password: str | None = ""
parent: Union[Dict[str, Any], bool, None] = None
type: str
discussion: Dict[str, Union[str, bool, int]] | None = None
likes_enabled: bool | None = None
sharing_enabled: bool | None = None
like_count: int | None = None
i_like: bool | None = None
is_reblogged: bool | None = None
is_following: bool | None = None
global_ID: str | None = None
featured_image: str | None = None
post_thumbnail: Dict[str, Any] | None = None
format: str | None = None
geo: Union[Dict[str, Any], bool, None] = None
menu_order: int | None = None
page_template: str | None = None
publicize_URLs: List[str] | None = None
terms: Dict[str, Dict[str, Any]] | None = None
tags: Dict[str, Dict[str, Any]] | None = None
categories: Dict[str, Dict[str, Any]] | None = None
attachments: Dict[str, Dict[str, Any]] | None = None
attachment_count: int | None = None
metadata: List[Dict[str, Any]] | None = None
meta: Dict[str, Any] | None = None
capabilities: Dict[str, bool] | None = None
revisions: List[int] | None = None
other_URLs: Dict[str, Any] | None = None
class PostsResponse(BaseModel):
"""Response model for WordPress posts list."""
found: int
posts: List[Post]
meta: Dict[str, Any]
def normalize_site(site: str) -> str:
"""
Normalize a site identifier by stripping protocol and trailing slashes.
Args:
site: Site URL, domain, or ID (e.g., "https://myblog.wordpress.com/", "myblog.wordpress.com", "123456789")
Returns:
Normalized site identifier (domain or ID only)
"""
site = site.strip()
if site.startswith("https://"):
site = site[8:]
elif site.startswith("http://"):
site = site[7:]
return site.rstrip("/")
async def get_posts(
credentials: Credentials,
site: str,
status: PostStatus | None = None,
number: int = 100,
offset: int = 0,
) -> PostsResponse:
"""
Get posts from a WordPress site.
Args:
credentials: OAuth credentials
site: Site ID or domain (e.g., "myblog.wordpress.com" or "123456789")
status: Filter by post status using PostStatus enum, or None for all
number: Number of posts to retrieve (max 100)
offset: Number of posts to skip (for pagination)
Returns:
PostsResponse with the list of posts
"""
site = normalize_site(site)
endpoint = f"/rest/v1.1/sites/{site}/posts"
headers = {
"Authorization": credentials.auth_header(),
}
params: Dict[str, Any] = {
"number": max(1, min(number, 100)), # 1100 posts per request
"offset": offset,
}
if status:
params["status"] = status.value
response = await Requests(raise_for_status=False).get(
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
headers=headers,
params=params,
)
if response.ok:
return PostsResponse.model_validate(response.json())
error_data = (
response.json()
if response.headers.get("content-type", "").startswith("application/json")
else {}
)
error_message = error_data.get("message", response.text)
raise ValueError(f"Failed to get posts: {response.status} - {error_message}")

View File

@@ -9,7 +9,15 @@ from backend.sdk import (
SchemaField,
)
from ._api import CreatePostRequest, PostResponse, PostStatus, create_post
from ._api import (
CreatePostRequest,
Post,
PostResponse,
PostsResponse,
PostStatus,
create_post,
get_posts,
)
from ._config import wordpress
@@ -49,8 +57,15 @@ class WordPressCreatePostBlock(Block):
media_urls: list[str] = SchemaField(
description="URLs of images to sideload and attach to the post", default=[]
)
publish_as_draft: bool = SchemaField(
description="If True, publishes the post as a draft. If False, publishes it publicly.",
default=False,
)
class Output(BlockSchemaOutput):
site: str = SchemaField(
description="The site ID or domain (pass-through for chaining with other blocks)"
)
post_id: int = SchemaField(description="The ID of the created post")
post_url: str = SchemaField(description="The full URL of the created post")
short_url: str = SchemaField(description="The shortened wp.me URL")
@@ -78,7 +93,9 @@ class WordPressCreatePostBlock(Block):
tags=input_data.tags,
featured_image=input_data.featured_image,
media_urls=input_data.media_urls,
status=PostStatus.PUBLISH,
status=(
PostStatus.DRAFT if input_data.publish_as_draft else PostStatus.PUBLISH
),
)
post_response: PostResponse = await create_post(
@@ -87,7 +104,69 @@ class WordPressCreatePostBlock(Block):
post_data=post_request,
)
yield "site", input_data.site
yield "post_id", post_response.ID
yield "post_url", post_response.URL
yield "short_url", post_response.short_URL
yield "post_data", post_response.model_dump()
class WordPressGetAllPostsBlock(Block):
"""
Fetches all posts from a WordPress.com site or Jetpack-enabled site.
Supports filtering by status and pagination.
"""
class Input(BlockSchemaInput):
credentials: CredentialsMetaInput = wordpress.credentials_field()
site: str = SchemaField(
description="Site ID or domain (e.g., 'myblog.wordpress.com' or '123456789')"
)
status: PostStatus | None = SchemaField(
description="Filter by post status, or None for all",
default=None,
)
number: int = SchemaField(
description="Number of posts to retrieve (max 100 per request)", default=20
)
offset: int = SchemaField(
description="Number of posts to skip (for pagination)", default=0
)
class Output(BlockSchemaOutput):
site: str = SchemaField(
description="The site ID or domain (pass-through for chaining with other blocks)"
)
found: int = SchemaField(description="Total number of posts found")
posts: list[Post] = SchemaField(
description="List of post objects with their details"
)
post: Post = SchemaField(
description="Individual post object (yielded for each post)"
)
def __init__(self):
super().__init__(
id="97728fa7-7f6f-4789-ba0c-f2c114119536",
description="Fetch all posts from WordPress.com or Jetpack sites",
categories={BlockCategory.SOCIAL},
input_schema=self.Input,
output_schema=self.Output,
)
async def run(
self, input_data: Input, *, credentials: Credentials, **kwargs
) -> BlockOutput:
posts_response: PostsResponse = await get_posts(
credentials=credentials,
site=input_data.site,
status=input_data.status,
number=input_data.number,
offset=input_data.offset,
)
yield "site", input_data.site
yield "found", posts_response.found
yield "posts", posts_response.posts
for post in posts_response.posts:
yield "post", post

View File

@@ -245,6 +245,13 @@ DEFAULT_CREDENTIALS = [
webshare_proxy_credentials,
]
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
def is_system_credential(credential_id: str) -> bool:
"""Check if a credential ID belongs to a system-managed credential."""
return credential_id in SYSTEM_CREDENTIAL_IDS
class IntegrationCredentialsStore:
def __init__(self):

View File

@@ -3,6 +3,13 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
serverExternalPackages: [
"@opentelemetry/instrumentation",
"@opentelemetry/sdk-node",
"import-in-the-middle",
"require-in-the-middle",
],
experimental: {
serverActions: {
bodySizeLimit: "256mb",

View File

@@ -117,6 +117,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.2",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5",
"@storybook/addon-docs": "9.1.5",
@@ -140,6 +141,7 @@
"eslint": "8.57.1",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"orval": "7.13.0",
@@ -147,7 +149,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"require-in-the-middle": "7.5.2",
"require-in-the-middle": "8.0.1",
"storybook": "9.1.5",
"tailwindcss": "3.4.17",
"typescript": "5.9.3"

View File

@@ -270,6 +270,9 @@ importers:
'@chromatic-com/storybook':
specifier: 4.1.2
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@opentelemetry/instrumentation':
specifier: 0.209.0
version: 0.209.0(@opentelemetry/api@1.9.0)
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
@@ -339,6 +342,9 @@ importers:
eslint-plugin-storybook:
specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 2.0.2
version: 2.0.2
msw:
specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -361,8 +367,8 @@ importers:
specifier: 0.7.1
version: 0.7.1(prettier@3.6.2)
require-in-the-middle:
specifier: 7.5.2
version: 7.5.2
specifier: 8.0.1
version: 8.0.1
storybook:
specifier: 9.1.5
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
@@ -1547,6 +1553,10 @@ packages:
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
@@ -1701,6 +1711,12 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.209.0':
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/redis-common@0.38.2':
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -4957,8 +4973,8 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@2.0.1:
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==}
import-in-the-middle@2.0.2:
resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -6502,10 +6518,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -8720,6 +8732,10 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs@0.209.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)':
@@ -8920,7 +8936,16 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
import-in-the-middle: 2.0.1
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.209.0
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -9100,7 +9125,7 @@ snapshots:
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -9944,7 +9969,7 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.38.0
'@sentry/core': 10.27.0
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.1
import-in-the-middle: 2.0.2
transitivePeerDependencies:
- supports-color
@@ -9983,7 +10008,7 @@ snapshots:
'@sentry/core': 10.27.0
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.1
import-in-the-middle: 2.0.2
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
@@ -12792,7 +12817,7 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@2.0.1:
import-in-the-middle@2.0.2:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
@@ -14631,14 +14656,6 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3

View File

@@ -68,7 +68,10 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<div>
<Text variant="large-semibold" className="line-clamp-1">
<Text
variant="large-semibold"
className="line-clamp-1 hover:cursor-text"
>
{beautifyString(title).replace("Block", "").trim()}
</Text>
</div>

View File

@@ -89,6 +89,18 @@ export function extractOptions(
// get display type and color for schema types [need for type display next to field name]
export const getTypeDisplayInfo = (schema: any) => {
if (
schema?.type === "array" &&
"format" in schema &&
schema.format === "table"
) {
return {
displayType: "table",
colorClass: "!text-indigo-500",
hexColor: "#6366f1",
};
}
if (schema?.type === "string" && schema?.format) {
const formatMap: Record<
string,

View File

@@ -1,32 +1,38 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/molecules/Alert/Alert";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
import { useAgentMissingCredentials } from "./hooks/useAgentMissingCredentials";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
@@ -45,7 +51,6 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
@@ -63,6 +68,10 @@ export function NewAgentLibraryView() {
} = useMarketplaceUpdate({ agent });
const [changelogOpen, setChangelogOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const { hasMissingCredentials, isLoading: isLoadingCredentials } =
useAgentMissingCredentials(agent);
useEffect(() => {
if (agent) {
@@ -137,13 +146,33 @@ export function NewAgentLibraryView() {
return (
<>
<div className="flex h-full flex-col">
<div className="mx-6 pt-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<div className="mx-6 flex flex-col gap-4 pt-4">
<div className="flex items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<AgentSettingsModal agent={agent} />
</div>
{hasMissingCredentials && !isLoadingCredentials && (
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
)}
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
@@ -154,6 +183,13 @@ export function NewAgentLibraryView() {
/>
</div>
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>
@@ -164,37 +200,49 @@ export function NewAgentLibraryView() {
<>
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
{hasMissingCredentials && !isLoadingCredentials && (
<div className={cn("mb-4", AGENT_LIBRARY_SECTION_PADDING_X)}>
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
</div>
)}
<div
className={cn(
"border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<div className="flex items-center gap-2">
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="flex-1"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
</div>
<SidebarRunsList
@@ -208,12 +256,7 @@ export function NewAgentLibraryView() {
</SectionWrap>
{activeItem ? (
activeItem === "settings" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
@@ -246,8 +289,6 @@ export function NewAgentLibraryView() {
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/>
)
) : sidebarLoading ? (
@@ -287,6 +328,13 @@ export function NewAgentLibraryView() {
</SelectedViewLayout>
)}
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>

View File

@@ -4,6 +4,7 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { isSystemCredential } from "../CredentialsInputs/helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -71,6 +72,7 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
if (isSystemCredential(credential)) return null;
return (
<CredentialsInput

View File

@@ -0,0 +1,131 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { GearIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { useAgentSystemCredentials } from "../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "../../selected-views/SelectedSettingsView/components/SystemCredentialRow";
interface Props {
agent: LibraryAgent;
controlledOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AgentSettingsModal({
agent,
controlledOpen,
onOpenChange,
}: Props) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
function setIsOpen(open: boolean) {
if (onOpenChange) {
onOpenChange(open);
} else {
setInternalIsOpen(open);
}
}
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const { hasSystemCredentials, systemCredentials } =
useAgentSystemCredentials(agent);
// Only show credential fields that have system credentials
const credentialFieldsWithSystemCreds = useMemo(() => {
return systemCredentials.map((item) => ({
fieldKey: item.key,
schema: item.schema,
systemCredential: item.credential,
}));
}, [systemCredentials]);
const hasAnySettings = hasHITLBlocks || hasSystemCredentials;
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
title="Agent Settings"
>
{controlledOpen === undefined && (
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
</Dialog.Trigger>
)}
<Dialog.Content>
<div className="space-y-6">
{hasHITLBlocks && (
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
<Text variant="large-semibold">Require human approval</Text>
<Text variant="large" className="mt-1 text-zinc-900">
The agent will pause and wait for your review before
continuing
</Text>
</div>
<Switch
checked={currentSafeMode || false}
onCheckedChange={handleToggle}
disabled={isPending}
className="mt-1"
/>
</div>
</div>
)}
{hasSystemCredentials && (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent
to access various services. You can switch to your own
credentials if preferred.
</Text>
</div>
<div className="w-full space-y-4">
{credentialFieldsWithSystemCreds.map(
({ fieldKey, schema, systemCredential }) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={schema}
systemCredential={systemCredential}
/>
),
)}
</div>
</div>
)}
{!hasAnySettings && (
<div className="py-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -10,11 +10,10 @@ import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers";
import { isSystemCredential } from "./helpers";
import {
CredentialsInputState,
useCredentialsInput,
@@ -36,6 +35,8 @@ type Props = {
readOnly?: boolean;
isOptional?: boolean;
showTitle?: boolean;
variant?: "default" | "node";
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function CredentialsInput({
@@ -48,6 +49,8 @@ export function CredentialsInput({
readOnly = false,
isOptional = false,
showTitle = true,
variant = "default",
allowSystemCredentials = false,
}: Props) {
const hookData = useCredentialsInput({
schema,
@@ -57,6 +60,7 @@ export function CredentialsInput({
onLoaded,
readOnly,
isOptional,
allowSystemCredentials,
});
if (!isLoaded(hookData)) {
@@ -77,21 +81,22 @@ export function CredentialsInput({
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
const selectedCredentialIsSystem =
selectedCredential && isSystemCredential(selectedCredential);
if (readOnly && selectedCredentialIsSystem) {
return null;
}
return (
<div className={cn("mb-6", className)}>
@@ -123,6 +128,7 @@ export function CredentialsInput({
onClearCredential={() => onSelectCredential(undefined)}
readOnly={readOnly}
allowNone={isOptional}
variant={variant}
/>
) : (
<div className="mb-4 space-y-2">
@@ -134,15 +140,6 @@ export function CredentialsInput({
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
@@ -226,13 +223,6 @@ export function CredentialsInput({
Error: {oAuthError}
</Text>
) : null}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
)}
</div>

View File

@@ -1,11 +1,11 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
} from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -60,7 +60,10 @@ export function APIKeyCredentialsModal({
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 px-2"
>
<FormField
control={form.control}
name="apiKey"
@@ -70,8 +73,7 @@ export function APIKeyCredentialsModal({
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API key..."
size="small"
placeholder="Enter API Key..."
hint={
schema.credentials_scopes ? (
<FormDescription>
@@ -98,8 +100,7 @@ export function APIKeyCredentialsModal({
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API key..."
size="small"
placeholder="Enter a name for this API Key..."
{...field}
/>
)}
@@ -113,13 +114,12 @@ export function APIKeyCredentialsModal({
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
<Button type="submit" className="min-w-68">
Add API Key
</Button>
</form>
</Form>

View File

@@ -26,10 +26,12 @@ type CredentialRowProps = {
provider: string;
displayName: string;
onSelect: () => void;
onDelete: () => void;
onDelete?: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
/** When "node", applies compact styling for node context */
variant?: "default" | "node";
};
export function CredentialRow({
@@ -41,14 +43,22 @@ export function CredentialRow({
readOnly = false,
showCaret = false,
asSelectTrigger = false,
variant = "default",
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const isNodeVariant = variant === "node";
return (
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
asSelectTrigger && isNodeVariant
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: asSelectTrigger
? "border-0 bg-transparent"
: readOnly
? "w-fit"
: "",
)}
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
style={
@@ -61,24 +71,36 @@ export function CredentialRow({
<ProviderIcon className="h-3 w-3 text-white" />
</div>
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
isNodeVariant && "overflow-hidden",
)}
>
<Text
variant="body"
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
className={cn(
"tracking-tight",
isNodeVariant
? "truncate"
: "line-clamp-1 flex-[0_0_50%] text-ellipsis",
)}
>
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="lex-[0_0_40%] relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
{!(asSelectTrigger && isNodeVariant) && (
<Text
variant="large"
className="relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
)}
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && (
{!readOnly && !showCaret && !asSelectTrigger && onDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
@@ -26,6 +27,8 @@ interface Props {
onClearCredential?: () => void;
readOnly?: boolean;
allowNone?: boolean;
/** When "node", applies compact styling for node context */
variant?: "default" | "node";
}
export function CredentialsSelect({
@@ -37,6 +40,7 @@ export function CredentialsSelect({
onClearCredential,
readOnly = false,
allowNone = true,
variant = "default",
}: Props) {
// Auto-select first credential if none is selected (only if allowNone is false)
useEffect(() => {
@@ -59,7 +63,12 @@ export function CredentialsSelect({
value={selectedCredentials?.id || (allowNone ? "__none__" : "")}
onValueChange={handleValueChange}
>
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
>
{selectedCredentials ? (
<SelectValue key={selectedCredentials.id} asChild>
<CredentialRow
@@ -75,8 +84,42 @@ export function CredentialsSelect({
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
variant={variant}
/>
</SelectValue>
) : allowNone ? (
<SelectValue key="__none__" asChild>
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
variant === "node"
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: "border-0 bg-transparent",
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200">
<Text
variant="body"
className="text-xs font-medium text-zinc-500"
>
</Text>
</div>
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
variant === "node" && "overflow-hidden",
)}
>
<Text
variant="body"
className={cn("tracking-tight text-zinc-500")}
>
None (skip this credential)
</Text>
</div>
</div>
</SelectValue>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}

View File

@@ -100,3 +100,29 @@ export function getCredentialDisplayName(
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;
export function isSystemCredential(credential: {
title?: string | null;
is_system?: boolean;
}): boolean {
if (credential.is_system === true) return true;
if (!credential.title) return false;
const titleLower = credential.title.toLowerCase();
return (
titleLower.includes("system") ||
titleLower.startsWith("use credits for") ||
titleLower.includes("use credits")
);
}
export function filterSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => !isSystemCredential(cred));
}
export function getSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => isSystemCredential(cred));
}

View File

@@ -6,9 +6,11 @@ import {
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
filterSystemCredentials,
getActionButtonText,
getSystemCredentials,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
@@ -23,6 +25,7 @@ type Params = {
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
isOptional?: boolean;
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function useCredentialsInput({
@@ -33,6 +36,7 @@ export function useCredentialsInput({
onLoaded,
readOnly = false,
isOptional = false,
allowSystemCredentials = false,
}: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
@@ -54,6 +58,7 @@ export function useCredentialsInput({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const hasAttemptedAutoSelect = useRef(false);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -82,13 +87,22 @@ export function useCredentialsInput({
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
const availableCreds = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
if (
selectedCredential &&
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
!availableCreds.some((c) => c.id === selectedCredential.id)
) {
onSelectCredential(undefined);
}
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
}, [
credentials,
selectedCredential,
onSelectCredential,
readOnly,
allowSystemCredentials,
]);
// The available credential, if there is only one
const singleCredential = useMemo(() => {
@@ -96,24 +110,111 @@ export function useCredentialsInput({
return null;
}
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
const credsToUse = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
return credsToUse.length === 1 ? credsToUse[0] : null;
}, [credentials, allowSystemCredentials]);
// Auto-select the one available credential (only if not optional)
// Auto-select the one available credential
// Prioritize system credentials if available
// For system credentials, always auto-select even if optional (they should be used by default)
useEffect(() => {
if (readOnly) return;
if (isOptional) return; // Don't auto-select when credential is optional
if (singleCredential && !selectedCredential) {
if (!credentials || !("savedCredentials" in credentials)) return;
// Early return if already selected to prevent infinite loops
const currentSelectedId = selectedCredential?.id;
if (currentSelectedId) {
hasAttemptedAutoSelect.current = true;
return;
}
// If selectedCredential is explicitly undefined and isOptional is true,
// don't auto-select - this could mean "None" was explicitly selected
// The parent component should handle setting the initial value
if (selectedCredential === undefined && isOptional) {
// Mark as attempted to prevent auto-selection when "None" is a valid choice
hasAttemptedAutoSelect.current = true;
return;
}
// Only attempt auto-selection once per credential load
if (hasAttemptedAutoSelect.current) return;
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
const savedCreds = credentials.savedCredentials;
const systemCreds = getSystemCredentials(savedCreds);
// Filter system credentials by type and scopes (same logic as useCredentials)
const matchingSystemCreds = systemCreds.filter((cred) => {
// Check type match
if (!supportedTypes.includes(cred.type)) {
return false;
}
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
return false;
}
}
return true;
});
// First, try to auto-select system credential if available
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
const credProvider = credentials.provider;
hasAttemptedAutoSelect.current = true;
onSelectCredential({
id: systemCred.id,
type: systemCred.type,
provider: credProvider,
title: (systemCred as any).title,
});
return;
}
// Otherwise, auto-select single credential if there's only one (and not optional)
if (!isOptional && singleCredential) {
hasAttemptedAutoSelect.current = true;
onSelectCredential(singleCredential);
}
}, [
singleCredential,
selectedCredential,
onSelectCredential,
singleCredential?.id, // Only depend on the ID, not the whole object
selectedCredential?.id, // Only depend on the ID, not the whole object
readOnly,
isOptional,
credentials,
schema.credentials_types,
schema.credentials_scopes,
// Note: onSelectCredential removed from deps to prevent infinite loops
// It should be stable, but if it's not, the ref will prevent multiple calls
]);
// Reset the ref when credentials change significantly
useEffect(() => {
if (credentials && "savedCredentials" in credentials) {
hasAttemptedAutoSelect.current = false;
}
}, [
credentials && "savedCredentials" in credentials
? credentials.savedCredentials.length
: 0,
credentials && "savedCredentials" in credentials
? credentials.provider
: null,
]);
if (
@@ -137,6 +238,11 @@ export function useCredentialsInput({
oAuthCallback,
} = credentials;
// Filter system credentials unless explicitly allowed (for settings)
const filteredCredentials = allowSystemCredentials
? savedCredentials
: filterSystemCredentials(savedCredentials);
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -291,7 +397,7 @@ export function useCredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow: savedCredentials,
credentialsToShow: filteredCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
@@ -306,7 +412,7 @@ export function useCredentialsInput({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
savedCredentials.length > 0,
filteredCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,

View File

@@ -12,7 +12,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
@@ -82,6 +82,8 @@ export function RunAgentModal({
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
@@ -89,6 +91,43 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger");
useEffect(() => {
if (!isOpen) return;
function checkOverflow() {
if (!contentRef.current) return;
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
setHasOverflow(
scrollableParent.scrollHeight > scrollableParent.clientHeight,
);
}
}
const timeoutId = setTimeout(checkOverflow, 100);
const resizeObserver = new ResizeObserver(checkOverflow);
if (contentRef.current) {
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
resizeObserver.observe(scrollableParent);
}
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [
isOpen,
hasAnySetupFields,
agentInputFields,
agentCredentialsInputFields,
]);
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -134,91 +173,97 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
{/* Header */}
<ModalHeader agent={agent} />
<div ref={contentRef} className="flex min-h-full flex-col">
<div className="flex-1">
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10 pb-32">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
</div>
) : null}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials before
scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
<Dialog.Footer
className={`sticky bottom-0 z-10 bg-white pt-4 ${
hasOverflow
? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
: ""
}`}
>
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials
before scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={isExecuting || isSettingUpTrigger}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</Dialog.Footer>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>

View File

@@ -1,6 +1,16 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useContext, useMemo } from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
isSystemCredential,
} from "../../../CredentialsInputs/helpers";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
@@ -22,8 +32,44 @@ export function ModalRunSection() {
agentCredentialsInputFields,
} = useRunAgentModalContext();
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Only show credential fields that have user credentials (NOT system credentials)
// System credentials should only be shown in settings, not in run modal
const credentialFields = useMemo(() => {
if (!allProviders || !agentCredentialsInputFields) return [];
return Object.entries(agentCredentialsInputFields).filter(
([_key, schema]) => {
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has user credentials (NOT system credentials)
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, show this field
if (matchingUserCreds.length > 0) {
return true;
}
}
// Hide the field if only system credentials exist (or no credentials at all)
return false;
},
);
}, [agentCredentialsInputFields, allProviders]);
// Get the list of required credentials from the schema
const requiredCredentials = new Set(
@@ -98,22 +144,113 @@ export function ModalRunSection() {
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
{credentialFields
.map(([key, inputSubSchema]) => {
const selectedCred = inputCredentials?.[key];
// Check if the selected credential is a system credential
// First check the credential object itself, then look it up in providers
let isSystemCredSelected = false;
if (selectedCred) {
// Check if credential object has is_system or title indicates system credential
isSystemCredSelected = isSystemCredential(
selectedCred as { title?: string; is_system?: boolean },
);
// If not detected by title/is_system, check by looking up in providers
if (
!isSystemCredSelected &&
selectedCred.id &&
allProviders
) {
const providerNames =
inputSubSchema.credentials_provider || [];
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = providerData.savedCredentials.filter(
(cred: any) => cred.is_system === true,
);
if (
systemCreds.some(
(cred: any) => cred.id === selectedCred.id,
)
) {
isSystemCredSelected = true;
break;
}
}
}
selectedCredentials={inputCredentials?.[key]}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
// If a system credential is selected, check if there are user credentials available
// If not, hide this field entirely (it will still be used for execution)
if (isSystemCredSelected) {
const providerNames =
inputSubSchema.credentials_provider || [];
const supportedTypes = inputSubSchema.credentials_types || [];
const hasUserCreds = providerNames.some(
(providerName: string) => {
const providerData = allProviders?.[providerName];
if (!providerData) return false;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
return userCreds.some((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
},
);
// If no user credentials available, hide the field completely
if (!hasUserCreds) {
return null;
}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
),
)}
}
// If a system credential is selected but user creds exist, don't show it in the UI
// (it will still be used for execution, but user can select a user credential instead)
const credToDisplay = isSystemCredSelected
? undefined
: selectedCred;
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={credToDisplay}
onSelectCredentials={(value) => {
// When user selects a credential, update the state and save to preferences
setInputCredentialsValue(key, value);
// Save to preferences store
if (value === undefined) {
store.setCredentialPreference(
agent.id.toString(),
key,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
store.setCredentialPreference(
agent.id.toString(),
key,
null,
);
} else {
store.setCredentialPreference(
agent.id.toString(),
key,
value,
);
}
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
})
.filter(Boolean)}
</div>
</ModalSection>
) : null}

View File

@@ -11,9 +11,25 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../CredentialsInputs/helpers";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
@@ -42,8 +58,10 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -58,6 +76,198 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
// Initialize credentials from saved preferences or default system credentials
// This ensures credentials are used even when the field is not displayed
useEffect(() => {
if (!allProviders || !agent.credentials_input_schema?.properties) return;
if (callbacks?.initialInputCredentials) {
hasInitializedSystemCreds.current = true;
return; // Don't override if initial credentials provided
}
if (hasInitializedSystemCreds.current) return; // Already initialized
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
// Use functional update to get current state and avoid stale closures
setInputCredentials((currentCreds) => {
const credsToAdd: Record<string, any> = {};
for (const [key, schema] of Object.entries(properties)) {
// Skip if already set
if (currentCreds[key]) continue;
// First, check if user has a saved preference
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// Check if "None" was explicitly selected (special marker)
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - don't add any credential
continue;
}
if (savedPreference) {
credsToAdd[key] = savedPreference;
continue;
}
// Otherwise, find default system credentials for this field
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's exactly one system credential, use it as default
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
credsToAdd[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break; // Use first matching provider
}
}
}
// Only update if we found credentials to add
if (Object.keys(credsToAdd).length > 0) {
hasInitializedSystemCreds.current = true;
return {
...currentCreds,
...credsToAdd,
};
}
return currentCreds; // No changes
});
}, [
allProviders,
agent.credentials_input_schema,
agent.id,
store,
callbacks?.initialInputCredentials,
]);
// Sync credentials with preferences store when modal opens
useEffect(() => {
if (!isOpen || !allProviders || !agent.credentials_input_schema?.properties)
return;
if (callbacks?.initialInputCredentials) return; // Don't override if initial credentials provided
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
setInputCredentials((currentCreds) => {
const updatedCreds: Record<string, any> = { ...currentCreds };
for (const [key, schema] of Object.entries(properties)) {
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - remove from credentials
delete updatedCreds[key];
} else if (savedPreference) {
// User has a saved preference - use it
updatedCreds[key] = savedPreference;
} else if (!updatedCreds[key]) {
// No preference and no current credential - try to find default system credential
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
updatedCreds[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break;
}
}
}
}
return updatedCreds;
});
}, [
isOpen,
agent.id,
agent.credentials_input_schema,
allProviders,
store,
callbacks?.initialInputCredentials,
]);
// Reset initialization flag when modal closes/opens or agent changes
useEffect(() => {
hasInitializedSystemCreds.current = false;
}, [isOpen, agent.graph_id]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
@@ -169,15 +379,70 @@ export function useAgentRunModal(
(agent.credentials_input_schema?.required as string[]) || [],
);
// Filter out credential fields that only have system credentials available
// System credentials should not be required in the run modal
// Also check if user has a saved preference (including NONE_MARKER)
const requiredCredentialsToCheck = [...requiredCredentials].filter(
(key) => {
// Check if user has a saved preference first
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// If "None" was explicitly selected, don't require it
if (savedPreference === NONE_CREDENTIAL_MARKER) {
return false;
}
// If user has a saved preference, it should be checked
if (savedPreference) {
return true;
}
const schema = agentCredentialsInputFields[key];
if (!schema || !allProviders) return true; // If we can't check, include it
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has non-system credentials available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, this field should be checked
if (matchingUserCreds.length > 0) {
return true;
}
}
// If only system credentials are available, exclude from required check
return false;
},
);
// Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set
const missing = [...requiredCredentials].filter((key) => {
const missing = requiredCredentialsToCheck.filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
});
return [missing.length === 0, missing];
}, [agent.credentials_input_schema, inputCredentials]);
}, [
agent.credentials_input_schema,
agentCredentialsInputFields,
inputCredentials,
allProviders,
agent.id,
store,
]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,

View File

@@ -1,37 +1,17 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
return null;
}
export function AgentSettingsButton() {
return (
<Button
variant={selected ? "secondary" : "ghost"}
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings"
>
<GearIcon
size={18}
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
);
}

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() {

View File

@@ -20,6 +20,7 @@ import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useAgentMissingCredentials } from "../../hooks/useAgentMissingCredentials";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
@@ -44,6 +45,7 @@ export function EmptyTasks({
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { hasMissingCredentials } = useAgentMissingCredentials(agent);
async function handleDeleteAgent() {
if (!agent.id) return;
@@ -124,6 +126,7 @@ export function EmptyTasks({
variant="primary"
size="large"
className="inline-flex w-[19.75rem]"
disabled={hasMissingCredentials}
>
Setup your task
</Button>

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() {

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {

View File

@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
interface MarketplaceBannersProps {
interface Props {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating,
onUpdate,
onPublish,
}: MarketplaceBannersProps) {
}: Props) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (

View File

@@ -1,3 +1,5 @@
"use client";
import { cn } from "@/lib/utils";
type Props = {

View File

@@ -1,22 +1,16 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props {
agent: LibraryAgent;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function LoadingSelectedContent(props: Props) {
return (
<SelectedViewLayout
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selectedSettings={props.selectedSettings}
>
<SelectedViewLayout agent={props.agent}>
<div
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
>

View File

@@ -33,8 +33,6 @@ interface Props {
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedRunView({
@@ -43,8 +41,6 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -84,12 +80,7 @@ export function SelectedRunView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<SelectedViewLayout agent={agent} banner={banner}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />

View File

@@ -21,8 +21,6 @@ interface Props {
scheduleId: string;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedScheduleView({
@@ -30,8 +28,6 @@ export function SelectedScheduleView({
scheduleId,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { schedule, isLoading, error } = useSelectedScheduleView(
agent.graph_id,
@@ -76,12 +72,7 @@ export function SelectedScheduleView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<SelectedViewLayout agent={agent} banner={banner}>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader

View File

@@ -1,11 +1,12 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SystemCredentialsSection } from "./components/SystemCredentialsSection";
interface Props {
agent: LibraryAgent;
@@ -16,8 +17,12 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const hasCredentialsSchema =
agent.credentials_input_schema &&
Object.keys(agent.credentials_input_schema.properties || {}).length > 0;
return (
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}>
<SelectedViewLayout agent={agent}>
<div className="flex flex-col gap-4">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
@@ -33,15 +38,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
<Text variant="h2">Agent Settings</Text>
</div>
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
{!hasHITLBlocks ? (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any human-in-the-loop blocks, so
there are no settings to configure.
</Text>
</div>
) : (
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
{hasHITLBlocks && (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
@@ -60,6 +58,16 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
</div>
</div>
)}
{hasCredentialsSchema && <SystemCredentialsSection agent={agent} />}
{!hasHITLBlocks && !hasCredentialsSchema && (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</div>
</SelectedViewLayout>

View File

@@ -0,0 +1,99 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useEffect, useState } from "react";
import { CredentialsInput } from "../../../../components/modals/CredentialsInputs/CredentialsInputs";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../stores/agentCredentialPreferencesStore";
interface Props {
credentialKey: string;
agentId: string;
schema: any;
systemCredential: CredentialsMetaResponse;
}
export function SystemCredentialRow({
credentialKey,
agentId,
schema,
systemCredential,
}: Props) {
const store = useAgentCredentialPreferencesStore();
// Initialize with saved preference or default to system credential
const savedPreference = store.getCredentialPreference(agentId, credentialKey);
const defaultCredential = {
id: systemCredential.id,
type: systemCredential.type,
provider: systemCredential.provider,
title: systemCredential.title,
};
// If saved preference is the NONE marker, use undefined (which CredentialsInput interprets as "None")
// Otherwise use saved preference or default
const [selectedCredential, setSelectedCredential] = useState<any>(
savedPreference === NONE_CREDENTIAL_MARKER
? undefined
: savedPreference || defaultCredential,
);
// Update when preference changes externally
useEffect(() => {
const preference = store.getCredentialPreference(agentId, credentialKey);
if (preference === NONE_CREDENTIAL_MARKER) {
setSelectedCredential(undefined);
} else if (preference) {
setSelectedCredential(preference);
} else {
setSelectedCredential(defaultCredential);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentialKey, agentId]);
const providerName = schema.credentials_provider?.[0] || "";
const displayName = toDisplayName(providerName);
function handleSelectCredentials(value: any) {
setSelectedCredential(value);
// Save preference:
// - undefined = explicitly selected "None" (save NONE_CREDENTIAL_MARKER)
// - null = use default system credential (fallback behavior, save null)
// - credential object = use this specific credential
if (value === undefined) {
// User explicitly selected "None" - save special marker
store.setCredentialPreference(
agentId,
credentialKey,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
// User cleared selection - use default system credential
store.setCredentialPreference(agentId, credentialKey, null);
} else {
// User selected a credential
store.setCredentialPreference(agentId, credentialKey, value);
}
}
return (
<div className="rounded-lg border border-zinc-100 bg-zinc-50/50 px-4 pb-2 pt-4">
<Text variant="body-medium" className="mb-2 ml-2">
{displayName}
</Text>
<CredentialsInput
schema={{ ...schema, discriminator: undefined }}
selectedCredentials={selectedCredential}
onSelectCredentials={handleSelectCredentials}
showTitle={false}
isOptional
allowSystemCredentials={true}
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSystemCredentials } from "../../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "./SystemCredentialRow";
interface Props {
agent: LibraryAgent;
}
export function SystemCredentialsSection({ agent }: Props) {
const { hasSystemCredentials, systemCredentials, isLoading } =
useAgentSystemCredentials(agent);
if (isLoading) {
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="text-muted-foreground">
Loading credentials...
</Text>
</div>
);
}
if (!hasSystemCredentials) return null;
// Group by credential field key (from schema) to show one row per field
const credentialsByField = systemCredentials.reduce(
(acc, item) => {
if (!acc[item.key]) {
acc[item.key] = item;
}
return acc;
},
{} as Record<string, (typeof systemCredentials)[0]>,
);
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent to
access various services. You can switch to your own credentials if
preferred.
</Text>
</div>
<div className="w-full space-y-4">
{Object.entries(credentialsByField).map(([fieldKey, item]) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={item.schema}
systemCredential={item.credential}
/>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
import { SectionWrap } from "../other/SectionWrap";
interface Props {
@@ -9,8 +9,6 @@ interface Props {
children: React.ReactNode;
banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedViewLayout(props: Props) {
@@ -19,8 +17,8 @@ export function SelectedViewLayout(props: Props) {
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
{props.banner && <div className="mb-4">{props.banner}</div>}
<div className="relative flex w-fit items-center gap-2">
{props.banner}
<div className="relative flex w-full items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
@@ -33,15 +31,9 @@ export function SelectedViewLayout(props: Props) {
: []),
]}
/>
{props.agent && props.onSelectSettings && (
<div className="absolute -right-8">
<AgentSettingsButton
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}
<div className="absolute right-0">
<AgentSettingsModal agent={props.agent} />
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">

View File

@@ -0,0 +1,109 @@
"use client";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { useCallback, useEffect, useState } from "react";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type AgentCredentialPreferences = Record<
string,
CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER
>;
const STORAGE_KEY_PREFIX = "agent_credential_prefs_";
function getStorageKey(agentId: string): string {
return `${STORAGE_KEY_PREFIX}${agentId}`;
}
function loadPreferences(agentId: string): AgentCredentialPreferences {
const key = getStorageKey(agentId);
const stored = storage.get(key as any);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
// Convert serialized NONE markers back to the constant
const result: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(parsed)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true
) {
result[key] = NONE_CREDENTIAL_MARKER;
} else {
result[key] = value as CredentialsMetaInput | null;
}
}
return result;
} catch {
return {};
}
}
function savePreferences(
agentId: string,
preferences: AgentCredentialPreferences,
): void {
const key = getStorageKey(agentId);
storage.set(key as any, JSON.stringify(preferences));
}
export function useAgentCredentialPreferences(agentId: string) {
const [preferences, setPreferences] = useState<AgentCredentialPreferences>(
() => loadPreferences(agentId),
);
useEffect(() => {
const loaded = loadPreferences(agentId);
setPreferences(loaded);
}, [agentId]);
const setCredentialPreference = useCallback(
(
credentialKey: string,
credential: CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER,
) => {
setPreferences((prev) => {
const updated = {
...prev,
[credentialKey]: credential,
};
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
const getCredentialPreference = useCallback(
(
credentialKey: string,
): CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER => {
return preferences[credentialKey] ?? null;
},
[preferences],
);
const clearPreference = useCallback(
(credentialKey: string) => {
setPreferences((prev) => {
const updated = { ...prev };
delete updated[credentialKey];
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
return {
preferences,
setCredentialPreference,
getCredentialPreference,
clearPreference,
};
}

View File

@@ -0,0 +1,105 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import { getSystemCredentials } from "../components/modals/CredentialsInputs/helpers";
/**
* Hook to check if an agent is missing required SYSTEM credentials.
* This is only used to block "New Task" buttons.
* User credential validation is handled separately in RunAgentModal.
*/
export function useAgentMissingCredentials(
agent: LibraryAgent | null | undefined,
) {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
if (
!agent ||
!agent.id ||
!allProviders ||
!agent.credentials_input_schema?.properties
) {
return {
hasMissingCredentials: false,
missingCredentials: [],
isLoading: !allProviders || !agent,
};
}
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const missingCredentials: Array<{
key: string;
providerDisplayName: string;
}> = [];
for (const [key, schema] of Object.entries(properties)) {
const isRequired = requiredCredentials.has(key);
if (!isRequired) continue; // Only check required credentials
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
let hasSystemCredential = false;
// Check if any provider has a system credential available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's a system credential available, it's not missing
if (matchingSystemCreds.length > 0) {
hasSystemCredential = true;
break;
}
}
// If no system credential available, mark as missing
if (!hasSystemCredential) {
const providerName = providerNames[0] || "";
missingCredentials.push({
key,
providerDisplayName: toDisplayName(providerName),
});
}
}
return {
hasMissingCredentials: missingCredentials.length > 0,
missingCredentials,
isLoading: false,
};
}, [allProviders, agent?.credentials_input_schema, agent?.id]);
return result;
}

View File

@@ -0,0 +1,130 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import {
CredentialsProviderData,
CredentialsProvidersContext,
} from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../components/modals/CredentialsInputs/helpers";
interface SystemCredentialInfo {
key: string;
provider: string;
schema: any;
credential: CredentialsMetaResponse;
}
interface MissingCredentialInfo {
key: string;
provider: string;
providerDisplayName: string;
}
interface UseAgentSystemCredentialsResult {
hasSystemCredentials: boolean;
systemCredentials: SystemCredentialInfo[];
hasMissingSystemCredentials: boolean;
missingSystemCredentials: MissingCredentialInfo[];
isLoading: boolean;
}
export function useAgentSystemCredentials(
agent: LibraryAgent,
): UseAgentSystemCredentialsResult {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
const empty = {
hasSystemCredentials: false,
systemCredentials: [],
hasMissingSystemCredentials: false,
missingSystemCredentials: [],
isLoading: false,
};
if (!agent.credentials_input_schema?.properties) return empty;
if (!allProviders) return { ...empty, isLoading: true };
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const systemCredentials: SystemCredentialInfo[] = [];
const missingSystemCredentials: MissingCredentialInfo[] = [];
for (const [key, schema] of Object.entries(properties)) {
const providerNames = schema.credentials_provider || [];
const isRequired = requiredCredentials.has(key);
const supportedTypes = schema.credentials_types || [];
for (const providerName of providerNames) {
const providerData: CredentialsProviderData | undefined =
allProviders[providerName];
if (!providerData) {
// Provider not loaded yet - don't mark as missing, wait for load
continue;
}
// Check for system credentials - now backend always returns them with is_system: true
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// Add system credentials if they exist (even if not configured, backend returns them)
for (const cred of matchingSystemCreds) {
systemCredentials.push({
key,
provider: providerName,
schema,
credential: cred,
});
}
// Only mark as missing if it's required AND there are NO credentials available
// (neither system nor user). This is a true "missing" state.
// Note: We don't block based on this anymore since the run modal
// has its own validation (allRequiredInputsAreSet)
if (
isRequired &&
matchingSystemCreds.length === 0 &&
matchingUserCreds.length === 0
) {
missingSystemCredentials.push({
key,
provider: providerName,
providerDisplayName: toDisplayName(providerName),
});
}
}
}
return {
hasSystemCredentials: systemCredentials.length > 0,
systemCredentials,
hasMissingSystemCredentials: missingSystemCredentials.length > 0,
missingSystemCredentials,
isLoading: false,
};
}, [agent.credentials_input_schema, allProviders]);
return result;
}

View File

@@ -0,0 +1,135 @@
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type CredentialPreference =
| CredentialsMetaInput
| null
| typeof NONE_CREDENTIAL_MARKER;
type AgentCredentialPreferences = Record<string, CredentialPreference>;
interface AgentCredentialPreferencesStore {
preferences: Record<string, AgentCredentialPreferences>; // agentId -> preferences
setCredentialPreference: (
agentId: string,
credentialKey: string,
credential: CredentialPreference,
) => void;
getCredentialPreference: (
agentId: string,
credentialKey: string,
) => CredentialPreference;
clearPreference: (agentId: string, credentialKey: string) => void;
}
const STORAGE_KEY = "agent_credential_preferences";
// Custom storage adapter for localStorage
const customStorage = {
getItem: (name: string): string | null => {
return storage.get(name as any) || null;
},
setItem: (name: string, value: string): void => {
storage.set(name as any, value);
},
removeItem: (name: string): void => {
storage.clean(name as any);
},
};
export const useAgentCredentialPreferencesStore =
create<AgentCredentialPreferencesStore>()(
persist(
(set, get) => ({
preferences: {},
setCredentialPreference: (agentId, credentialKey, credential) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = {
...state.preferences,
[agentId]: {
...agentPrefs,
[credentialKey]: credential,
},
};
return { preferences: updated };
});
},
getCredentialPreference: (agentId, credentialKey) => {
const state = get();
const pref = state.preferences[agentId]?.[credentialKey];
// Convert serialized NONE marker back to constant
if (
pref &&
typeof pref === "object" &&
"__none__" in pref &&
(pref as any).__none__ === true &&
pref !== NONE_CREDENTIAL_MARKER
) {
return NONE_CREDENTIAL_MARKER;
}
return pref ?? null;
},
clearPreference: (agentId, credentialKey) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = { ...agentPrefs };
delete updated[credentialKey];
return {
preferences: {
...state.preferences,
[agentId]: updated,
},
};
});
},
}),
{
name: STORAGE_KEY,
storage: createJSONStorage(() => customStorage),
// Transform on rehydrate to convert NONE markers
onRehydrateStorage: () => (state, error) => {
if (error || !state) {
console.error("Failed to rehydrate credential preferences:", error);
return;
}
// Convert serialized NONE markers back to constant
const converted: Record<string, AgentCredentialPreferences> = {};
for (const [agentId, prefs] of Object.entries(
state.preferences || {},
)) {
const convertedPrefs: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(prefs)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true &&
value !== NONE_CREDENTIAL_MARKER
) {
convertedPrefs[key] = NONE_CREDENTIAL_MARKER;
} else {
convertedPrefs[key] = value as CredentialPreference;
}
}
converted[agentId] = convertedPrefs;
}
// Update state with converted preferences
if (
Object.keys(converted).length > 0 ||
Object.keys(state.preferences || {}).length > 0
) {
state.preferences = converted;
}
},
},
),
);

View File

@@ -6792,6 +6792,12 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Host",
"description": "Host pattern for host-scoped credentials"
},
"is_system": {
"type": "boolean",
"title": "Is System",
"description": "Whether this is a system-managed credential",
"default": false
}
},
"type": "object",

View File

@@ -20,6 +20,7 @@ export function Button(props: ButtonProps) {
rightIcon,
children,
as = "button",
asChild: _asChild, // Destructure to prevent passing to DOM
...restProps
} = props;

View File

@@ -49,7 +49,12 @@ export function DrawerWrap({
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : null}
) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<Drawer.Title>{title}</Drawer.Title>
</span>
)}
{!isForceOpen ? (
title ? (

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import { Table } from "./Table";
const meta = {
title: "Molecules/Table",
component: Table,
decorators: [
(Story) => (
<TooltipProvider>
<Story />
</TooltipProvider>
),
],
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
allowAddRow: {
control: "boolean",
description: "Whether to show the Add row button",
},
allowDeleteRow: {
control: "boolean",
description: "Whether to show delete buttons for each row",
},
readOnly: {
control: "boolean",
description:
"Whether the table is read-only (renders text instead of inputs)",
},
addRowLabel: {
control: "text",
description: "Label for the Add row button",
},
},
} satisfies Meta<typeof Table>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
columns: ["name", "email", "role"],
allowAddRow: true,
allowDeleteRow: true,
},
};
export const WithDefaultValues: Story = {
args: {
columns: ["name", "email", "role"],
defaultValues: [
{ name: "John Doe", email: "john@example.com", role: "Admin" },
{ name: "Jane Smith", email: "jane@example.com", role: "User" },
{ name: "Bob Wilson", email: "bob@example.com", role: "Editor" },
],
allowAddRow: true,
allowDeleteRow: true,
},
};
export const ReadOnly: Story = {
args: {
columns: ["name", "email"],
defaultValues: [
{ name: "John Doe", email: "john@example.com" },
{ name: "Jane Smith", email: "jane@example.com" },
],
readOnly: true,
},
};
export const NoAddOrDelete: Story = {
args: {
columns: ["name", "email"],
defaultValues: [
{ name: "John Doe", email: "john@example.com" },
{ name: "Jane Smith", email: "jane@example.com" },
],
allowAddRow: false,
allowDeleteRow: false,
},
};
export const SingleColumn: Story = {
args: {
columns: ["item"],
allowAddRow: true,
allowDeleteRow: true,
addRowLabel: "Add item",
},
};
export const CustomAddLabel: Story = {
args: {
columns: ["key", "value"],
allowAddRow: true,
allowDeleteRow: true,
addRowLabel: "Add new entry",
},
};
export const KeyValuePairs: Story = {
args: {
columns: ["key", "value"],
defaultValues: [
{ key: "API_KEY", value: "sk-..." },
{ key: "DATABASE_URL", value: "postgres://..." },
],
allowAddRow: true,
allowDeleteRow: true,
addRowLabel: "Add variable",
},
};

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import {
Table as BaseTable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTable, RowData } from "./useTable";
import { formatColumnTitle, formatPlaceholder } from "./helpers";
export interface TableProps {
columns: string[];
defaultValues?: RowData[];
onChange?: (rows: RowData[]) => void;
allowAddRow?: boolean;
allowDeleteRow?: boolean;
addRowLabel?: string;
className?: string;
readOnly?: boolean;
}
export function Table({
columns,
defaultValues,
onChange,
allowAddRow = true,
allowDeleteRow = true,
addRowLabel = "Add row",
className,
readOnly = false,
}: TableProps) {
const { rows, handleAddRow, handleDeleteRow, handleCellChange } = useTable({
columns,
defaultValues,
onChange,
});
const showDeleteColumn = allowDeleteRow && !readOnly;
const showAddButton = allowAddRow && !readOnly;
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="overflow-hidden rounded-xl border border-zinc-200 bg-white">
<BaseTable>
<TableHeader>
<TableRow className="border-b border-zinc-100 bg-zinc-50/50">
{columns.map((column) => (
<TableHead
key={column}
className="h-10 px-3 text-sm font-medium text-zinc-600"
>
{formatColumnTitle(column)}
</TableHead>
))}
{showDeleteColumn && <TableHead className="w-[50px]" />}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={rowIndex} className="border-none">
{columns.map((column) => (
<TableCell key={`${rowIndex}-${column}`} className="p-2">
{readOnly ? (
<Text
variant="body"
className="px-3 py-2 text-sm text-zinc-800"
>
{row[column] || "-"}
</Text>
) : (
<Input
id={`table-${rowIndex}-${column}`}
label={formatColumnTitle(column)}
hideLabel
value={row[column] ?? ""}
onChange={(e) =>
handleCellChange(rowIndex, column, e.target.value)
}
placeholder={formatPlaceholder(column)}
size="small"
wrapperClassName="mb-0"
/>
)}
</TableCell>
))}
{showDeleteColumn && (
<TableCell className="p-2">
<Button
variant="icon"
size="icon"
onClick={() => handleDeleteRow(rowIndex)}
aria-label="Delete row"
className="text-zinc-400 transition-colors hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))}
{showAddButton && (
<TableRow>
<TableCell
colSpan={columns.length + (showDeleteColumn ? 1 : 0)}
className="p-2"
>
<Button
variant="outline"
size="small"
onClick={handleAddRow}
leftIcon={<Plus className="h-4 w-4" />}
className="w-fit"
>
{addRowLabel}
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</BaseTable>
</div>
</div>
);
}
export { type RowData } from "./useTable";

View File

@@ -0,0 +1,7 @@
export const formatColumnTitle = (key: string): string => {
return key.charAt(0).toUpperCase() + key.slice(1);
};
export const formatPlaceholder = (key: string): string => {
return `Enter ${key.toLowerCase()}`;
};

View File

@@ -0,0 +1,81 @@
import { useState, useEffect } from "react";
export type RowData = Record<string, string>;
interface UseTableOptions {
columns: string[];
defaultValues?: RowData[];
onChange?: (rows: RowData[]) => void;
}
export function useTable({
columns,
defaultValues,
onChange,
}: UseTableOptions) {
const createEmptyRow = (): RowData => {
const emptyRow: RowData = {};
columns.forEach((column) => {
emptyRow[column] = "";
});
return emptyRow;
};
const [rows, setRows] = useState<RowData[]>(() => {
if (defaultValues && defaultValues.length > 0) {
return defaultValues;
}
return [];
});
useEffect(() => {
if (defaultValues !== undefined) {
setRows(defaultValues);
}
}, [defaultValues]);
const updateRows = (newRows: RowData[]) => {
setRows(newRows);
onChange?.(newRows);
};
const handleAddRow = () => {
const newRows = [...rows, createEmptyRow()];
updateRows(newRows);
};
const handleDeleteRow = (rowIndex: number) => {
const newRows = rows.filter((_, index) => index !== rowIndex);
updateRows(newRows);
};
const handleCellChange = (
rowIndex: number,
columnKey: string,
value: string,
) => {
const newRows = rows.map((row, index) => {
if (index === rowIndex) {
return {
...row,
[columnKey]: value,
};
}
return row;
});
updateRows(newRows);
};
const clearAll = () => {
updateRows([]);
};
return {
rows,
handleAddRow,
handleDeleteRow,
handleCellChange,
clearAll,
createEmptyRow,
};
}

View File

@@ -30,6 +30,8 @@ export const FormRenderer = ({
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]);
console.log("preprocessedSchema", preprocessedSchema);
return (
<div className={"mb-6 mt-4"}>
<Form

View File

@@ -5,19 +5,14 @@ import { useAnyOfField } from "./useAnyOfField";
import { getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants";
import { findCustomFieldId } from "../../registry";
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,
@@ -26,6 +21,15 @@ export const AnyOfField = (props: FieldProps) => {
field_id,
} = useAnyOfField(props);
const parentCustomFieldId = findCustomFieldId(schema);
if (parentCustomFieldId) {
return null;
}
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
const handleId = getHandleId({
uiOptions,
id: field_id + ANY_OF_FLAG,
@@ -40,12 +44,21 @@ export const AnyOfField = (props: FieldProps) => {
const isHandleConnected = isInputConnected(nodeId, handleId);
// Now anyOf can render - custom fields if the option schema matches a custom field
const optionCustomFieldId = optionSchema
? findCustomFieldId(optionSchema)
: null;
const optionUiSchema = optionCustomFieldId
? { ...updatedUiSchema, "ui:field": optionCustomFieldId }
: updatedUiSchema;
const optionsSchemaField =
(optionSchema && optionSchema.type !== "null" && (
<_SchemaField
{...props}
schema={optionSchema}
uiSchema={updatedUiSchema}
uiSchema={optionUiSchema}
/>
)) ||
null;

View File

@@ -17,6 +17,7 @@ interface InputExpanderModalProps {
defaultValue: string;
description?: string;
placeholder?: string;
inputType?: "text" | "json";
}
export const InputExpanderModal: FC<InputExpanderModalProps> = ({
@@ -27,6 +28,7 @@ export const InputExpanderModal: FC<InputExpanderModalProps> = ({
defaultValue,
description,
placeholder,
inputType = "text",
}) => {
const [tempValue, setTempValue] = useState(defaultValue);
const [isCopied, setIsCopied] = useState(false);
@@ -78,7 +80,10 @@ export const InputExpanderModal: FC<InputExpanderModalProps> = ({
hideLabel
id="input-expander-modal"
value={tempValue}
className="!min-h-[300px] rounded-2xlarge"
className={cn(
"!min-h-[300px] rounded-2xlarge",
inputType === "json" && "font-mono text-sm",
)}
onChange={(e) => setTempValue(e.target.value)}
placeholder={placeholder || "Enter text..."}
autoFocus

View File

@@ -88,6 +88,8 @@ export const CredentialsField = (props: FieldProps) => {
showTitle={false}
readOnly={formContext?.readOnly}
isOptional={!isRequired}
className="w-full"
variant="node"
/>
{/* Optional credentials toggle - only show in builder canvas, not run dialogs */}

View File

@@ -0,0 +1,124 @@
"use client";
import { FieldProps, getTemplate, getUiOptions } from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { ArrowsOutIcon } from "@phosphor-icons/react";
import { InputExpanderModal } from "../../base/standard/widgets/TextInput/TextInputExpanderModal";
import { getHandleId, updateUiOption } from "../../helpers";
import { useJsonTextField } from "./useJsonTextField";
import { getPlaceholder } from "./helpers";
export const JsonTextField = (props: FieldProps) => {
const {
formData,
onChange,
schema,
registry,
uiSchema,
required,
name,
fieldPathId,
} = props;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const fieldId = fieldPathId?.$id ?? props.id ?? "json-field";
const handleId = getHandleId({
uiOptions,
id: fieldId,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
const {
textValue,
isModalOpen,
handleChange,
handleModalOpen,
handleModalClose,
handleModalSave,
} = useJsonTextField({
formData,
onChange,
path: fieldPathId?.path,
});
const placeholder = getPlaceholder(schema);
const title = schema.title || name || "JSON Value";
return (
<div className="flex flex-col gap-2">
<TitleFieldTemplate
id={fieldId}
title={title}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
<div className="nodrag relative flex items-center gap-2">
<Input
id={fieldId}
hideLabel={true}
type="textarea"
label=""
size="small"
wrapperClassName="mb-0 flex-1 "
value={textValue}
onChange={handleChange}
placeholder={placeholder}
required={required}
disabled={props.disabled}
className="min-h-[60px] pr-8 font-mono text-xs"
/>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleModalOpen}
type="button"
className="p-1"
>
<ArrowsOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand input</TooltipContent>
</Tooltip>
</div>
{schema.description && (
<span className="text-xs text-gray-500">{schema.description}</span>
)}
<InputExpanderModal
isOpen={isModalOpen}
onClose={handleModalClose}
onSave={handleModalSave}
title={`Edit ${title}`}
description={schema.description || "Enter valid JSON"}
defaultValue={textValue}
placeholder={placeholder}
inputType="json"
/>
</div>
);
};
export default JsonTextField;

View File

@@ -0,0 +1,67 @@
import { RJSFSchema } from "@rjsf/utils";
/**
* Converts form data to a JSON string for display
* @param formData - The data to stringify
* @returns JSON string or empty string if data is null/undefined
*/
export function stringifyFormData(formData: unknown): string {
if (formData === undefined || formData === null) {
return "";
}
try {
return JSON.stringify(formData, null, 2);
} catch {
return "";
}
}
/**
* Parses a JSON string into an object/array
* @param value - The JSON string to parse
* @returns Parsed value or undefined if parsing fails or empty
*/
export function parseJsonValue(value: string): unknown | undefined {
const trimmed = value.trim();
if (trimmed === "") {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
/**
* Gets the appropriate placeholder text based on schema type
* @param schema - The JSON schema
* @returns Placeholder string
*/
export function getPlaceholder(schema: RJSFSchema): string {
if (schema.type === "array") {
return '["item1", "item2"] or [{"key": "value"}]';
}
if (schema.type === "object") {
return '{"key": "value"}';
}
return "Enter JSON value...";
}
/**
* Checks if a JSON string is valid
* @param value - The JSON string to validate
* @returns true if valid JSON, false otherwise
*/
export function isValidJson(value: string): boolean {
if (value.trim() === "") {
return true; // Empty is considered valid (will be undefined)
}
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from "react";
import { FieldProps } from "@rjsf/utils";
import { stringifyFormData, parseJsonValue, isValidJson } from "./helpers";
type FieldOnChange = FieldProps["onChange"];
type FieldPathId = FieldProps["fieldPathId"];
interface UseJsonTextFieldOptions {
formData: unknown;
onChange: FieldOnChange;
path?: FieldPathId["path"];
}
interface UseJsonTextFieldReturn {
textValue: string;
isModalOpen: boolean;
hasError: boolean;
handleChange: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
handleModalOpen: () => void;
handleModalClose: () => void;
handleModalSave: (value: string) => void;
}
/**
* Custom hook for managing JSON text field state and handlers
*/
export function useJsonTextField({
formData,
onChange,
path,
}: UseJsonTextFieldOptions): UseJsonTextFieldReturn {
const [textValue, setTextValue] = useState(() => stringifyFormData(formData));
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasError, setHasError] = useState(false);
// Update text value when formData changes externally
useEffect(() => {
const newValue = stringifyFormData(formData);
setTextValue(newValue);
setHasError(false);
}, [formData]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setTextValue(value);
// Validate JSON and update error state
const valid = isValidJson(value);
setHasError(!valid);
// Try to parse and update formData
if (value.trim() === "") {
onChange(undefined, path ?? []);
return;
}
const parsed = parseJsonValue(value);
if (parsed !== undefined) {
onChange(parsed, path ?? []);
}
},
[onChange, path],
);
const handleModalOpen = useCallback(() => {
setIsModalOpen(true);
}, []);
const handleModalClose = useCallback(() => {
setIsModalOpen(false);
}, []);
const handleModalSave = useCallback(
(value: string) => {
setTextValue(value);
setIsModalOpen(false);
// Validate and update
const valid = isValidJson(value);
setHasError(!valid);
if (value.trim() === "") {
onChange(undefined, path ?? []);
return;
}
const parsed = parseJsonValue(value);
if (parsed !== undefined) {
onChange(parsed, path ?? []);
}
},
[onChange, path],
);
return {
textValue,
isModalOpen,
hasError,
handleChange,
handleModalOpen,
handleModalClose,
handleModalSave,
};
}

View File

@@ -0,0 +1,57 @@
import React from "react";
import { FieldProps, getUiOptions } from "@rjsf/utils";
import { BlockIOObjectSubSchema } from "@/lib/autogpt-server-api/types";
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from "@/components/__legacy__/ui/multiselect";
import { cn } from "@/lib/utils";
import { useMultiSelectField } from "./useMultiSelectField";
export const MultiSelectField = (props: FieldProps) => {
const { schema, formData, onChange, fieldPathId } = props;
const uiOptions = getUiOptions(props.uiSchema);
const { optionSchema, options, selection, createChangeHandler } =
useMultiSelectField({
schema: schema as BlockIOObjectSubSchema,
formData,
});
const handleValuesChange = createChangeHandler(onChange, fieldPathId);
const displayName = schema.title || "options";
return (
<div className={cn("flex flex-col", uiOptions.className)}>
<MultiSelector
className="nodrag"
values={selection}
onValuesChange={handleValuesChange}
>
<MultiSelectorTrigger className="rounded-3xl border border-zinc-200 bg-white px-2 shadow-none">
<MultiSelectorInput
placeholder={
(schema as any).placeholder ?? `Select ${displayName}...`
}
/>
</MultiSelectorTrigger>
<MultiSelectorContent className="nowheel">
<MultiSelectorList>
{options
.map((key) => ({ ...optionSchema[key], key }))
.map(({ key, title, description }) => (
<MultiSelectorItem key={key} value={key} title={description}>
{title ?? key}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
);
};

View File

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

View File

@@ -0,0 +1,65 @@
import { FieldProps } from "@rjsf/utils";
import { BlockIOObjectSubSchema } from "@/lib/autogpt-server-api/types";
type FormData = Record<string, boolean> | null | undefined;
interface UseMultiSelectFieldOptions {
schema: BlockIOObjectSubSchema;
formData: FormData;
}
export function useMultiSelectField({
schema,
formData,
}: UseMultiSelectFieldOptions) {
const getOptionSchema = (): Record<string, BlockIOObjectSubSchema> => {
if (schema.properties) {
return schema.properties as Record<string, BlockIOObjectSubSchema>;
}
if (
"anyOf" in schema &&
Array.isArray(schema.anyOf) &&
schema.anyOf.length > 0 &&
"properties" in schema.anyOf[0]
) {
return (schema.anyOf[0] as BlockIOObjectSubSchema).properties as Record<
string,
BlockIOObjectSubSchema
>;
}
return {};
};
const optionSchema = getOptionSchema();
const options = Object.keys(optionSchema);
const getSelection = (): string[] => {
if (!formData || typeof formData !== "object") {
return [];
}
return Object.entries(formData)
.filter(([_, value]) => value === true)
.map(([key]) => key);
};
const selection = getSelection();
const createChangeHandler =
(
onChange: FieldProps["onChange"],
fieldPathId: FieldProps["fieldPathId"],
) =>
(values: string[]) => {
const newValue = Object.fromEntries(
options.map((opt) => [opt, values.includes(opt)]),
);
onChange(newValue, fieldPathId?.path);
};
return {
optionSchema,
options,
selection,
createChangeHandler,
};
}

View File

@@ -0,0 +1,52 @@
import { descriptionId, FieldProps, getTemplate, titleId } from "@rjsf/utils";
import { Table, RowData } from "@/components/molecules/Table/Table";
import { useMemo } from "react";
export const TableField = (props: FieldProps) => {
const { schema, formData, onChange, fieldPathId, registry, uiSchema } = props;
const itemSchema = schema.items as any;
const properties = itemSchema?.properties || {};
const columns: string[] = useMemo(() => {
return Object.keys(properties);
}, [properties]);
const handleChange = (rows: RowData[]) => {
onChange(rows, fieldPathId?.path.slice(0, -1));
};
const TitleFieldTemplate = getTemplate("TitleFieldTemplate", registry);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
);
return (
<div className="flex flex-col gap-2">
<TitleFieldTemplate
id={titleId(fieldPathId)}
title={schema.title || ""}
required={true}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
<DescriptionFieldTemplate
id={descriptionId(fieldPathId)}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
<Table
columns={columns}
defaultValues={formData}
onChange={handleChange}
allowAddRow={true}
allowDeleteRow={true}
addRowLabel="Add row"
/>
</div>
);
};

View File

@@ -1,6 +1,10 @@
import { FieldProps, RJSFSchema, RegistryFieldsType } from "@rjsf/utils";
import { CredentialsField } from "./CredentialField/CredentialField";
import { GoogleDrivePickerField } from "./GoogleDrivePickerField/GoogleDrivePickerField";
import { JsonTextField } from "./JsonTextField/JsonTextField";
import { MultiSelectField } from "./MultiSelectField/MultiSelectField";
import { isMultiSelectSchema } from "../utils/schema-utils";
import { TableField } from "./TableField/TableField";
export interface CustomFieldDefinition {
id: string;
@@ -8,6 +12,9 @@ export interface CustomFieldDefinition {
component: (props: FieldProps<any, RJSFSchema, any>) => JSX.Element | null;
}
/** Field ID for JsonTextField - used to render nested complex types as text input */
export const JSON_TEXT_FIELD_ID = "custom/json_text_field";
export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
{
id: "custom/credential_field",
@@ -30,6 +37,28 @@ export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
},
component: GoogleDrivePickerField,
},
{
id: "custom/json_text_field",
// Not matched by schema - assigned via uiSchema for nested complex types
matcher: () => false,
component: JsonTextField,
},
{
id: "custom/multi_select_field",
matcher: isMultiSelectSchema,
component: MultiSelectField,
},
{
id: "custom/table_field",
matcher: (schema: any) => {
return (
schema.type === "array" &&
"format" in schema &&
schema.format === "table"
);
},
component: TableField,
},
];
export function findCustomFieldId(schema: any): string | null {

View File

@@ -1,19 +1,46 @@
import { RJSFSchema, UiSchema } from "@rjsf/utils";
import { findCustomFieldId } from "../custom/custom-registry";
import {
findCustomFieldId,
JSON_TEXT_FIELD_ID,
} from "../custom/custom-registry";
function isComplexType(schema: RJSFSchema): boolean {
return schema.type === "object" || schema.type === "array";
}
function hasComplexAnyOfOptions(schema: RJSFSchema): boolean {
const options = schema.anyOf || schema.oneOf;
if (!Array.isArray(options)) return false;
return options.some(
(opt: any) =>
opt &&
typeof opt === "object" &&
(opt.type === "object" || opt.type === "array"),
);
}
/**
* Generates uiSchema with ui:field settings for custom fields based on schema matchers.
* This is the standard RJSF way to route fields to custom components.
*
* Nested complex types (arrays/objects inside arrays/objects) are rendered as JsonTextField
* to avoid deeply nested form UIs. Users can enter raw JSON for these fields.
*
* @param schema - The JSON schema
* @param existingUiSchema - Existing uiSchema to merge with
* @param insideComplexType - Whether we're already inside a complex type (object/array)
*/
export function generateUiSchemaForCustomFields(
schema: RJSFSchema,
existingUiSchema: UiSchema = {},
insideComplexType: boolean = false,
): UiSchema {
const uiSchema: UiSchema = { ...existingUiSchema };
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (propSchema && typeof propSchema === "object") {
// First check for custom field matchers (credentials, google drive, etc.)
const customFieldId = findCustomFieldId(propSchema);
if (customFieldId) {
@@ -21,8 +48,33 @@ export function generateUiSchemaForCustomFields(
...(uiSchema[key] as object),
"ui:field": customFieldId,
};
// Skip further processing for custom fields
continue;
}
// Handle nested complex types - render as JsonTextField
if (insideComplexType && isComplexType(propSchema as RJSFSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": JSON_TEXT_FIELD_ID,
};
// Don't recurse further - this field is now a text input
continue;
}
// Handle anyOf/oneOf inside complex types
if (
insideComplexType &&
hasComplexAnyOfOptions(propSchema as RJSFSchema)
) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": JSON_TEXT_FIELD_ID,
};
continue;
}
// Recurse into object properties
if (
propSchema.type === "object" &&
propSchema.properties &&
@@ -31,6 +83,7 @@ export function generateUiSchemaForCustomFields(
const nestedUiSchema = generateUiSchemaForCustomFields(
propSchema as RJSFSchema,
(uiSchema[key] as UiSchema) || {},
true, // Now inside a complex type
);
uiSchema[key] = {
...(uiSchema[key] as object),
@@ -38,9 +91,11 @@ export function generateUiSchemaForCustomFields(
};
}
// Handle arrays
if (propSchema.type === "array" && propSchema.items) {
const itemsSchema = propSchema.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
// Check for custom field on array items
const itemsCustomFieldId = findCustomFieldId(itemsSchema);
if (itemsCustomFieldId) {
uiSchema[key] = {
@@ -49,10 +104,28 @@ export function generateUiSchemaForCustomFields(
"ui:field": itemsCustomFieldId,
},
};
} else if (isComplexType(itemsSchema)) {
// Array items that are complex types become JsonTextField
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (hasComplexAnyOfOptions(itemsSchema)) {
// Array items with anyOf containing complex types become JsonTextField
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (itemsSchema.properties) {
// Recurse into object items (but they're now inside a complex type)
const itemsUiSchema = generateUiSchemaForCustomFields(
itemsSchema,
((uiSchema[key] as UiSchema)?.items as UiSchema) || {},
true, // Inside complex type (array)
);
if (Object.keys(itemsUiSchema).length > 0) {
uiSchema[key] = {
@@ -63,6 +136,61 @@ export function generateUiSchemaForCustomFields(
}
}
}
// Handle anyOf/oneOf at root level - process complex options
if (!insideComplexType) {
const anyOfOptions = propSchema.anyOf || propSchema.oneOf;
if (Array.isArray(anyOfOptions)) {
for (let i = 0; i < anyOfOptions.length; i++) {
const option = anyOfOptions[i] as RJSFSchema;
if (option && typeof option === "object") {
// Handle anyOf array options with complex items
if (option.type === "array" && option.items) {
const itemsSchema = option.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
// Array items that are complex types become JsonTextField
if (isComplexType(itemsSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (hasComplexAnyOfOptions(itemsSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
}
}
}
// Recurse into anyOf object options with properties
if (
option.type === "object" &&
option.properties &&
typeof option.properties === "object"
) {
const optionUiSchema = generateUiSchemaForCustomFields(
option,
{},
true, // Inside complex type (anyOf object option)
);
if (Object.keys(optionUiSchema).length > 0) {
// Store under the property key - RJSF will apply it
uiSchema[key] = {
...(uiSchema[key] as object),
...optionUiSchema,
};
}
}
}
}
}
}
}
}
}

View File

@@ -1,7 +1,11 @@
import { getUiOptions, RJSFSchema, UiSchema } from "@rjsf/utils";
export function isAnyOfSchema(schema: RJSFSchema | undefined): boolean {
return Array.isArray(schema?.anyOf) && schema!.anyOf.length > 0;
return (
Array.isArray(schema?.anyOf) &&
schema!.anyOf.length > 0 &&
schema?.enum === undefined
);
}
export const isAnyOfChild = (
@@ -33,3 +37,21 @@ export function isOptionalType(schema: RJSFSchema | undefined): {
export function isAnyOfSelector(name: string) {
return name.includes("anyof_select");
}
export function isMultiSelectSchema(schema: RJSFSchema | undefined): boolean {
if (typeof schema !== "object" || schema === null) {
return false;
}
if ("anyOf" in schema || "oneOf" in schema) {
return false;
}
return !!(
schema.type === "object" &&
schema.properties &&
Object.values(schema.properties).every(
(prop: any) => prop.type === "boolean",
)
);
}

View File

@@ -593,6 +593,7 @@ export type CredentialsMetaResponse = {
scopes?: Array<string>;
username?: string;
host?: string;
is_system?: boolean;
};
/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */

View File

@@ -1,5 +1,4 @@
import { createContext, useCallback, useEffect, useState } from "react";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import {
APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse,
@@ -10,8 +9,9 @@ import {
UserPasswordCredentials,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { createContext, useCallback, useEffect, useState } from "react";
type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials,
@@ -72,6 +72,8 @@ export default function CredentialsProvider({
const api = useBackendAPI();
const onFailToast = useToastOnFail();
console.log("providers", providers);
const addCredentials = useCallback(
(
provider: CredentialsProviderName,
@@ -218,17 +220,7 @@ export default function CredentialsProvider({
[api, onFailToast],
);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("load provider names"));
}, [api, onFailToast]);
useEffect(() => {
const loadCredentials = useCallback(() => {
if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders({});
return;
@@ -288,6 +280,20 @@ export default function CredentialsProvider({
onFailToast,
]);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("Load provider names"));
}, [api, onFailToast]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
return (
<CredentialsProvidersContext.Provider value={providers}>
{children}